Polish WebUI streaming and provider settings

This commit is contained in:
Xubin Ren 2026-05-17 17:41:33 +08:00
parent 9340567f2d
commit 4b5de66c58
12 changed files with 456 additions and 97 deletions

View File

@ -230,6 +230,25 @@ def _mask_secret_hint(secret: str | None) -> str | None:
return f"{secret[:4]}••••{secret[-4:]}"
def _provider_requires_api_key(spec: Any) -> bool:
if spec.backend == "azure_openai":
return True
if spec.is_local or spec.is_direct:
return False
return True
def _provider_configured_for_settings(spec: Any, provider_config: Any) -> bool:
if _provider_requires_api_key(spec):
return bool(provider_config.api_key)
return bool(
provider_config.api_key
or provider_config.api_base
or getattr(provider_config, "region", None)
or getattr(provider_config, "profile", None)
)
_WEB_SEARCH_PROVIDER_OPTIONS: tuple[dict[str, str], ...] = (
{"name": "duckduckgo", "label": "DuckDuckGo", "credential": "none"},
{"name": "brave", "label": "Brave Search", "credential": "api_key"},
@ -786,13 +805,14 @@ class WebSocketChannel(BaseChannel):
providers = []
for spec in PROVIDERS:
provider_config = getattr(config.providers, spec.name, None)
if provider_config is None or spec.is_oauth or spec.is_local:
if provider_config is None or spec.is_oauth:
continue
providers.append(
{
"name": spec.name,
"label": spec.label,
"configured": bool(provider_config.api_key),
"configured": _provider_configured_for_settings(spec, provider_config),
"api_key_required": _provider_requires_api_key(spec),
"api_key_hint": _mask_secret_hint(provider_config.api_key),
"api_base": provider_config.api_base,
"default_api_base": spec.default_api_base or None,
@ -862,7 +882,12 @@ class WebSocketChannel(BaseChannel):
if find_by_name(provider) is None:
return _http_error(400, "unknown provider")
provider_config = getattr(config.providers, provider, None)
if provider_config is None or not provider_config.api_key:
spec = find_by_name(provider)
if (
provider_config is None
or spec is None
or not _provider_configured_for_settings(spec, provider_config)
):
return _http_error(400, "provider is not configured")
if defaults.provider != provider:
defaults.provider = provider
@ -885,7 +910,7 @@ class WebSocketChannel(BaseChannel):
if not provider_name:
return _http_error(400, "provider is required")
spec = find_by_name(provider_name)
if spec is None or spec.is_oauth or spec.is_local:
if spec is None or spec.is_oauth:
return _http_error(400, "unknown provider")
config = load_config()

View File

@ -396,7 +396,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
name="vllm",
keywords=("vllm",),
env_key="HOSTED_VLLM_API_KEY",
display_name="vLLM/Local",
display_name="vLLM",
backend="openai_compat",
is_local=True,
),

View File

@ -946,7 +946,12 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
providers = {provider["name"]: provider for provider in body["providers"]}
assert providers["openai"]["configured"] is True
assert providers["openai"]["api_key_hint"] == "secr••••-key"
assert providers["azure_openai"]["api_key_required"] is True
assert providers["openrouter"]["configured"] is False
assert providers["openrouter"]["api_key_required"] is True
assert providers["atomic_chat"]["configured"] is False
assert providers["atomic_chat"]["api_key_required"] is False
assert providers["atomic_chat"]["default_api_base"] == "http://localhost:1337/v1"
assert body["agent"]["has_api_key"] is True
assert body["web_search"]["provider"] == "brave"
assert body["web_search"]["api_key_hint"] == "brav••••cret"
@ -969,10 +974,24 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert provider_rows["openrouter"]["configured"] is True
assert "sk-or-test" not in provider_updated.text
local_provider_updated = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/provider/update?provider=atomic_chat"
"&api_base=http%3A%2F%2Flocalhost%3A1337%2Fv1",
headers={"Authorization": "Bearer tok"},
)
assert local_provider_updated.status_code == 200
local_provider_body = local_provider_updated.json()
local_provider_rows = {
provider["name"]: provider for provider in local_provider_body["providers"]
}
assert local_provider_rows["atomic_chat"]["configured"] is True
assert "localhost:1337" in local_provider_updated.text
updated = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/update?model=openrouter/test"
"&provider=openrouter",
f"{port}/api/settings/update?model=atomic_chat/test"
"&provider=atomic_chat",
headers={"Authorization": "Bearer tok"},
)
assert updated.status_code == 200
@ -992,10 +1011,11 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert search_body["web_search"]["base_url"] == "https://search.example.com"
saved = load_config(config_path)
assert saved.agents.defaults.model == "openrouter/test"
assert saved.agents.defaults.provider == "openrouter"
assert saved.agents.defaults.model == "atomic_chat/test"
assert saved.agents.defaults.provider == "atomic_chat"
assert saved.providers.openrouter.api_key == "sk-or-test"
assert saved.providers.openrouter.api_base == "https://openrouter.ai/api/v1"
assert saved.providers.atomic_chat.api_base == "http://localhost:1337/v1"
assert saved.tools.web.search.provider == "searxng"
assert saved.tools.web.search.api_key == ""
assert saved.tools.web.search.base_url == "https://search.example.com"

