fix(webui): keep slash commands out of streaming state

This commit is contained in:
chengyongru
2026-07-07 15:42:04 +08:00
committed by Xubin Ren
parent 0f88927364
commit 8f68040f05
6 changed files with 318 additions and 59 deletions
+105 -19
View File
@@ -82,6 +82,30 @@ function stubVisualViewport({
};
}
function stubResizeObserver() {
const original = globalThis.ResizeObserver;
const observers: ResizeObserverInstance[] = [];
class MockResizeObserver {
element?: Element;
callback: ResizeObserverCallback;
disconnect = vi.fn();
constructor(callback: ResizeObserverCallback) {
this.callback = callback;
observers.push(this);
}
observe(element: Element) {
this.element = element;
}
}
vi.stubGlobal("ResizeObserver", MockResizeObserver);
return {
observers,
restore: () => vi.stubGlobal("ResizeObserver", original),
};
}
function makeLongMessages(count: number): UIMessage[] {
return Array.from({ length: count }, (_, index) => ({
id: `m${index}`,
@@ -362,23 +386,7 @@ describe("ThreadViewport", () => {
});
it("keeps the scroll-to-bottom button above a growing composer", () => {
const originalResizeObserver = globalThis.ResizeObserver;
const resizeObservers: ResizeObserverInstance[] = [];
class MockResizeObserver {
element?: Element;
callback: ResizeObserverCallback;
disconnect = vi.fn();
constructor(callback: ResizeObserverCallback) {
this.callback = callback;
resizeObservers.push(this);
}
observe(element: Element) {
this.element = element;
}
}
vi.stubGlobal("ResizeObserver", MockResizeObserver);
const resizeObserver = stubResizeObserver();
try {
const { container } = render(
@@ -418,7 +426,7 @@ describe("ThreadViewport", () => {
toJSON: () => ({}),
}) as DOMRect;
const composerObserver = resizeObservers.find(
const composerObserver = resizeObserver.observers.find(
(observer) => observer.element === composerDock,
);
expect(composerObserver).toBeDefined();
@@ -429,7 +437,85 @@ describe("ThreadViewport", () => {
expect(buttonPositioner).toHaveStyle({ bottom: "256px" });
} finally {
vi.stubGlobal("ResizeObserver", originalResizeObserver);
resizeObserver.restore();
}
});
it("keeps the active prompt visible when the composer grows", async () => {
const resizeObserver = stubResizeObserver();
try {
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,
});
await act(async () => {
rerender(
<ThreadViewport
messages={threaded}
isStreaming
composer={<div>composer</div>}
scrollToLatestUserPromptSignal={1}
/>,
);
});
scrollTo.mockClear();
const composerDock = screen.getByTestId("thread-composer-dock");
composerDock.getBoundingClientRect = () =>
({
height: 240,
width: 800,
top: 0,
right: 800,
bottom: 240,
left: 0,
x: 0,
y: 0,
toJSON: () => ({}),
}) as DOMRect;
const composerObserver = resizeObserver.observers.find(
(observer) => observer.element === composerDock,
);
expect(composerObserver).toBeDefined();
act(() => {
composerObserver!.callback([], composerObserver as unknown as ResizeObserver);
});
await waitFor(() =>
expect(scrollTo).toHaveBeenCalledWith({
top: 404,
behavior: "auto",
}),
);
} finally {
resizeObserver.restore();
}
});