Polish WebUI streaming and provider settings
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user