);
}
diff --git a/src/components/CodeView.tsx b/src/components/CodeView.tsx
index 62e841b..a79d3c9 100644
--- a/src/components/CodeView.tsx
+++ b/src/components/CodeView.tsx
@@ -17,9 +17,13 @@ const MAX_HIGHLIGHT_CHARS = 50_000;
export function CodeView({
code,
language,
+ isLive,
}: {
code: string;
language: string | null;
+ /** True while the canvas mirrors a still-streaming block. Suppresses
+ * syntax highlighting (see `highlighted` below) until the block settles. */
+ isLive?: boolean;
}) {
const lineCount = useMemo(() => {
if (!code) return 1;
@@ -30,6 +34,13 @@ export function CodeView({
const highlighted = useMemo(() => {
if (!code) return "";
+ // While the canvas mirrors a still-streaming block, skip highlighting
+ // entirely: re-running the highlighter — worst case `highlightAuto` over
+ // every grammar for an as-yet-unknown language — on each token flush is the
+ // canvas's dominant streaming cost. Plain escaped text is instant; the full
+ // highlight lands the moment streaming stops (isLive flips false and this
+ // recomputes).
+ if (isLive) return escapeHtml(code);
if (code.length > MAX_HIGHLIGHT_CHARS) return escapeHtml(code);
if (language) {
const known = hljs.getLanguage(language);
@@ -46,7 +57,7 @@ export function CodeView({
} catch {
return escapeHtml(code);
}
- }, [code, language]);
+ }, [code, language, isLive]);
return (
diff --git a/src/components/ContextUsageBar.tsx b/src/components/ContextUsageBar.tsx
index 8793bfd..55555e3 100644
--- a/src/components/ContextUsageBar.tsx
+++ b/src/components/ContextUsageBar.tsx
@@ -1,16 +1,20 @@
-import { useEffect, useMemo, useRef, useState } from "react";
+import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
import { Gauge, Loader2, Maximize2, Sparkles, X } from "lucide-react";
import { useChatStore } from "@/stores/chatStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { useModelsStore } from "@/stores/modelsStore";
import { useSpaceStore } from "@/stores/spaceStore";
-import { DEFAULT_PARAMS, type GenerationParams } from "@/types";
+import { DEFAULT_PARAMS, type GenerationParams, type Message } from "@/types";
import {
computeContextUsage,
formatTokens,
} from "@/lib/contextUsage";
import { cn } from "@/lib/utils";
+// Stable empty array so the messages selector's no-messages branch keeps a
+// constant reference (a fresh `[]` would re-fire the selector every render).
+const EMPTY_MESSAGES: Message[] = [];
+
/**
* Layer the same set of GenerationParams the chat send path uses, just
* enough to find the effective `num_ctx` for the active session. Mirrors
@@ -53,7 +57,9 @@ export function ContextUsageBar() {
: undefined,
);
const messages = useChatStore((s) =>
- s.activeSessionId ? s.messages[s.activeSessionId] ?? [] : [],
+ s.activeSessionId
+ ? s.messages[s.activeSessionId] ?? EMPTY_MESSAGES
+ : EMPTY_MESSAGES,
);
const globalSystemPrompt = useSettingsStore((s) => s.global_system_prompt);
const modelDefaults = useModelsStore((s) =>
@@ -115,9 +121,15 @@ export function ContextUsageBar() {
? session.system_prompt
: globalSystemPrompt || "";
+ // Defer the recompute: `messages` changes identity on every streaming flush
+ // and computeContextUsage rescans the whole transcript. useDeferredValue lets
+ // React skip intermediate values during a burst and recompute when idle —
+ // and it always catches up to the final value once streaming stops, so the
+ // bar can't end stale (the failure mode a leading-edge timer throttle has).
+ const deferredMessages = useDeferredValue(messages);
const usage = useMemo(
- () => computeContextUsage(messages, effectiveSystemPrompt, params),
- [messages, effectiveSystemPrompt, params],
+ () => computeContextUsage(deferredMessages, effectiveSystemPrompt, params),
+ [deferredMessages, effectiveSystemPrompt, params],
);
// Don't show the bar before the user has any conversation to measure —
diff --git a/src/components/Markdown.test.ts b/src/components/Markdown.test.ts
index e741f90..7f1dce0 100644
--- a/src/components/Markdown.test.ts
+++ b/src/components/Markdown.test.ts
@@ -8,7 +8,7 @@
// schemes; javascript:/file:/data:/relative hrefs are refused.
import { describe, it, expect } from "vitest";
-import { __testing } from "./Markdown";
+import { __testing, stableSplit } from "./Markdown";
const { texReplace, rehypeTexFallback, shouldOpenExternally } = __testing;
@@ -82,6 +82,49 @@ describe("rehypeTexFallback", () => {
});
});
+describe("stableSplit (streaming prefix/tail boundary)", () => {
+ // Round-trip helper — the split must never lose or reorder a byte.
+ const split = (s: string) => {
+ const at = stableSplit(s);
+ const stable = s.slice(0, at);
+ const tail = s.slice(at);
+ expect(stable + tail).toBe(s);
+ return [stable, tail] as const;
+ };
+
+ it("cuts at the last top-level blank line", () => {
+ const [stable, tail] = split("para one\n\npara two\n\npara three");
+ expect(stable).toBe("para one\n\npara two\n\n");
+ expect(tail).toBe("para three");
+ });
+
+ it("returns 0 for a single still-growing block (no boundary yet)", () => {
+ expect(stableSplit("still typing the first paragraph")).toBe(0);
+ });
+
+ it("never splits inside an open code fence", () => {
+ // The blank line is INSIDE the unterminated fence, so the whole block must
+ // stay in the tail (rendered without highlight) until the fence closes.
+ const [stable, tail] = split("intro\n\n```py\na = 1\n\nb = 2");
+ expect(stable).toBe("intro\n\n");
+ expect(tail).toBe("```py\na = 1\n\nb = 2");
+ });
+
+ it("treats a blank line after a CLOSED fence as a boundary", () => {
+ const [stable, tail] = split("```py\nx = 1\n```\n\nnext para");
+ expect(stable).toBe("```py\nx = 1\n```\n\n");
+ expect(tail).toBe("next para");
+ });
+
+ it("puts everything in the stable prefix when content ends at a boundary", () => {
+ // A pause at a block boundary (content ending in a blank line) renders
+ // everything as the highlighted, memoised prefix and an empty tail.
+ const [stable, tail] = split("a\n\nb\n\n");
+ expect(stable).toBe("a\n\nb\n\n");
+ expect(tail).toBe("");
+ });
+});
+
describe("shouldOpenExternally (link scheme allow-list)", () => {
it("allows absolute http / https / mailto", () => {
expect(shouldOpenExternally("https://example.com")).toBe(true);
diff --git a/src/components/Markdown.tsx b/src/components/Markdown.tsx
index 30f573a..500fccf 100644
--- a/src/components/Markdown.tsx
+++ b/src/components/Markdown.tsx
@@ -1,4 +1,4 @@
-import { memo, type ReactNode } from "react";
+import { memo, useMemo, type ReactNode } from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeHighlight from "rehype-highlight";
@@ -212,6 +212,14 @@ function extractRaw(node: ReactNode): string {
// bubble where this component re-renders thousands of times.
const MARKDOWN_PLUGINS_REMARK = [remarkGfm];
const MARKDOWN_PLUGINS_REHYPE = [rehypeTexFallback, rehypeHighlight];
+// Same pipeline minus syntax highlighting, used for the still-streaming tail
+// of a message. Re-highlighting a growing block on every animation-frame flush
+// is the dominant per-token render cost (worst case `highlightAuto` runs every
+// grammar for an as-yet-unknown language); skipping it while the block is live
+// — then re-rendering the whole message *with* highlighting once streaming
+// stops — keeps streaming cheap without changing the settled view. The TeX
+// fallback stays (cheap, and keeps inline symbols consistent across the split).
+const MARKDOWN_PLUGINS_REHYPE_NOHL = [rehypeTexFallback];
// Open a link from rendered (untrusted) markdown through the OS browser / mail
// client. Mirrors the helper used by Settings / Onboarding / Updates; the
@@ -278,36 +286,110 @@ const MARKDOWN_COMPONENTS: Components = {
},
};
-// `memo` so non-streaming messages skip the entire react-markdown
-// re-parse when their parent re-renders for unrelated reasons. The
-// streaming bubble still re-renders per token (content does change),
-// but at least the components-object allocation no longer happens each
-// tick.
+// `prose-sm` is our base — chat reads in chunks, not articles, so we want
+// compact rhythm. The custom theme in tailwind.config.ts dials line-height
+// back up and rewires colours / borders to fit the glass surface. The
+// first/last resets stop a leading/trailing block adding stray margin (the
+// bubble already provides padding).
+const PROSE_CLASS = cn(
+ "prose prose-sm prose-invert max-w-none",
+ "prose-p:first:mt-0 prose-p:last:mb-0",
+);
+
+// The bare react-markdown invocation, memoised so an unchanged `content` (+
+// `highlight`) skips the whole remark/rehype re-parse when a parent re-renders.
+// Renders no wrapper element, so several can sit inside one `prose` container
+// and have block margins collapse normally across the boundary — that's what
+// lets `StreamingMarkdown` split a message without a visible spacing seam.
+const MarkdownBody = memo(function MarkdownBody({
+ content,
+ highlight,
+}: {
+ content: string;
+ highlight: boolean;
+}) {
+ return (
+
+ {content}
+
+ );
+});
+
+// `memo` so non-streaming messages skip the entire react-markdown re-parse when
+// their parent re-renders for unrelated reasons.
export const Markdown = memo(function Markdown({
content,
className,
}: MarkdownProps) {
return (
-
-
- {content}
-
+
+
+
+ );
+});
+
+// Where to split a still-streaming message into a stable prefix (already-
+// complete blocks) and the live tail (the block currently being typed). We cut
+// at the last blank line sitting at the TOP level — not inside an open code
+// fence — because a blank line there is always a CommonMark block boundary, so
+// the prefix renders identically whether or not the tail is appended. Returns
+// the prefix length; 0 when there's no safe split yet.
+//
+// Fence tracking matters two ways: a blank line *inside* a ``` block isn't a
+// boundary, and a long code block streams as one growing tail — which the
+// no-highlight tail renderer keeps cheap until its closing fence lands and it
+// folds into the highlighted, memoised prefix.
+export function stableSplit(content: string): number {
+ const lines = content.split("\n");
+ let inFence = false;
+ let fenceChar = "";
+ let offset = 0;
+ let boundary = 0;
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ const fence = /^\s{0,3}(`{3,}|~{3,})/.exec(line);
+ if (fence) {
+ const ch = fence[1][0];
+ if (!inFence) {
+ inFence = true;
+ fenceChar = ch;
+ } else if (ch === fenceChar) {
+ inFence = false;
+ }
+ }
+ offset += line.length + 1; // +1 for the "\n" that split() consumed
+ // A top-level blank line closes the preceding block. Never treat the very
+ // last line as a boundary — the tail must keep the trailing block so a
+ // message ending in "\n\n" doesn't render an empty tail.
+ if (!inFence && line.trim() === "" && i < lines.length - 1) {
+ boundary = offset;
+ }
+ }
+ return boundary;
+}
+
+// Streaming variant of `Markdown`: only the trailing, still-growing block is
+// re-parsed per flush; completed blocks render once through the memoised
+// `MarkdownBody`, and the live tail skips syntax highlighting. Both halves
+// render inside ONE prose container so block spacing across the split matches a
+// single document. Once streaming ends, callers swap back to `Markdown`, which
+// re-renders the whole message with highlighting — so the settled view is
+// identical to what it was before this optimisation.
+export const StreamingMarkdown = memo(function StreamingMarkdown({
+ content,
+ className,
+}: MarkdownProps) {
+ const splitAt = useMemo(() => stableSplit(content), [content]);
+ const stable = content.slice(0, splitAt);
+ const tail = content.slice(splitAt);
+ return (
+
+ {stable.length > 0 && }
+
);
});
diff --git a/src/components/Message.tsx b/src/components/Message.tsx
index 0ea7602..4cde8b9 100644
--- a/src/components/Message.tsx
+++ b/src/components/Message.tsx
@@ -16,7 +16,7 @@ import {
Wrench,
} from "lucide-react";
import { AttachmentActions } from "./AttachmentActions";
-import { Markdown } from "./Markdown";
+import { Markdown, StreamingMarkdown } from "./Markdown";
import { MarkdownSourceProvider } from "./markdownSource";
import { lastCodeBlock } from "@/lib/codeBlocks";
import {
@@ -471,10 +471,23 @@ function MessageItemImpl({ message, isStreaming, metrics, canRegenerate }: Messa
}
const isUser = message.role === "user";
- const persistedMetrics = parseMetrics(message.metrics_json);
+ // Memoise the JSON parses on their source strings. chatStore only swaps these
+ // strings when their dirty flags fire, so during a content-only streaming
+ // flush they stay reference-equal and the parse is skipped — otherwise the
+ // streaming bubble re-ran all three JSON.parse calls every animation frame.
+ const persistedMetrics = useMemo(
+ () => parseMetrics(message.metrics_json),
+ [message.metrics_json],
+ );
const showMetrics = metrics ?? persistedMetrics;
- const toolCalls = !isUser ? parseToolCalls(message.tool_calls_json) : [];
- const attachments = parseAttachments(message.attachments_json);
+ const toolCalls = useMemo(
+ () => (!isUser ? parseToolCalls(message.tool_calls_json) : []),
+ [isUser, message.tool_calls_json],
+ );
+ const attachments = useMemo(
+ () => parseAttachments(message.attachments_json),
+ [message.attachments_json],
+ );
// Images can come from a user upload or, on an assistant turn, from an
// MCP tool result (mapped to an image attachment in mcp/client.rs).
const images = attachments.filter((a) => a.kind === "image");
@@ -690,7 +703,15 @@ function MessageItemImpl({ message, isStreaming, metrics, canRegenerate }: Messa
lastBlockRaw,
}}
>
-
+ {/* While streaming, only the trailing block re-parses per flush
+ (and skips highlighting) — completed blocks render once. Once
+ done, fall back to the plain whole-message render so the
+ settled view (and its full highlighting) is unchanged. */}
+ {isStreaming ? (
+
+ ) : (
+
+ )}
)}
diff --git a/src/components/ParameterPanel.tsx b/src/components/ParameterPanel.tsx
index 0662201..956cd9c 100644
--- a/src/components/ParameterPanel.tsx
+++ b/src/components/ParameterPanel.tsx
@@ -687,6 +687,14 @@ function SliderRow({
}) {
const usingStops = stops && stops.length > 0;
+ // Local drag position so dragging updates the thumb + readout live WITHOUT
+ // persisting on every pointermove. `onChange` (which clones the sessions
+ // array and writes to SQLite over IPC) now fires only on `onValueCommit` —
+ // pointer-up or a key press — so a one-second drag is a single write, not
+ // the 60-120 it used to spray. `null` = not dragging; fall back to the
+ // prop-derived position.
+ const [drag, setDrag] = useState
(null);
+
// Find the stop index closest to the current value so a pre-existing
// legacy value (e.g. 6144 from before the stops migration) lands sensibly.
const stopIdx = usingStops
@@ -703,7 +711,10 @@ function SliderRow({
return best;
})()
: 0;
- const displayValue = usingStops ? stops![stopIdx] : value;
+ // Live slider position: the in-flight drag value while dragging, else derived
+ // from props (`stopIdx` for stops, the raw `value` for continuous).
+ const pos = drag ?? (usingStops ? stopIdx : value);
+ const displayValue = usingStops ? stops![pos] : pos;
const displayText = format
? format(displayValue)
: displayValue.toFixed(precision);
@@ -720,19 +731,27 @@ function SliderRow({