Merge remote-tracking branch 'origin/main' into HEAD

# Conflicts:
#	webui/src/components/settings/SettingsView.tsx
This commit is contained in:
chengyongru
2026-06-15 18:11:22 +08:00
45 changed files with 1010 additions and 203 deletions
+9
View File
@@ -66,6 +66,7 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
"settings.sections.webuiSafety",
"settings.sections.capabilities",
"settings.sections.apps",
"settings.sections.about",
"settings.rows.theme",
"settings.rows.language",
"settings.rows.density",
@@ -101,6 +102,10 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
"settings.status.upToDate",
"settings.actions.save",
"settings.actions.saving",
"settings.about.checking",
"settings.about.checkForUpdates",
"settings.about.upToDate",
"settings.about.updateAvailable",
];
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
@@ -244,6 +249,7 @@ describe("webui i18n", () => {
for (const key of SETTINGS_NAV_KEYS) {
expect(common.settings.nav[key as keyof typeof common.settings.nav]).toBeTruthy();
}
expect(common.settings.sections.about).toBeTruthy();
expect(common.settings.rows.theme).toBeTruthy();
expect(common.settings.status.loading).toBeTruthy();
expect(common.settings.actions.save).toBeTruthy();
@@ -255,6 +261,9 @@ describe("webui i18n", () => {
expect(common.settings.byok.showApiKey).toBeTruthy();
expect(common.settings.byok.hideApiKey).toBeTruthy();
expect(common.settings.byok.configuredKeyHint).toBeTruthy();
expect(common.settings.about.version).toBeTruthy();
expect(common.settings.about.checkForUpdates).toBeTruthy();
expect(common.settings.about.updateAvailable).toContain("{{version}}");
}
});
+49
View File
@@ -134,6 +134,28 @@ function mockBlobUrls() {
});
}
function stubVisualViewport({
height,
offsetTop = 0,
}: {
height: number;
offsetTop?: number;
}) {
const target = new EventTarget();
vi.stubGlobal("visualViewport", {
width: 390,
height,
offsetTop,
offsetLeft: 0,
pageTop: offsetTop,
pageLeft: 0,
scale: 1,
addEventListener: target.addEventListener.bind(target),
removeEventListener: target.removeEventListener.bind(target),
dispatchEvent: target.dispatchEvent.bind(target),
} as unknown as VisualViewport);
}
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
@@ -1124,6 +1146,33 @@ describe("ThreadComposer", () => {
});
});
it("keeps the slash command palette above a keyboard-constrained visual viewport", async () => {
vi.spyOn(HTMLFormElement.prototype, "getBoundingClientRect").mockReturnValue(
rect({ top: 120, bottom: 220, width: 390, height: 100 }),
);
Object.defineProperty(window, "innerHeight", {
value: 800,
configurable: true,
});
stubVisualViewport({ height: 300 });
render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Ask anything..."
slashCommands={COMMANDS}
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "/" } });
await waitFor(() => {
const palette = screen.getByRole("listbox", { name: "Slash commands" });
expect(palette.className).toContain("bottom-full");
expect(palette).toHaveStyle({ maxHeight: "112px" });
});
});
it("dismisses the slash command palette on outside click", () => {
render(
<div>
+300 -130
View File
@@ -29,6 +29,59 @@ interface ResizeObserverInstance {
disconnect: ReturnType<typeof vi.fn>;
}
function stubVisualViewport({
height,
innerHeight,
offsetTop = 0,
}: {
height: number;
innerHeight: number;
offsetTop?: number;
}) {
const originalInnerHeight = window.innerHeight;
const originalVisualViewport = window.visualViewport;
const target = new EventTarget();
const viewport = {
width: 390,
height,
offsetTop,
offsetLeft: 0,
pageTop: offsetTop,
pageLeft: 0,
scale: 1,
addEventListener: target.addEventListener.bind(target),
removeEventListener: target.removeEventListener.bind(target),
dispatchEvent: target.dispatchEvent.bind(target),
} as unknown as VisualViewport;
Object.defineProperty(window, "innerHeight", {
configurable: true,
value: innerHeight,
});
Object.defineProperty(window, "visualViewport", {
configurable: true,
value: viewport,
});
return {
viewport,
restore: () => {
Object.defineProperty(window, "innerHeight", {
configurable: true,
value: originalInnerHeight,
});
if (originalVisualViewport) {
Object.defineProperty(window, "visualViewport", {
configurable: true,
value: originalVisualViewport,
});
} else {
Reflect.deleteProperty(window, "visualViewport");
}
},
};
}
function makeLongMessages(count: number): UIMessage[] {
return Array.from({ length: count }, (_, index) => ({
id: `m${index}`,
@@ -57,6 +110,21 @@ function ViewportWithPromptNavigator({ messages }: { messages: UIMessage[] }) {
}
describe("ThreadViewport", () => {
it("bottom-aligns short history near the composer", () => {
render(
<ThreadViewport
messages={messages}
isStreaming={false}
composer={<div>composer</div>}
/>,
);
const messageRegion = screen.getByTestId("thread-message-region");
expect(messageRegion).toHaveClass("justify-end");
expect(messageRegion).toHaveClass("pb-4");
expect(messageRegion.className).not.toContain("5rem");
});
it("keeps the scroll-to-bottom button above a growing composer", () => {
const originalResizeObserver = globalThis.ResizeObserver;
const resizeObservers: ResizeObserverInstance[] = [];
@@ -129,6 +197,124 @@ describe("ThreadViewport", () => {
}
});
it("keeps the thread scrollport above a mobile soft keyboard", async () => {
const visualViewport = stubVisualViewport({ innerHeight: 800, height: 480 });
try {
const { container } = render(
<ThreadViewport
messages={messages}
isStreaming={false}
composer={<textarea aria-label="Message input" />}
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 2400 },
clientHeight: { configurable: true, value: 600 },
scrollTop: { configurable: true, value: 0 },
});
act(() => {
scroller.dispatchEvent(new Event("scroll"));
});
const input = screen.getByLabelText("Message input");
act(() => {
input.focus();
fireEvent.focusIn(input);
});
await waitFor(() => expect(scroller).toHaveStyle({ bottom: "320px" }));
expect(screen.queryByRole("button", { name: "Scroll to bottom" })).not.toBeInTheDocument();
act(() => {
visualViewport.viewport.dispatchEvent(new Event("resize"));
});
expect(scroller).toHaveStyle({ bottom: "320px" });
} finally {
visualViewport.restore();
}
});
it("scrolls recent messages into view when the composer receives focus", async () => {
const scrollTo = vi.fn();
const { container } = render(
<ThreadViewport
messages={messages}
isStreaming={false}
composer={<textarea aria-label="Message input" />}
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 2400 },
clientHeight: { configurable: true, value: 600 },
scrollTop: { configurable: true, writable: true, value: 0 },
scrollTo: { configurable: true, value: scrollTo },
});
act(() => {
scroller.dispatchEvent(new Event("scroll"));
});
scrollTo.mockClear();
const input = screen.getByLabelText("Message input");
act(() => {
input.focus();
fireEvent.focusIn(input);
});
await waitFor(() =>
expect(scrollTo).toHaveBeenCalledWith({
top: 1800,
behavior: "auto",
}),
);
});
it("scrolls recent messages into view when the focused composer resizes the visual viewport without an inset", async () => {
const visualViewport = stubVisualViewport({ innerHeight: 500, height: 500 });
const scrollTo = vi.fn();
try {
const { container } = render(
<ThreadViewport
messages={messages}
isStreaming={false}
composer={<textarea aria-label="Message input" />}
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 2400 },
clientHeight: { configurable: true, value: 600 },
scrollTop: { configurable: true, writable: true, value: 0 },
scrollTo: { configurable: true, value: scrollTo },
});
const input = screen.getByLabelText("Message input");
Object.defineProperty(document, "activeElement", {
configurable: true,
get: () => input,
});
act(() => {
visualViewport.viewport.dispatchEvent(new Event("resize"));
});
await waitFor(() =>
expect(scrollTo).toHaveBeenCalledWith({
top: 1800,
behavior: "auto",
}),
);
expect(scroller).not.toHaveStyle({ bottom: "320px" });
} finally {
Reflect.deleteProperty(document, "activeElement");
visualViewport.restore();
}
});
it("hides the scroll-to-bottom button when disabled for the welcome view", () => {
const { container } = render(
<ThreadViewport
@@ -455,148 +641,132 @@ describe("ThreadViewport", () => {
});
it("resets to the bottom when opening a different conversation", async () => {
const scrollIntoView = vi.fn();
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
HTMLElement.prototype.scrollIntoView = scrollIntoView;
const scrollTo = vi.fn();
const { container, rerender } = render(
<ThreadViewport
messages={messages}
isStreaming={false}
composer={<div />}
conversationKey="chat-a"
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 2400 },
clientHeight: { configurable: true, value: 600 },
scrollTop: { configurable: true, writable: true, value: 0 },
scrollTo: { configurable: true, value: scrollTo },
});
act(() => {
scroller.dispatchEvent(new Event("scroll"));
});
scrollTo.mockClear();
try {
const { container, rerender } = render(
<ThreadViewport
messages={messages}
isStreaming={false}
composer={<div />}
conversationKey="chat-a"
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 2400 },
clientHeight: { configurable: true, value: 600 },
scrollTop: { configurable: true, value: 0 },
});
act(() => {
scroller.dispatchEvent(new Event("scroll"));
});
scrollIntoView.mockClear();
rerender(
<ThreadViewport
messages={messages}
isStreaming={false}
composer={<div />}
conversationKey="chat-b"
/>,
);
rerender(
<ThreadViewport
messages={messages}
isStreaming={false}
composer={<div />}
conversationKey="chat-b"
/>,
);
await waitFor(() =>
expect(scrollIntoView).toHaveBeenCalledWith({
block: "end",
behavior: "auto",
}),
);
} finally {
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
}
await waitFor(() =>
expect(scrollTo).toHaveBeenCalledWith({
top: 1800,
behavior: "auto",
}),
);
});
it("waits for hydrated messages before fulfilling open-chat bottom scroll", async () => {
const scrollIntoView = vi.fn();
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
HTMLElement.prototype.scrollIntoView = scrollIntoView;
const scrollTo = vi.fn();
const { container, rerender } = render(
<ThreadViewport
messages={emptyMessages}
isStreaming={false}
composer={<div />}
conversationKey={null}
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 0 },
clientHeight: { configurable: true, value: 600 },
scrollTop: { configurable: true, writable: true, value: 0 },
scrollTo: { configurable: true, value: scrollTo },
});
scrollTo.mockClear();
try {
const { container, rerender } = render(
<ThreadViewport
messages={emptyMessages}
isStreaming={false}
composer={<div />}
conversationKey={null}
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
Object.defineProperty(scroller, "scrollHeight", {
configurable: true,
value: 0,
});
scrollIntoView.mockClear();
rerender(
<ThreadViewport
messages={emptyMessages}
isStreaming={false}
composer={<div />}
conversationKey="chat-a"
/>,
);
expect(scrollTo).toHaveBeenCalledWith({
top: 0,
behavior: "auto",
});
rerender(
<ThreadViewport
messages={emptyMessages}
isStreaming={false}
composer={<div />}
conversationKey="chat-a"
/>,
);
expect(scrollIntoView).toHaveBeenCalledWith({
block: "end",
Object.defineProperty(scroller, "scrollHeight", {
configurable: true,
value: 2400,
});
scrollTo.mockClear();
rerender(
<ThreadViewport
messages={messages}
isStreaming={false}
composer={<div />}
conversationKey="chat-a"
/>,
);
await waitFor(() =>
expect(scrollTo).toHaveBeenCalledWith({
top: 1800,
behavior: "auto",
});
Object.defineProperty(scroller, "scrollHeight", {
configurable: true,
value: 2400,
});
scrollIntoView.mockClear();
rerender(
<ThreadViewport
messages={messages}
isStreaming={false}
composer={<div />}
conversationKey="chat-a"
/>,
);
await waitFor(() =>
expect(scrollIntoView).toHaveBeenCalledWith({
block: "end",
behavior: "auto",
}),
);
} finally {
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
}
}),
);
});
it("scrolls to the bottom when explicitly signalled after send", async () => {
const scrollIntoView = vi.fn();
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
HTMLElement.prototype.scrollIntoView = scrollIntoView;
const scrollTo = vi.fn();
const { container, rerender } = render(
<ThreadViewport
messages={messages}
isStreaming={false}
composer={<div />}
scrollToBottomSignal={0}
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 2400 },
clientHeight: { configurable: true, value: 600 },
scrollTop: { configurable: true, writable: true, value: 0 },
scrollTo: { configurable: true, value: scrollTo },
});
scrollTo.mockClear();
try {
const { container, rerender } = render(
<ThreadViewport
messages={messages}
isStreaming={false}
composer={<div />}
scrollToBottomSignal={0}
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
Object.defineProperty(scroller, "scrollHeight", {
configurable: true,
value: 2400,
});
scrollIntoView.mockClear();
rerender(
<ThreadViewport
messages={messages}
isStreaming={false}
composer={<div />}
scrollToBottomSignal={1}
/>,
);
rerender(
<ThreadViewport
messages={messages}
isStreaming={false}
composer={<div />}
scrollToBottomSignal={1}
/>,
);
await waitFor(() =>
expect(scrollIntoView).toHaveBeenCalledWith({
block: "end",
behavior: "auto",
}),
);
} finally {
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
}
await waitFor(() =>
expect(scrollTo).toHaveBeenCalledWith({
top: 1800,
behavior: "auto",
}),
);
});
});