fix(webui): anchor sent prompts during active turns
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("MarkdownText lazy renderer failure", () => {
|
||||
it("keeps rendering plain text if the markdown renderer chunk fails to load", async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("@/components/MarkdownTextRenderer", () => {
|
||||
throw new Error("markdown renderer failed to load");
|
||||
});
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
const { MarkdownText } = await import("@/components/MarkdownText");
|
||||
|
||||
render(<MarkdownText>hello **markdown**</MarkdownText>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("hello **markdown**")).toBeInTheDocument();
|
||||
});
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
vi.doUnmock("@/components/MarkdownTextRenderer");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
assistantCopyFlags,
|
||||
buildDisplayUnits,
|
||||
ThreadMessages,
|
||||
unitKeysForDisplay,
|
||||
} from "@/components/thread/ThreadMessages";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
|
||||
@@ -72,6 +73,42 @@ describe("ThreadMessages", () => {
|
||||
expect(screen.getByText("Forked from history")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps turn unit keys stable across replayed ids and mutable turn sequence", () => {
|
||||
const liveUnits = buildDisplayUnits([
|
||||
{ id: "optimistic-user", role: "user", content: "go", turnId: "turn-1", turnPhase: "user", turnSeq: 0, createdAt: 1 },
|
||||
{
|
||||
id: "live-a1",
|
||||
role: "assistant",
|
||||
content: "first answer slice",
|
||||
turnId: "turn-1",
|
||||
turnPhase: "answer",
|
||||
turnSeq: 2,
|
||||
createdAt: 2,
|
||||
},
|
||||
{
|
||||
id: "live-a2",
|
||||
role: "assistant",
|
||||
content: "second answer slice",
|
||||
turnId: "turn-1",
|
||||
turnPhase: "answer",
|
||||
turnSeq: 20,
|
||||
createdAt: 3,
|
||||
},
|
||||
]);
|
||||
const replayUnits = buildDisplayUnits([
|
||||
{ id: "replayed-user", role: "user", content: "go", turnId: "turn-1", turnPhase: "user", turnSeq: 10, createdAt: 10 },
|
||||
{ id: "replayed-a1", role: "assistant", content: "first answer slice", turnId: "turn-1", turnPhase: "answer", turnSeq: 11, createdAt: 11 },
|
||||
{ id: "replayed-a2", role: "assistant", content: "second answer slice", turnId: "turn-1", turnPhase: "answer", turnSeq: 99, createdAt: 12 },
|
||||
]);
|
||||
|
||||
expect(unitKeysForDisplay(liveUnits)).toEqual(unitKeysForDisplay(replayUnits));
|
||||
expect(unitKeysForDisplay(liveUnits)).toEqual([
|
||||
"turn-turn-1-user",
|
||||
"turn-turn-1-answer-1",
|
||||
"turn-turn-1-answer-2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps file edits as their own activity row inside a turn", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
|
||||
@@ -1035,11 +1035,79 @@ describe("ThreadShell", () => {
|
||||
expect(historyCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("does not scroll again when canonical history refreshes after a session update", async () => {
|
||||
const client = makeClient();
|
||||
const scrollTo = vi.fn();
|
||||
const originalScrollTo = HTMLElement.prototype.scrollTo;
|
||||
HTMLElement.prototype.scrollTo = scrollTo;
|
||||
let historyCalls = 0;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes("websocket%3Achat-a/webui-thread")) {
|
||||
historyCalls += 1;
|
||||
return httpJson(
|
||||
transcriptFromSimpleMessages(
|
||||
historyCalls === 1
|
||||
? [{ role: "user", content: "question" }]
|
||||
: [
|
||||
{ role: "user", content: "question" },
|
||||
{ role: "assistant", content: "canonical answer" },
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
render(
|
||||
wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("chat-a")}
|
||||
title="Chat chat-a"
|
||||
onToggleSidebar={() => {}}
|
||||
onNewChat={() => {}}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("question")).toBeInTheDocument());
|
||||
await waitFor(() => expect(scrollTo).toHaveBeenCalled());
|
||||
await act(async () => {
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
|
||||
}
|
||||
});
|
||||
scrollTo.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
client._emitSessionUpdate("chat-a");
|
||||
});
|
||||
|
||||
await waitFor(() => expect(historyCalls).toBe(2));
|
||||
await waitFor(() => expect(screen.getByText("canonical answer")).toBeInTheDocument());
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
HTMLElement.prototype.scrollTo = originalScrollTo;
|
||||
}
|
||||
});
|
||||
|
||||
it("scrolls to the bottom after loading a session from the blank new-chat page", async () => {
|
||||
const client = makeClient();
|
||||
const scrollIntoView = vi.fn();
|
||||
const scrollTo = vi.fn();
|
||||
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
|
||||
const originalScrollTo = HTMLElement.prototype.scrollTo;
|
||||
HTMLElement.prototype.scrollIntoView = scrollIntoView;
|
||||
HTMLElement.prototype.scrollTo = scrollTo;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
@@ -1092,13 +1160,14 @@ describe("ThreadShell", () => {
|
||||
|
||||
await waitFor(() => expect(screen.getByText("loaded answer")).toBeInTheDocument());
|
||||
await waitFor(() =>
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({
|
||||
block: "end",
|
||||
expect(scrollTo).toHaveBeenCalledWith({
|
||||
top: 0,
|
||||
behavior: "auto",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
|
||||
HTMLElement.prototype.scrollTo = originalScrollTo;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ function ViewportWithPromptNavigator({ messages }: { messages: UIMessage[] }) {
|
||||
}
|
||||
|
||||
describe("ThreadViewport", () => {
|
||||
it("bottom-aligns short history near the composer", () => {
|
||||
it("top-aligns short threads in the message rendering area", () => {
|
||||
render(
|
||||
<ThreadViewport
|
||||
messages={messages}
|
||||
@@ -120,11 +120,76 @@ describe("ThreadViewport", () => {
|
||||
);
|
||||
|
||||
const messageRegion = screen.getByTestId("thread-message-region");
|
||||
expect(messageRegion).toHaveClass("justify-end");
|
||||
expect(messageRegion).toHaveClass("justify-start");
|
||||
expect(messageRegion).not.toHaveClass("justify-end");
|
||||
expect(messageRegion).toHaveClass("pb-4");
|
||||
expect(messageRegion.className).not.toContain("5rem");
|
||||
});
|
||||
|
||||
it("top-aligns a short active turn while the agent is responding", () => {
|
||||
render(
|
||||
<ThreadViewport
|
||||
messages={messages}
|
||||
isStreaming
|
||||
composer={<div>composer</div>}
|
||||
/>,
|
||||
);
|
||||
|
||||
const messageRegion = screen.getByTestId("thread-message-region");
|
||||
expect(messageRegion).toHaveClass("justify-start");
|
||||
expect(messageRegion).not.toHaveClass("justify-end");
|
||||
expect(screen.getByTestId("thread-composer-dock")).not.toHaveClass("mt-auto");
|
||||
});
|
||||
|
||||
it("anchors the latest user prompt after sending instead of scrolling to the bottom", async () => {
|
||||
const threaded: UIMessage[] = [
|
||||
{ id: "u1", role: "user", content: "old question", createdAt: 1 },
|
||||
{ id: "a1", role: "assistant", content: "old answer", createdAt: 2 },
|
||||
{ id: "u2", role: "user", content: "new question", createdAt: 3 },
|
||||
];
|
||||
const scrollTo = vi.fn();
|
||||
const { container, rerender } = render(
|
||||
<ThreadViewport
|
||||
messages={threaded}
|
||||
isStreaming
|
||||
composer={<div>composer</div>}
|
||||
scrollToLatestUserPromptSignal={0}
|
||||
/>,
|
||||
);
|
||||
|
||||
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
||||
Object.defineProperties(scroller, {
|
||||
scrollHeight: { configurable: true, value: 1200 },
|
||||
clientHeight: { configurable: true, value: 500 },
|
||||
scrollTop: { configurable: true, writable: true, value: 700 },
|
||||
scrollTo: { configurable: true, value: scrollTo },
|
||||
});
|
||||
const prompt = container.querySelector<HTMLElement>('[data-user-prompt-id="u2"]');
|
||||
expect(prompt).not.toBeNull();
|
||||
Object.defineProperty(prompt, "offsetTop", {
|
||||
configurable: true,
|
||||
value: 420,
|
||||
});
|
||||
scrollTo.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<ThreadViewport
|
||||
messages={threaded}
|
||||
isStreaming
|
||||
composer={<div>composer</div>}
|
||||
scrollToLatestUserPromptSignal={1}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(scrollTo).toHaveBeenCalledWith({
|
||||
top: 404,
|
||||
behavior: "auto",
|
||||
});
|
||||
expect(screen.getByTestId("thread-message-region")).toHaveClass("justify-start");
|
||||
});
|
||||
|
||||
it("keeps the scroll-to-bottom button above a growing composer", () => {
|
||||
const originalResizeObserver = globalThis.ResizeObserver;
|
||||
const resizeObservers: ResizeObserverInstance[] = [];
|
||||
|
||||
Reference in New Issue
Block a user