fix(web): add button to load older chat messages reliably (#1223)

This commit is contained in:
Carlos Florêncio
2026-06-01 19:12:49 +01:00
committed by GitHub
parent 0f87f7a56b
commit 5591cdc0bf
8 changed files with 355 additions and 33 deletions
+3 -1
View File
@@ -8,7 +8,9 @@ Scoped guidance for `apps/backend/cmd/mock-agent/`. The mock agent satisfies the
`handler.go` routes any prompt starting with `/e2e:` to `emitPredefinedScenario(e, name)`, which looks `name` up in `scenarioRegistry` (`scenarios.go`). The registry is the single source of truth for scenario names — to add a scenario, add one map entry and one `scenario<Name>(e *emitter)` function.
Friendly aliases live in `handler.go` next to the dispatcher: `/ask-single`, `/ask-multiple`, `/crash`, `/todo`, `/mermaid`, `/markdown`, `/sleep [n]`, `/tool:<name>`, `/subagent`, `/subtask`.
Friendly aliases live in `handler.go` next to the dispatcher: `/ask-single`, `/ask-multiple`, `/crash`, `/todo`, `/mermaid`, `/markdown`, `/sleep [n]`, `/tool:<name>`, `/subagent`, `/subtask`, `/bulk[:N]`.
`/bulk` (default 120, e.g. `/bulk:300`) emits N short agent messages, each followed by a tiny tool call. The tool-call boundary flushes the buffered text into its own message row — consecutive agent text chunks otherwise coalesce into a single message (`manager_streaming.flushMessageBuffer` only fires on a tool-call/turn boundary) — so the result is a long, paginated conversation. Use it to manually exercise chat scrollback and the "Load older messages" pagination in dev/preview.
**2. Inline scripts — `e2e:<directive>(...)`**
+67
View File
@@ -102,6 +102,65 @@ func nextOverloadedAttempt(sid acp.SessionId) int {
return next
}
// bulkCmdRe matches `/bulk` or `/e2e:bulk`, optionally followed by `:N` or ` N`
// — the number of agent messages to emit (default 120, capped at 1000). Used to
// populate a long chat history for manually testing scrollback and the "Load
// older messages" pagination in dev/preview.
var bulkCmdRe = regexp.MustCompile(`(?i)^/(?:e2e:)?bulk(?:[: ]+(\d+))?$`)
const (
bulkDefaultCount = 120
bulkMaxCount = 1000
// Tool-input/output map keys, shared to avoid repeating the literals.
toolKeyFilePath = "file_path"
toolKeyContent = "content"
)
// parseBulkCmd reports whether the prompt is the /bulk command and, if so, how
// many messages to emit (default 120, capped at 1000). Returns ok=false for any
// other prompt.
func parseBulkCmd(prompt string) (count int, ok bool) {
cmd := stripKandevSystem(strings.TrimSpace(prompt))
m := bulkCmdRe.FindStringSubmatch(cmd)
if m == nil {
return 0, false
}
count = bulkDefaultCount
if m[1] != "" {
if n, err := strconv.Atoi(m[1]); err == nil && n > 0 {
count = n
}
}
if count > bulkMaxCount {
count = bulkMaxCount
}
return count, true
}
// emitBulk emits `count` short agent messages, each followed by a lightweight
// completed tool call. The tool-call boundary flushes the buffered text above
// into its own message row — consecutive agent text chunks otherwise coalesce
// into a single message — so the result is a long, paginated conversation. Handy
// for manually exercising chat scrollback and the "Load older messages" button
// in dev/preview without a real agent. Usage: `/bulk`, `/bulk:300`, `/bulk 300`.
func emitBulk(e *emitter, count int) {
e.text(fmt.Sprintf("Generating %d messages to populate the chat history…", count))
for i := 1; i <= count; i++ {
e.text(fmt.Sprintf(
"Bulk message %d of %d — filler content for testing chat pagination and the load-older button.",
i, count))
// The tool-call start flushes the text above into its own message row.
toolID := nextToolID()
e.startTool(toolID, fmt.Sprintf("Read file-%d.txt", i), acp.ToolKindRead,
map[string]any{toolKeyFilePath: fmt.Sprintf("/tmp/bulk/file-%d.txt", i)})
e.completeTool(toolID, map[string]any{toolKeyContent: fmt.Sprintf("contents of file %d", i)})
fixedDelay(8)
}
e.text(fmt.Sprintf(
"Done — emitted %d messages. Scroll up and use “Load older messages” to page through them.",
count))
}
// delayRange returns min/max delay in milliseconds based on model name.
func delayRange(model string) (int, int) {
switch model {
@@ -149,6 +208,14 @@ func handlePrompt(e *emitter, prompt, model string) {
return
}
// Bulk mode: emit many messages to populate a long, paginated chat history.
// Handled before the switch so `/e2e:bulk` doesn't fall into the generic
// `/e2e:<scenario>` branch.
if count, ok := parseBulkCmd(prompt); ok {
emitBulk(e, count)
return
}
switch {
case strings.EqualFold(cmd, "all") || strings.EqualFold(cmd, "/all"):
emitAllTypes(e, model)
@@ -691,3 +691,58 @@ func hasMonitorMeta(meta map[string]any) bool {
name, _ := cc["toolName"].(string)
return name == "Monitor"
}
// --- /bulk command tests ---
// TestParseBulkCmd verifies the /bulk command parsing and count defaulting/capping.
func TestParseBulkCmd(t *testing.T) {
tests := []struct {
prompt string
wantCount int
wantOK bool
}{
{"/bulk", bulkDefaultCount, true},
{"/bulk:5", 5, true},
{"/bulk 5", 5, true},
{"/e2e:bulk:42", 42, true},
{"/BULK:7", 7, true},
{"/bulk:99999", bulkMaxCount, true}, // capped
{"/bulk:0", bulkDefaultCount, true}, // non-positive falls back to default
{"/bulkish", 0, false},
{"hello", 0, false},
{"", 0, false},
}
for _, tt := range tests {
t.Run(tt.prompt, func(t *testing.T) {
count, ok := parseBulkCmd(tt.prompt)
if ok != tt.wantOK || count != tt.wantCount {
t.Errorf("parseBulkCmd(%q) = (%d, %v), want (%d, %v)",
tt.prompt, count, ok, tt.wantCount, tt.wantOK)
}
})
}
}
// TestEmitBulk verifies emitBulk emits count+2 text messages (intro + N + outro)
// and one tool call per message (each flushing its text into a distinct row).
func TestEmitBulk(t *testing.T) {
e, mock := newTestEmitter()
const count = 4
emitBulk(e, count)
var texts, toolCalls int
for _, u := range mock.getUpdates() {
switch {
case isTextUpdate(u):
texts++
case isToolCallUpdate(u):
toolCalls++
}
}
if texts != count+2 {
t.Errorf("text updates = %d, want %d (intro + %d + outro)", texts, count+2, count)
}
if toolCalls != count {
t.Errorf("tool calls = %d, want %d", toolCalls, count)
}
}
@@ -228,6 +228,7 @@ export const NativeMessageList = memo(function NativeMessageList({
messagesLoading={messagesLoading}
isInitialLoading={isInitialLoading}
messagesCount={messages.length}
onLoadMore={loadMore}
/>
{items.map((item) => {
@@ -1,5 +1,6 @@
"use client";
import { Button } from "@kandev/ui/button";
import { GridSpinner } from "@/components/grid-spinner";
import type { Message, TaskSessionState, TaskState } from "@/lib/types/http";
import type { RenderItem } from "@/hooks/use-processed-messages";
@@ -48,6 +49,7 @@ export function MessageListStatus({
messagesLoading,
isInitialLoading,
messagesCount,
onLoadMore,
}: {
isLoadingMore: boolean;
hasMore: boolean;
@@ -55,6 +57,12 @@ export function MessageListStatus({
messagesLoading: boolean;
isInitialLoading: boolean;
messagesCount: number;
/**
* Explicitly load the previous page of older messages. Rendered as a button so
* older history is always reachable even when the scroll-up IntersectionObserver
* fails to re-arm (e.g. pinned at the very top with the sentinel always in view).
*/
onLoadMore?: () => void;
}) {
return (
<>
@@ -63,6 +71,20 @@ export function MessageListStatus({
Loading older messages...
</div>
)}
{hasMore && !isLoadingMore && onLoadMore && (
<div className="flex justify-center py-2">
<Button
type="button"
variant="ghost"
size="sm"
className="cursor-pointer text-xs text-muted-foreground"
data-testid="load-older-messages"
onClick={onLoadMore}
>
Load older messages
</Button>
</div>
)}
{showLoadingState && (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<GridSpinner className="text-primary mr-2" />
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, memo } from "react";
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
import { SessionPanelContent } from "@kandev/ui/pannel-session";
import type { RenderItem } from "@/hooks/use-processed-messages";
import type { Message, TaskSessionState } from "@/lib/types/http";
import { AgentStatus } from "@/components/task/chat/messages/agent-status";
import { MessageRenderer } from "@/components/task/chat/message-renderer";
import { useLazyLoadMessages } from "@/hooks/use-lazy-load-messages";
@@ -388,6 +389,68 @@ function useVisibleScrollParent() {
return { scrollParent, setScrollRef };
}
type HeaderFooterArgs = {
isLoadingMore: boolean;
hasMore: boolean;
showLoadingState: boolean;
messagesLoading: boolean;
isInitialLoading: boolean;
messages: Message[];
loadMore: () => Promise<number>;
sessionState?: TaskSessionState;
sessionId: string | null;
footerActionMessages?: Message[];
};
/** Memoized Virtuoso Header (load-more status) and Footer (agent status + actions). */
function useVirtuosoHeaderFooter(args: HeaderFooterArgs) {
const { isLoadingMore, hasMore, showLoadingState, messagesLoading, isInitialLoading } = args;
const { messages, loadMore, sessionState, sessionId, footerActionMessages } = args;
const footerActions = useMemo(() => footerActionMessages ?? [], [footerActionMessages]);
const Header = useCallback(
() => (
<MessageListStatus
isLoadingMore={isLoadingMore}
hasMore={hasMore}
showLoadingState={showLoadingState}
messagesLoading={messagesLoading}
isInitialLoading={isInitialLoading}
messagesCount={messages.length}
onLoadMore={loadMore}
/>
),
[
isLoadingMore,
hasMore,
showLoadingState,
messagesLoading,
isInitialLoading,
messages.length,
loadMore,
],
);
const Footer = useCallback(
() => (
<>
<AgentStatus sessionState={sessionState} sessionId={sessionId} messages={messages} />
{footerActions.map((msg) => (
<MessageRenderer
key={msg.id}
comment={msg}
isTaskDescription={false}
sessionState={sessionState}
/>
))}
</>
),
[sessionId, sessionState, messages, footerActions],
);
return { Header, Footer, footerActions };
}
export const VirtuosoMessageList = memo(function VirtuosoMessageList(props: MessageListProps) {
const {
items,
@@ -421,38 +484,18 @@ export const VirtuosoMessageList = memo(function VirtuosoMessageList(props: Mess
sessionState,
});
const Header = useCallback(
() => (
<MessageListStatus
isLoadingMore={isLoadingMore}
hasMore={hasMore}
showLoadingState={showLoadingState}
messagesLoading={messagesLoading}
isInitialLoading={isInitialLoading}
messagesCount={messages.length}
/>
),
[isLoadingMore, hasMore, showLoadingState, messagesLoading, isInitialLoading, messages.length],
);
const footerActions = useMemo(() => footerActionMessages ?? [], [footerActionMessages]);
const Footer = useCallback(
() => (
<>
<AgentStatus sessionState={sessionState} sessionId={sessionId} messages={messages} />
{footerActions.map((msg) => (
<MessageRenderer
key={msg.id}
comment={msg}
isTaskDescription={false}
sessionState={sessionState}
/>
))}
</>
),
[sessionId, sessionState, messages, footerActions],
);
const { Header, Footer, footerActions } = useVirtuosoHeaderFooter({
isLoadingMore,
hasMore,
showLoadingState,
messagesLoading,
isInitialLoading,
messages,
loadMore,
sessionState,
sessionId,
footerActionMessages,
});
if (isInitialLoading || items.length === 0) {
return (
@@ -464,6 +507,7 @@ export const VirtuosoMessageList = memo(function VirtuosoMessageList(props: Mess
messagesLoading={messagesLoading}
isInitialLoading={isInitialLoading}
messagesCount={messages.length}
onLoadMore={loadMore}
/>
<AgentStatus sessionState={sessionState} sessionId={sessionId} messages={messages} />
{footerActions.map((msg) => (
+19
View File
@@ -771,6 +771,25 @@ export class ApiClient {
}
}
/**
* Seed `count` agent text messages (type "message"), oldest-to-newest.
* Unlike `seedToolCallMessages`, these are `message`-type rows so the chat's
* newest-window fetch contains a user/agent message and does not trigger the
* auto-backfill that would otherwise pull older pages on its own.
*/
async seedAgentMessages(
sessionId: string,
count: number,
prefix = "filler message",
): Promise<void> {
for (let i = 0; i < count; i++) {
await this.seedSessionMessage(sessionId, {
type: "message",
content: `${prefix} ${i + 1}`,
});
}
}
async testHarnessHealth(): Promise<{ ok: boolean }> {
return this.request("GET", "/api/v1/_test/health");
}
@@ -0,0 +1,112 @@
// Chat message pagination — "Load older messages" button reliably walks a long
// conversation all the way back to the very first message (the initial prompt).
//
// Regression for the scroll-up lazy-loader wedging at the top: the
// IntersectionObserver only fires on intersection *transitions*, so once the
// user is pinned at scrollTop 0 with the sentinel permanently in view it never
// re-arms and older messages stop loading. The explicit button does not depend
// on scroll/intersection state, so it always makes progress.
//
// Covers the native renderer (the production default — `STRATEGY = "native"`).
// The virtuoso renderer shares the same MessageListStatus button, but its
// windowed rendering keeps off-screen items out of the DOM, so asserting the
// oldest message is *visible* there is a virtualization concern, not a
// pagination one — out of scope for this regression.
import { test, expect } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import type { SeedData } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
import { seedMessagesDescription } from "../search/shared";
// Stands in for the user's "initial prompt": an early chat message seeded near
// the start of the conversation, then buried under a large turn so it falls
// outside the initial newest-100 fetch window. Kept OUT of the task description
// (which the chat renders verbatim) so the only on-screen occurrence is the
// paginated message itself.
const INITIAL_PROMPT = "INITIAL-PROMPT-MARKER-7Q2X";
// Enough filler to require multiple lazy-load pages beyond the initial 100, while
// keeping sequential seeding (one round-trip per message) off the test budget:
// 120 + the marker leaves ~20+ messages older than the initial window.
const FILLER_COUNT = 120;
/** Boot an idle session, seed the marker, then bury it under FILLER_COUNT messages. */
async function seedBigConversation(apiClient: ApiClient, seedData: SeedData): Promise<string> {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"message-pagination-load-older",
seedData.agentProfileId,
{
description: seedMessagesDescription(["chat ready"]),
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
const sessionId = task.session_id!;
await expect
.poll(
async () => {
const { messages } = await apiClient.listSessionMessages(sessionId);
return messages.some((m) => m.content.includes("chat ready"));
},
{ timeout: 60_000, message: "Waiting for the boot turn to persist" },
)
.toBe(true);
await apiClient.seedSessionMessage(sessionId, { type: "message", content: INITIAL_PROMPT });
await apiClient.seedAgentMessages(sessionId, FILLER_COUNT);
return task.id;
}
test.describe("@chat message pagination", () => {
test("load-older button reaches the initial prompt in a big conversation", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(180_000);
const taskId = await seedBigConversation(apiClient, seedData);
// Open the session fresh (SSR + initial fetch loads only the newest ~100).
await testPage.goto(`/t/${taskId}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.waitForChatIdle({ timeout: 30_000 });
// Filler renders; the initial prompt does NOT yet (older than the window).
await expect(session.chat.getByText("filler message", { exact: false }).first()).toBeVisible({
timeout: 30_000,
});
await expect(session.chat.getByText(INITIAL_PROMPT)).toHaveCount(0);
// The explicit load-older button is offered because more messages exist.
const loadOlder = session.chat.getByTestId("load-older-messages");
await expect(loadOlder).toBeVisible({ timeout: 10_000 });
// Click it repeatedly until the initial prompt is reached. The button is the
// reliable path — no scrolling, no intersection timing.
await expect
.poll(
async () => {
if (
await session.chat
.getByText(INITIAL_PROMPT)
.isVisible()
.catch(() => false)
) {
return "reached";
}
if (await loadOlder.isVisible().catch(() => false)) {
await loadOlder.click().catch(() => undefined);
}
return "loading";
},
{ timeout: 60_000, intervals: [300], message: "Loading older pages until initial prompt" },
)
.toBe("reached");
// The initial prompt is visible, and there's nothing left to load.
await expect(session.chat.getByText(INITIAL_PROMPT)).toBeVisible();
await expect(loadOlder).toBeHidden();
});
});