perf: virtual windowing, markdown memoisation, and search debounce

- Terminal: virtual windowing renders max 150 lines, loads 50 older on
  scroll-up with scroll position compensation; auto-advances window
  forward during auto-scroll so old DOM nodes are unloaded continuously
- Markdown: two-stage derived rendering separates expensive parse step
  (marked + hljs + spoilers, runs on content change) from cheap search
  highlight step (runs on query change only)
- Achievements: fix double Object.keys() call in derived store
- Terminal: 150ms debounce on search query to reduce redundant updates
- Tests: add Markdown.test.ts for processSpoilers and highlightSearchMatches;
  extend Terminal.test.ts with virtual windowing helper coverage
This commit is contained in:
2026-03-06 18:03:42 -08:00
committed by Naomi Carrigan
parent 55d65fa244
commit 46339a040a
5 changed files with 349 additions and 17 deletions
+11 -7
View File
@@ -108,15 +108,19 @@
return processed; return processed;
} }
function renderMarkdown(text: string): string { // Two-stage reactive rendering:
// Stage 1 — only re-runs when `content` changes (expensive: marked + hljs + spoilers)
let parsedHtml = $derived.by(() => {
try { try {
const html = marked.parse(text) as string; const html = marked.parse(content) as string;
const withSpoilers = processSpoilers(html); return processSpoilers(html);
return highlightSearchMatches(withSpoilers, searchQuery);
} catch { } catch {
return text; return content;
} }
} });
// Stage 2 — re-runs when search changes; skips re-parsing markdown entirely
let renderedHtml = $derived(highlightSearchMatches(parsedHtml, searchQuery));
function handleSpoilerClick(event: Event) { function handleSpoilerClick(event: Event) {
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
@@ -191,7 +195,7 @@
role="presentation" role="presentation"
> >
<!-- eslint-disable-next-line svelte/no-at-html-tags -- Markdown rendering requires @html; content is from Claude API --> <!-- eslint-disable-next-line svelte/no-at-html-tags -- Markdown rendering requires @html; content is from Claude API -->
{@html renderMarkdown(content)} {@html renderedHtml}
</div> </div>
<style> <style>
+163
View File
@@ -0,0 +1,163 @@
/**
* Markdown Component Tests
*
* Tests the pure helper functions extracted from the Markdown component:
* - processSpoilers: wraps ||text|| syntax in spoiler spans, leaving code blocks untouched
* - highlightSearchMatches: injects <mark> tags for search terms, skipping code blocks
*
* Manual testing checklist:
* - [ ] Code blocks render with syntax highlighting and a copy button
* - [ ] ||spoiler text|| renders as a hidden span revealed on click
* - [ ] Search query highlights matching text in non-code content
* - [ ] Links open in the system browser via the Tauri opener
*/
import { describe, it, expect } from "vitest";
// Mirror functions from Markdown.svelte for isolated testing
function processSpoilers(html: string): string {
const codeBlockPlaceholders: string[] = [];
let processed = html.replace(/<(pre|code)[^>]*>[\s\S]*?<\/\1>/gi, (match) => {
codeBlockPlaceholders.push(match);
return `__CODE_PLACEHOLDER_${codeBlockPlaceholders.length - 1}__`;
});
processed = processed.replace(
/\|\|(.+?)\|\|/g,
'<span class="spoiler" role="button" tabindex="0">$1</span>'
);
processed = processed.replace(/__CODE_PLACEHOLDER_(\d+)__/g, (_, index) => {
return codeBlockPlaceholders[parseInt(index)];
});
return processed;
}
function highlightSearchMatches(html: string, query: string): string {
if (!query) return html;
const codeBlockPlaceholders: string[] = [];
const tagPlaceholders: string[] = [];
let processed = html.replace(/<(pre|code)[^>]*>[\s\S]*?<\/\1>/gi, (match) => {
codeBlockPlaceholders.push(match);
return `__CODE_SEARCH_PLACEHOLDER_${codeBlockPlaceholders.length - 1}__`;
});
processed = processed.replace(/<[^>]+>/g, (match) => {
tagPlaceholders.push(match);
return `__TAG_PLACEHOLDER_${tagPlaceholders.length - 1}__`;
});
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(`(${escapedQuery})`, "gi");
processed = processed.replace(regex, '<mark class="search-highlight">$1</mark>');
processed = processed.replace(/__TAG_PLACEHOLDER_(\d+)__/g, (_, index) => {
return tagPlaceholders[parseInt(index)];
});
processed = processed.replace(/__CODE_SEARCH_PLACEHOLDER_(\d+)__/g, (_, index) => {
return codeBlockPlaceholders[parseInt(index)];
});
return processed;
}
// ---
describe("processSpoilers", () => {
it("wraps ||text|| in a spoiler span", () => {
const result = processSpoilers("<p>||secret||</p>");
expect(result).toContain('<span class="spoiler"');
expect(result).toContain("secret");
});
it("adds role=button and tabindex to spoiler spans", () => {
const result = processSpoilers("<p>||hidden||</p>");
expect(result).toContain('role="button"');
expect(result).toContain('tabindex="0"');
});
it("leaves content without spoiler markers unchanged", () => {
const html = "<p>Normal text here</p>";
expect(processSpoilers(html)).toBe(html);
});
it("handles multiple spoilers in the same string", () => {
const result = processSpoilers("<p>||a|| and ||b||</p>");
const matches = result.match(/class="spoiler"/g);
expect(matches).toHaveLength(2);
});
it("does not apply spoiler syntax inside code blocks", () => {
const html = "<pre><code>||not a spoiler||</code></pre>";
const result = processSpoilers(html);
expect(result).not.toContain('class="spoiler"');
expect(result).toContain("||not a spoiler||");
});
it("does not apply spoiler syntax inside inline code", () => {
const html = "<p>Example: <code>||inline||</code></p>";
const result = processSpoilers(html);
expect(result).not.toContain('class="spoiler"');
});
it("handles spoilers adjacent to code blocks correctly", () => {
const html = "<pre><code>code</code></pre><p>||revealed||</p>";
const result = processSpoilers(html);
expect(result).toContain('<span class="spoiler"');
expect(result).toContain("<pre><code>code</code></pre>");
});
});
describe("highlightSearchMatches", () => {
it("returns unchanged html when query is empty string", () => {
const html = "<p>hello world</p>";
expect(highlightSearchMatches(html, "")).toBe(html);
});
it("wraps matched text in a mark element", () => {
const result = highlightSearchMatches("<p>hello world</p>", "hello");
expect(result).toContain('<mark class="search-highlight">hello</mark>');
});
it("is case-insensitive", () => {
const result = highlightSearchMatches("<p>Hello World</p>", "hello");
expect(result).toContain('<mark class="search-highlight">Hello</mark>');
});
it("highlights all occurrences", () => {
const result = highlightSearchMatches("<p>cat and cat</p>", "cat");
const matches = result.match(/<mark class="search-highlight">/g);
expect(matches).toHaveLength(2);
});
it("does not highlight inside code blocks", () => {
const html = "<pre><code>hello inside code</code></pre>";
const result = highlightSearchMatches(html, "hello");
expect(result).not.toContain('<mark class="search-highlight">');
expect(result).toContain("hello inside code");
});
it("does not corrupt HTML tags", () => {
const result = highlightSearchMatches('<p class="foo">hello</p>', "hello");
expect(result).toContain('<p class="foo">');
expect(result).toContain('<mark class="search-highlight">hello</mark>');
});
it("escapes regex special characters in the query", () => {
const result = highlightSearchMatches("<p>price: $1.00</p>", "$1");
expect(result).toContain('<mark class="search-highlight">$1</mark>');
});
it("highlights text outside code blocks whilst leaving code intact", () => {
const html = "<pre><code>match here</code></pre><p>match here too</p>";
const result = highlightSearchMatches(html, "match");
expect(result).toContain("<pre><code>match here</code></pre>");
expect(result).toContain('<mark class="search-highlight">match</mark>');
});
});
+63 -5
View File
@@ -12,15 +12,26 @@
import { clipboardStore } from "$lib/stores/clipboard"; import { clipboardStore } from "$lib/stores/clipboard";
import { shouldHidePaths, maskPaths, showThinkingBlocks } from "$lib/stores/config"; import { shouldHidePaths, maskPaths, showThinkingBlocks } from "$lib/stores/config";
// Virtual windowing constants — keeps the DOM lean during long sessions
const WINDOW_SIZE = 150; // max lines rendered at once
const LOAD_CHUNK = 50; // how many older lines to load when scrolling up
const AVG_LINE_HEIGHT = 60; // rough px estimate per line, used for top spacer
let terminalElement: HTMLDivElement; let terminalElement: HTMLDivElement;
let shouldAutoScroll = true; let shouldAutoScroll = true;
let lines: TerminalLine[] = []; let lines: TerminalLine[] = [];
let currentSearchQuery = ""; let currentSearchQuery = "";
let currentConversationId: string | null = null; let currentConversationId: string | null = null;
let isRestoringScroll = false; let isRestoringScroll = false;
let windowStart = 0;
let isLoadingMore = false;
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
searchQuery.subscribe((value) => { searchQuery.subscribe((value) => {
currentSearchQuery = value; if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(() => {
currentSearchQuery = value;
}, 150);
}); });
let hidePaths = false; let hidePaths = false;
@@ -54,12 +65,14 @@
const savedPosition = claudeStore.getScrollPosition(newId); const savedPosition = claudeStore.getScrollPosition(newId);
isRestoringScroll = true; isRestoringScroll = true;
if (savedPosition === -1) { if (savedPosition === -1) {
// Auto-scroll to bottom // Auto-scroll to bottom — window reactive statement will advance windowStart
shouldAutoScroll = true; shouldAutoScroll = true;
terminalElement.scrollTop = terminalElement.scrollHeight; terminalElement.scrollTop = terminalElement.scrollHeight;
} else { } else {
// Restore to saved position // Restore to saved position — show from the beginning of history
windowStart = 0;
shouldAutoScroll = false; shouldAutoScroll = false;
await tick();
terminalElement.scrollTop = savedPosition; terminalElement.scrollTop = savedPosition;
} }
// Small delay to prevent the scroll handler from overriding our restore // Small delay to prevent the scroll handler from overriding our restore
@@ -69,10 +82,28 @@
} }
}); });
function handleScroll() { async function handleScroll() {
if (!terminalElement || isRestoringScroll) return; if (!terminalElement || isRestoringScroll) return;
const { scrollTop, scrollHeight, clientHeight } = terminalElement; const { scrollTop, scrollHeight, clientHeight } = terminalElement;
shouldAutoScroll = scrollHeight - scrollTop - clientHeight < 100; shouldAutoScroll = scrollHeight - scrollTop - clientHeight < 100;
// Load older lines when the user scrolls near the top
if (scrollTop < 300 && windowStart > 0 && !isLoadingMore) {
isLoadingMore = true;
const prevScrollHeight = terminalElement.scrollHeight;
const prevScrollTop = terminalElement.scrollTop;
windowStart = Math.max(0, windowStart - LOAD_CHUNK);
await tick();
if (terminalElement) {
// Compensate for the new items pushing content down
terminalElement.scrollTop =
prevScrollTop + (terminalElement.scrollHeight - prevScrollHeight);
}
isLoadingMore = false;
}
} }
afterUpdate(() => { afterUpdate(() => {
@@ -138,6 +169,17 @@
}); });
} }
// Visible slice — only render lines within the current window
$: visibleLines = lines.slice(windowStart, windowStart + WINDOW_SIZE);
// Height of the invisible spacer above the visible window
$: topSpacerHeight = windowStart * AVG_LINE_HEIGHT;
// Advance the window forward when auto-scrolling and new lines overflow it
$: if (shouldAutoScroll && lines.length > windowStart + WINDOW_SIZE) {
windowStart = Math.max(0, lines.length - WINDOW_SIZE);
}
$: { $: {
if (currentSearchQuery && lines.length > 0) { if (currentSearchQuery && lines.length > 0) {
const escapedQuery = currentSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const escapedQuery = currentSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -179,6 +221,7 @@
if (terminalElement) { if (terminalElement) {
terminalElement.removeEventListener("copy", handleCopy); terminalElement.removeEventListener("copy", handleCopy);
} }
if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
}); });
// Copy message content to clipboard // Copy message content to clipboard
@@ -238,7 +281,13 @@
Waiting for Claude... Type a message below to start! Waiting for Claude... Type a message below to start!
</div> </div>
{:else} {:else}
{#each lines as line (line.id)} <div style="height: {topSpacerHeight}px" aria-hidden="true"></div>
{#if windowStart > 0}
<div class="terminal-older-indicator">
{windowStart} older {windowStart === 1 ? "message" : "messages"} — scroll up to load
</div>
{/if}
{#each visibleLines as line (line.id)}
{#if line.type === "thinking"} {#if line.type === "thinking"}
{#if showThinking} {#if showThinking}
<ThinkingBlock content={line.content} timestamp={line.timestamp} /> <ThinkingBlock content={line.content} timestamp={line.timestamp} />
@@ -428,6 +477,15 @@
color: var(--text-secondary); color: var(--text-secondary);
} }
.terminal-older-indicator {
color: var(--text-tertiary, #6b7280);
font-size: 0.75rem;
text-align: center;
padding: 0.25rem 0;
margin-bottom: 0.5rem;
border-bottom: 1px solid var(--border-color);
}
.terminal-header-text { .terminal-header-text {
color: var(--text-secondary); color: var(--text-secondary);
} }
+104
View File
@@ -89,6 +89,27 @@ function truncateToolContent(content: string): string {
return content.slice(0, TOOL_COLLAPSE_THRESHOLD) + "…"; return content.slice(0, TOOL_COLLAPSE_THRESHOLD) + "…";
} }
// Virtual windowing helpers — mirror the logic in Terminal.svelte
const WINDOW_SIZE = 150;
const LOAD_CHUNK = 50;
const AVG_LINE_HEIGHT = 60;
/** Returns the windowStart index when auto-scrolling to the bottom. */
function autoScrollWindowStart(linesLength: number, windowSize: number): number {
return Math.max(0, linesLength - windowSize);
}
/** Returns the new windowStart after loading LOAD_CHUNK older messages. */
function olderWindowStart(currentStart: number, chunkSize: number): number {
return Math.max(0, currentStart - chunkSize);
}
/** Returns the height in pixels of the invisible top spacer. */
function topSpacerHeight(windowStart: number, avgLineHeight: number): number {
return windowStart * avgLineHeight;
}
// --- // ---
describe("getLineClass", () => { describe("getLineClass", () => {
@@ -262,3 +283,86 @@ describe("truncateToolContent", () => {
expect(result.endsWith("...")).toBe(false); expect(result.endsWith("...")).toBe(false);
}); });
}); });
describe("autoScrollWindowStart", () => {
it("returns 0 when lines fit within the window", () => {
expect(autoScrollWindowStart(50, WINDOW_SIZE)).toBe(0);
});
it("returns 0 when lines exactly fill the window", () => {
expect(autoScrollWindowStart(WINDOW_SIZE, WINDOW_SIZE)).toBe(0);
});
it("advances when lines exceed the window size", () => {
expect(autoScrollWindowStart(200, WINDOW_SIZE)).toBe(50);
});
it("never returns a negative value", () => {
expect(autoScrollWindowStart(0, WINDOW_SIZE)).toBe(0);
});
it("keeps last WINDOW_SIZE lines visible for large collections", () => {
expect(autoScrollWindowStart(500, WINDOW_SIZE)).toBe(350);
});
});
describe("olderWindowStart", () => {
it("subtracts the chunk size from the current start", () => {
expect(olderWindowStart(100, LOAD_CHUNK)).toBe(50);
});
it("never returns a negative value when chunk is larger than start", () => {
expect(olderWindowStart(20, LOAD_CHUNK)).toBe(0);
});
it("returns 0 when current start is 0", () => {
expect(olderWindowStart(0, LOAD_CHUNK)).toBe(0);
});
it("returns 0 when current start exactly equals the chunk size", () => {
expect(olderWindowStart(LOAD_CHUNK, LOAD_CHUNK)).toBe(0);
});
it("correctly loads a partial chunk near the beginning", () => {
expect(olderWindowStart(30, LOAD_CHUNK)).toBe(0);
});
});
describe("topSpacerHeight", () => {
it("returns 0 when windowStart is 0", () => {
expect(topSpacerHeight(0, AVG_LINE_HEIGHT)).toBe(0);
});
it("multiplies windowStart by avgLineHeight", () => {
expect(topSpacerHeight(10, AVG_LINE_HEIGHT)).toBe(600);
});
it("scales linearly with windowStart", () => {
expect(topSpacerHeight(50, AVG_LINE_HEIGHT)).toBe(3000);
expect(topSpacerHeight(100, AVG_LINE_HEIGHT)).toBe(6000);
expect(topSpacerHeight(150, AVG_LINE_HEIGHT)).toBe(9000);
});
it("uses the provided avgLineHeight rather than a hard-coded value", () => {
expect(topSpacerHeight(5, 100)).toBe(500);
expect(topSpacerHeight(5, 80)).toBe(400);
});
});
describe("virtual windowing constants", () => {
it("WINDOW_SIZE is 150", () => {
expect(WINDOW_SIZE).toBe(150);
});
it("LOAD_CHUNK is 50", () => {
expect(LOAD_CHUNK).toBe(50);
});
it("LOAD_CHUNK is smaller than WINDOW_SIZE", () => {
expect(LOAD_CHUNK).toBeLessThan(WINDOW_SIZE);
});
it("AVG_LINE_HEIGHT is a positive number", () => {
expect(AVG_LINE_HEIGHT).toBeGreaterThan(0);
});
});
+8 -5
View File
@@ -1471,11 +1471,14 @@ export const achievementsByRarity = derived(achievementsStore, ($store) => {
return byRarity; return byRarity;
}); });
export const achievementProgress = derived(achievementsStore, ($store) => ({ export const achievementProgress = derived(achievementsStore, ($store) => {
unlocked: $store.totalUnlocked, const total = Object.keys($store.achievements).length;
total: Object.keys($store.achievements).length, return {
percentage: Math.round(($store.totalUnlocked / Object.keys($store.achievements).length) * 100), unlocked: $store.totalUnlocked,
})); total,
percentage: Math.round(($store.totalUnlocked / total) * 100),
};
});
// Initialize achievement listener // Initialize achievement listener
export async function initAchievementsListener() { export async function initAchievementsListener() {