View File

@ -9,6 +9,7 @@ interface CodeBlockProps {
language?: string;
code: string;
className?: string;
highlight?: boolean;
}
interface HighlightedCodeProps {
@ -60,7 +61,12 @@ function PlainCodeFallback({ code }: { code: string }) {
);
}
export function CodeBlock({ language, code, className }: CodeBlockProps) {
export function CodeBlock({
language,
code,
className,
highlight = true,
}: CodeBlockProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const isDark = useThemeValue() === "dark";
@ -111,9 +117,13 @@ export function CodeBlock({ language, code, className }: CodeBlockProps) {
<span>{copied ? t("code.copied") : t("code.copy")}</span>
</button>
</div>
<Suspense fallback={<PlainCodeFallback code={code} />}>
<LazyHighlightedCode language={language} code={code} isDark={isDark} />
</Suspense>
{highlight ? (
<Suspense fallback={<PlainCodeFallback code={code} />}>
<LazyHighlightedCode language={language} code={code} isDark={isDark} />
</Suspense>
) : (
<PlainCodeFallback code={code} />
)}
</div>
);
}

View File

@ -1,15 +1,46 @@
import { Suspense, lazy } from "react";
import {
Suspense,
lazy,
memo,
startTransition,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
interface MarkdownTextProps {
children: string;
className?: string;
streaming?: boolean;
}
const loadMarkdownRenderer = () => import("@/components/MarkdownTextRenderer");
const LazyMarkdownRenderer = lazy(loadMarkdownRenderer);
const MemoizedMarkdownRenderer = memo(function MemoizedMarkdownRenderer({
source,
className,
highlightCode,
}: {
source: string;
className?: string;
highlightCode: boolean;
}) {
return (
<LazyMarkdownRenderer className={className} highlightCode={highlightCode}>
{source}
</LazyMarkdownRenderer>
);
});
const SHORT_STREAM_COMMIT_MS = 80;
const MEDIUM_STREAM_COMMIT_MS = 140;
const LONG_STREAM_COMMIT_MS = 220;
export function preloadMarkdownText(): void {
void loadMarkdownRenderer();
}
@ -19,7 +50,18 @@ export function preloadMarkdownText(): void {
* ``remark-math`` / ``rehype-katex``, and fenced code blocks delegated to
* ``CodeBlock`` for copy-to-clipboard and syntax highlighting.
*/
export function MarkdownText({ children, className }: MarkdownTextProps) {
export function MarkdownText({
children,
className,
streaming = false,
}: MarkdownTextProps) {
const renderedSource = useStreamingMarkdownSource(children, streaming);
const highlightCode = !streaming && renderedSource === children;
useEffect(() => {
if (streaming) preloadMarkdownText();
}, [streaming]);
return (
<Suspense
fallback={
@ -29,11 +71,73 @@ export function MarkdownText({ children, className }: MarkdownTextProps) {
className,
)}
>
{children}
{renderedSource}
</div>
}
>
<LazyMarkdownRenderer className={className}>{children}</LazyMarkdownRenderer>
<MemoizedMarkdownRenderer
source={renderedSource}
className={className}
highlightCode={highlightCode}
/>
</Suspense>
);
}
function useStreamingMarkdownSource(source: string, streaming: boolean): string {
const [renderedSource, setRenderedSource] = useState(source);
const latestSourceRef = useRef(source);
const renderedSourceRef = useRef(source);
const timerRef = useRef<number | null>(null);
const clearPendingCommit = useCallback(() => {
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
const commitSource = useCallback((next: string, urgent: boolean) => {
if (renderedSourceRef.current === next) return;
renderedSourceRef.current = next;
if (urgent) {
setRenderedSource(next);
return;
}
startTransition(() => setRenderedSource(next));
}, []);
const scheduleCommit = useCallback(() => {
if (timerRef.current !== null) return;
timerRef.current = window.setTimeout(() => {
timerRef.current = null;
commitSource(latestSourceRef.current, false);
}, streamingCommitDelay(latestSourceRef.current.length));
}, [commitSource]);
latestSourceRef.current = source;
useLayoutEffect(() => {
latestSourceRef.current = source;
if (!streaming) {
clearPendingCommit();
commitSource(source, true);
}
}, [clearPendingCommit, commitSource, source, streaming]);
useEffect(() => {
latestSourceRef.current = source;
if (!streaming) return;
scheduleCommit();
}, [scheduleCommit, source, streaming]);
useEffect(() => clearPendingCommit, [clearPendingCommit]);
return renderedSource;
}
function streamingCommitDelay(length: number): number {
if (length > 24_000) return LONG_STREAM_COMMIT_MS;
if (length > 8_000) return MEDIUM_STREAM_COMMIT_MS;
return SHORT_STREAM_COMMIT_MS;
}

View File

@ -1,4 +1,5 @@
import { Children, isValidElement } from "react";
import { Children, isValidElement, useMemo } from "react";
import type { Components } from "react-markdown";
import ReactMarkdown from "react-markdown";
import rehypeKatex from "rehype-katex";
import remarkGfm from "remark-gfm";
@ -12,8 +13,12 @@ import "katex/dist/katex.min.css";
interface MarkdownTextRendererProps {
children: string;
className?: string;
highlightCode?: boolean;
}
const remarkPlugins = [remarkGfm, remarkMath];
const rehypePlugins = [rehypeKatex];
/**
* Heavy markdown stack (GFM, math, KaTeX, syntax highlighting) kept in a
* separate chunk so the app shell can paint sooner on refresh.
@ -21,7 +26,88 @@ interface MarkdownTextRendererProps {
export default function MarkdownTextRenderer({
children,
className,
highlightCode = true,
}: MarkdownTextRendererProps) {
const components = useMemo<Components>(
() => ({
code({ className: cls, children: kids, ...props }) {
const match = /language-(\w+)/.exec(cls || "");
if (match) {
const code = String(kids).replace(/\n$/, "");
return (
<CodeBlock
language={match[1]}
code={code}
className="my-3"
highlight={highlightCode}
/>
);
}
const raw = String(kids).replace(/\n$/, "");
/** Plain fenced ``` blocks (no language) & wide one-liners: block monospace, not inline pill. */
const widePlainBlock = raw.includes("\n") || raw.length > 120;
if (widePlainBlock) {
return (
<code
className={cn(
"block min-w-0 whitespace-pre bg-transparent p-0 font-mono text-[0.8125rem]",
"leading-snug text-inherit",
cls,
)}
{...props}
>
{kids}
</code>
);
}
return (
<code
className={cn(
"rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]",
cls,
)}
{...props}
>
{kids}
</code>
);
},
pre({ children: markdownChildren }) {
const kids = Children.toArray(markdownChildren);
const lone = kids.length === 1 ? kids[0] : null;
/** Highlighted fences render ``CodeBlock`` (block shell); skip invalid ``<pre><div>``. */
if (lone != null && isValidElement(lone) && lone.type === CodeBlock) {
return <>{markdownChildren}</>;
}
return (
<pre
className={cn(
"my-3 overflow-x-auto rounded-lg border border-border/60 bg-muted/35",
"p-3 font-mono text-[0.8125rem] leading-snug text-foreground/90",
"whitespace-pre [overflow-wrap:normal]",
)}
>
{markdownChildren}
</pre>
);
},
a({ href, children: markdownChildren, ...props }) {
return (
<a
href={href}
target="_blank"
rel="noreferrer noopener"
className="text-primary underline underline-offset-2 hover:opacity-80"
{...props}
>
{markdownChildren}
</a>
);
},
}),
[highlightCode],
);
return (
<div
className={cn(
@ -42,77 +128,9 @@ export default function MarkdownTextRenderer({
style={{ lineHeight: "var(--cjk-line-height)" }}
>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
components={{
code({ className: cls, children: kids, ...props }) {
const match = /language-(\w+)/.exec(cls || "");
if (match) {
const code = String(kids).replace(/\n$/, "");
return <CodeBlock language={match[1]} code={code} className="my-3" />;
}
const raw = String(kids).replace(/\n$/, "");
/** Plain fenced ``` blocks (no language) & wide one-liners: block monospace, not inline pill. */
const widePlainBlock = raw.includes("\n") || raw.length > 120;
if (widePlainBlock) {
return (
<code
className={cn(
"block min-w-0 whitespace-pre bg-transparent p-0 font-mono text-[0.8125rem]",
"leading-snug text-inherit",
cls,
)}
{...props}
>
{kids}
</code>
);
}
return (
<code
className={cn(
"rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]",
cls,
)}
{...props}
>
{kids}
</code>
);
},
pre({ children: markdownChildren }) {
const kids = Children.toArray(markdownChildren);
const lone = kids.length === 1 ? kids[0] : null;
/** Highlighted fences render ``CodeBlock`` (block shell); skip invalid ``<pre><div>``. */
if (lone != null && isValidElement(lone) && lone.type === CodeBlock) {
return <>{markdownChildren}</>;
}
return (
<pre
className={cn(
"my-3 overflow-x-auto rounded-lg border border-border/60 bg-muted/35",
"p-3 font-mono text-[0.8125rem] leading-snug text-foreground/90",
"whitespace-pre [overflow-wrap:normal]",
)}
>
{markdownChildren}
</pre>
);
},
a({ href, children: markdownChildren, ...props }) {
return (
<a
href={href}
target="_blank"
rel="noreferrer noopener"
className="text-primary underline underline-offset-2 hover:opacity-80"
{...props}
>
{markdownChildren}
</a>
);
},
}}
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={components}
>
{children}
</ReactMarkdown>

View File

@ -1,6 +1,5 @@
import {
useCallback,
useDeferredValue,
useEffect,
useRef,
useState,
@ -120,7 +119,7 @@ export function MessageBubble({
<TypingDots />
) : empty && message.isStreaming ? null : (
<>
<MarkdownText>{message.content}</MarkdownText>
<MarkdownText streaming={!!message.isStreaming}>{message.content}</MarkdownText>
{media.length > 0 ? <MessageMedia media={media} align="left" /> : null}
{showAssistantFooterRow ? (
<div className="mt-2 flex min-h-8 flex-wrap items-center gap-x-2 gap-y-1 text-muted-foreground">
@ -480,8 +479,6 @@ export function ReasoningBubble({
embeddedInCluster = false,
}: ReasoningBubbleProps) {
const { t } = useTranslation();
const deferredText = useDeferredValue(text);
const markdownSource = streaming ? deferredText : text;
const [userToggled, setUserToggled] = useState(false);
const [openLocal, setOpenLocal] = useState(true);
const open = userToggled ? openLocal : streaming;
@ -537,6 +534,7 @@ export function ReasoningBubble({
)}
>
<MarkdownText
streaming={streaming}
className={cn(
"text-[12.5px] italic text-muted-foreground/88",
"prose-p:my-1.5 prose-li:my-0.5",
@ -547,7 +545,7 @@ export function ReasoningBubble({
"prose-code:text-[0.92em]",
)}
>
{markdownSource}
{text}
</MarkdownText>
</div>
)}

View File

@ -52,6 +52,13 @@ import type { SettingsPayload, WebSearchSettingsUpdate } from "@/lib/types";
type SettingsSectionKey = "general" | "byok";
type ByokPaneKey = "llm" | "web-search";
const LOCAL_UNCONFIGURED_PROVIDER_ORDER = new Map(
["vllm", "ollama", "lm_studio", "atomic_chat", "ovms"].map((name, index) => [
name,
index,
]),
);
interface SettingsViewProps {
theme: "light" | "dark";
onToggleTheme: () => void;
@ -176,7 +183,8 @@ export function SettingsView({
if (!provider) return;
const providerForm = providerForms[providerName] ?? { apiKey: "", apiBase: "" };
const apiKey = providerForm.apiKey.trim();
if (!provider.configured && !apiKey) {
const apiKeyRequired = provider.api_key_required ?? true;
if (!provider.configured && apiKeyRequired && !apiKey) {
setError(t("settings.byok.apiKeyRequired"));
return;
}
@ -917,7 +925,10 @@ function ByokSettings({
const [activePane, setActivePane] = useState<ByokPaneKey>("llm");
const [showAllUnconfigured, setShowAllUnconfigured] = useState(false);
const configuredProviders = settings.providers.filter((provider) => provider.configured);
const unconfiguredProviders = settings.providers.filter((provider) => !provider.configured);
const unconfiguredProviders = useMemo(
() => orderUnconfiguredProviders(settings.providers.filter((provider) => !provider.configured)),
[settings.providers],
);
const initialUnconfiguredCount = 6;
const visibleUnconfiguredProviders = showAllUnconfigured
? unconfiguredProviders
@ -935,6 +946,12 @@ function ByokSettings({
const saving = providerSaving === provider.name;
const keyVisible = !!visibleProviderKeys[provider.name];
const editingKey = !provider.configured || !!editingProviderKeys[provider.name];
const apiKeyRequired = provider.api_key_required ?? true;
const apiKey = form.apiKey.trim();
const apiBase = form.apiBase.trim();
const missingRequiredApiKey = apiKeyRequired && !provider.configured && !apiKey;
const missingOptionalCredential =
!apiKeyRequired && !provider.configured && !apiKey && !apiBase;
return (
<div
key={provider.name}
@ -1045,7 +1062,7 @@ function ByokSettings({
size="sm"
variant="outline"
onClick={() => onSaveProvider(provider.name)}
disabled={saving || (!provider.configured && !form.apiKey.trim())}
disabled={saving || missingRequiredApiKey || missingOptionalCredential}
className="rounded-full"
>
{saving ? t("settings.actions.saving") : t("settings.actions.save")}
@ -1188,6 +1205,25 @@ function ByokEmptyState({ children }: { children: ReactNode }) {
);
}
function orderUnconfiguredProviders(
providers: SettingsPayload["providers"],
): SettingsPayload["providers"] {
return providers
.map((provider, index) => ({ provider, index }))
.sort((left, right) => {
const rank = providerVisibilityRank(left.provider) - providerVisibilityRank(right.provider);
return rank || left.index - right.index;
})
.map(({ provider }) => provider);
}
function providerVisibilityRank(provider: SettingsPayload["providers"][number]): number {
const localRank = LOCAL_UNCONFIGURED_PROVIDER_ORDER.get(provider.name);
if (localRank !== undefined) return localRank;
if ((provider.api_key_required ?? true) === false) return 100;
return 200;
}
const PROVIDER_ICONS: Record<string, LucideIcon> = {
custom: Hexagon,
openrouter: Sparkles,
@ -1212,6 +1248,12 @@ const PROVIDER_ICONS: Record<string, LucideIcon> = {
qianfan: Database,
azure_openai: Cloud,
bedrock: Database,
vllm: Cpu,
ollama: Cpu,
lm_studio: Cpu,
atomic_chat: Cpu,
ovms: Cpu,
nvidia: Zap,
};
function ProviderIcon({ provider }: { provider: string }) {

View File

@ -110,6 +110,7 @@ export interface SettingsPayload {
name: string;
label: string;
configured: boolean;
api_key_required?: boolean;
api_key_hint?: string | null;
api_base?: string | null;
default_api_base?: string | null;

View File

@ -198,8 +198,52 @@ describe("App layout", () => {
name: "openrouter",
label: "OpenRouter",
configured: false,
api_key_required: true,
default_api_base: "https://openrouter.ai/api/v1",
},
{
name: "azure_openai",
label: "Azure OpenAI",
configured: false,
api_key_required: true,
},
{
name: "huggingface",
label: "Hugging Face",
configured: false,
api_key_required: true,
},
{
name: "siliconflow",
label: "SiliconFlow",
configured: false,
api_key_required: true,
},
{
name: "volcengine",
label: "VolcEngine",
configured: false,
api_key_required: true,
},
{
name: "byteplus",
label: "BytePlus",
configured: false,
api_key_required: true,
},
{
name: "qianfan",
label: "Qianfan",
configured: false,
api_key_required: true,
},
{
name: "atomic_chat",
label: "Atomic Chat",
configured: false,
api_key_required: false,
default_api_base: "http://localhost:1337/v1",
},
],
web_search: {
provider: "brave",
@ -254,6 +298,9 @@ describe("App layout", () => {
fireEvent.click(screen.getByText("OpenAI"));
expect(screen.getByText("open••••-key")).toBeInTheDocument();
expect(screen.queryByDisplayValue("unsaved-openai-key")).not.toBeInTheDocument();
fireEvent.click(screen.getByText("Atomic Chat"));
expect(screen.getByDisplayValue("http://localhost:1337/v1")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
fireEvent.click(screen.getByRole("tab", { name: "Web Search" }));
expect(screen.getByText("Search provider")).toBeInTheDocument();

View File

@ -35,6 +35,18 @@ vi.mock("react-syntax-highlighter/dist/esm/styles/prism/one-light", () => ({
}));
describe("CodeBlock", () => {
it("renders plain code without mounting the highlighter when highlighting is disabled", () => {
render(
<ThemeProvider theme="dark">
<CodeBlock language="ts" code="const value = 1;" highlight={false} />
</ThemeProvider>,
);
expect(screen.queryByTestId("highlighted-code")).not.toBeInTheDocument();
expect(screen.getByText("const value = 1;")).toBeInTheDocument();
expect(screen.getByText("ts")).toBeInTheDocument();
});
it("reads theme from context without creating per-block observers", async () => {
const originalMutationObserver = globalThis.MutationObserver;
const observer = vi.fn();

View File

@ -0,0 +1,82 @@
import { act, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { MarkdownText } from "@/components/MarkdownText";
const rendererSpy = vi.hoisted(() => vi.fn());
vi.mock("@/components/MarkdownTextRenderer", () => ({
default: ({
children,
highlightCode,
}: {
children: string;
highlightCode?: boolean;
}) => {
rendererSpy({ children, highlightCode });
return (
<div
data-testid="markdown-renderer"
data-highlight-code={String(highlightCode)}
>
{children}
</div>
);
},
}));
describe("MarkdownText", () => {
it("throttles streaming markdown commits and flushes before final highlighting", async () => {
rendererSpy.mockClear();
vi.useFakeTimers();
try {
const { rerender } = render(
<MarkdownText streaming>hello</MarkdownText>,
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello");
expect(screen.getByTestId("markdown-renderer")).toHaveAttribute(
"data-highlight-code",
"false",
);
expect(rendererSpy).toHaveBeenCalledTimes(1);
rerender(<MarkdownText streaming>hello world</MarkdownText>);
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello");
expect(rendererSpy).toHaveBeenCalledTimes(1);
act(() => {
vi.advanceTimersByTime(79);
});
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello");
expect(rendererSpy).toHaveBeenCalledTimes(1);
act(() => {
vi.advanceTimersByTime(1);
});
await act(async () => {
await Promise.resolve();
});
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello world");
expect(rendererSpy).toHaveBeenCalledTimes(2);
rerender(<MarkdownText streaming>hello world!!!</MarkdownText>);
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello world");
rerender(<MarkdownText>hello world!!!</MarkdownText>);
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello world!!!");
expect(screen.getByTestId("markdown-renderer")).toHaveAttribute(
"data-highlight-code",
"true",
);
} finally {
vi.useRealTimers();
}
});
});