fix(web): add button to load older chat messages reliably (#1223)
This commit is contained in:
@@ -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>(...)`**
|
||||
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user