From 5ae51b32c405818d1fbf2b489cf4e6f33b94b7bb Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:17:16 +0200 Subject: [PATCH 1/8] Batch AI chat stream updates --- scripts/test-ai-chat-stream-batching.mjs | 263 ++++++++++++++++++ src/renderer/src/hooks/useAiChat.ts | 178 ++++++++---- .../src/utils/ai-chat-stream-buffer.ts | 73 +++++ 3 files changed, 455 insertions(+), 59 deletions(-) create mode 100644 scripts/test-ai-chat-stream-batching.mjs create mode 100644 src/renderer/src/utils/ai-chat-stream-buffer.ts diff --git a/scripts/test-ai-chat-stream-batching.mjs b/scripts/test-ai-chat-stream-batching.mjs new file mode 100644 index 00000000..066a2c4b --- /dev/null +++ b/scripts/test-ai-chat-stream-batching.mjs @@ -0,0 +1,263 @@ +#!/usr/bin/env node + +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import vm from 'node:vm'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); + +const CHUNK_COUNT = 240; + +function loadTsModule(filePath) { + const resolvedPath = path.resolve(filePath); + const source = fs.readFileSync(resolvedPath, 'utf8'); + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + }, + fileName: resolvedPath, + }); + + const module = { exports: {} }; + vm.runInNewContext(transpiled.outputText, { + module, + exports: module.exports, + require, + console, + setTimeout, + clearTimeout, + }, { filename: resolvedPath }); + return module.exports; +} + +const { + AI_CHAT_STREAM_FLUSH_MS, + createAiChatStreamBuffer, +} = loadTsModule('src/renderer/src/utils/ai-chat-stream-buffer.ts'); + +function makeChunks(count = CHUNK_COUNT) { + return Array.from({ length: count }, (_, index) => `chunk-${index}\n`); +} + +function simulateUnbatchedStreaming(chunks) { + let content = ''; + let visibleUpdates = 0; + let markdownReparseChars = 0; + let layoutMeasurements = 0; + + const startedAt = performance.now(); + for (const chunk of chunks) { + content += chunk; + visibleUpdates += 1; + markdownReparseChars += content.length; + layoutMeasurements += 1; + } + + return { + content, + elapsedMs: performance.now() - startedAt, + layoutMeasurements, + markdownReparseChars, + visibleUpdates, + }; +} + +function createManualScheduler() { + let nextHandle = 1; + const callbacks = new Map(); + + return { + get size() { + return callbacks.size; + }, + schedule(callback) { + const handle = nextHandle; + nextHandle += 1; + callbacks.set(handle, callback); + return handle; + }, + cancel(handle) { + callbacks.delete(handle); + }, + runNext() { + const next = callbacks.entries().next(); + if (next.done) return false; + const [handle, callback] = next.value; + callbacks.delete(handle); + callback(); + return true; + }, + }; +} + +function createRenderCounters() { + return { + content: '', + layoutMeasurements: 0, + markdownReparseChars: 0, + visibleUpdates: 0, + onFlush(content) { + this.content = content; + this.visibleUpdates += 1; + this.layoutMeasurements += 1; + this.markdownReparseChars += content.length; + }, + }; +} + +function simulateBatchedBurstStreaming(chunks) { + const scheduler = createManualScheduler(); + const counters = createRenderCounters(); + const startedAt = performance.now(); + const buffer = createAiChatStreamBuffer({ + flushIntervalMs: AI_CHAT_STREAM_FLUSH_MS, + onFlush: (content) => counters.onFlush(content), + scheduleFlush: scheduler.schedule, + cancelFlush: scheduler.cancel, + }); + + for (const chunk of chunks) { + buffer.append(chunk); + } + assert.equal(scheduler.size, 1); + buffer.flushNow(); + assert.equal(scheduler.size, 0); + + return { + content: counters.content, + elapsedMs: performance.now() - startedAt, + layoutMeasurements: counters.layoutMeasurements, + markdownReparseChars: counters.markdownReparseChars, + visibleUpdates: counters.visibleUpdates, + }; +} + +function createConversationHarness() { + const scheduler = createManualScheduler(); + let messages = [ + { id: 'user-1', role: 'user', content: 'Question', createdAt: 1 }, + { id: 'assistant-1', role: 'assistant', content: '', createdAt: 2 }, + ]; + const persisted = []; + const counters = createRenderCounters(); + const buffer = createAiChatStreamBuffer({ + flushIntervalMs: AI_CHAT_STREAM_FLUSH_MS, + onFlush: (content) => { + counters.onFlush(content); + messages = messages.map((message) => ( + message.id === 'assistant-1' + ? { ...message, content } + : message + )); + }, + scheduleFlush: scheduler.schedule, + cancelFlush: scheduler.cancel, + }); + + return { + append(chunk) { + buffer.append(chunk); + }, + complete() { + buffer.flushNow(); + persisted.push(messages.map((message) => ({ ...message }))); + buffer.reset(); + }, + fail(error) { + const currentContent = buffer.getContent(); + buffer.append(`${currentContent ? '\n\n' : ''}Error: ${error}`); + buffer.flushNow(); + persisted.push(messages.map((message) => ({ ...message }))); + buffer.reset(); + }, + get counters() { + return counters; + }, + get messages() { + return messages; + }, + get persisted() { + return persisted; + }, + get schedulerSize() { + return scheduler.size; + }, + flushScheduled() { + return scheduler.runNext(); + }, + }; +} + +function test(name, fn) { + fn(); + console.log(`PASS ${name}`); +} + +const chunks = makeChunks(); +const baseline = simulateUnbatchedStreaming(chunks); +const batched = simulateBatchedBurstStreaming(chunks); + +assert.equal(baseline.content, chunks.join('')); +assert.equal(baseline.visibleUpdates, CHUNK_COUNT); +assert.equal(baseline.layoutMeasurements, CHUNK_COUNT); +assert.equal(batched.content, baseline.content); +assert.ok(batched.visibleUpdates < baseline.visibleUpdates); +assert.ok(batched.markdownReparseChars < baseline.markdownReparseChars); + +test('scheduled flush exposes partial streaming content', () => { + const harness = createConversationHarness(); + harness.append('Hello'); + assert.equal(harness.schedulerSize, 1); + assert.equal(harness.flushScheduled(), true); + assert.equal(harness.messages[1].content, 'Hello'); + harness.append(' world'); + assert.equal(harness.flushScheduled(), true); + assert.equal(harness.messages[1].content, 'Hello world'); + assert.equal(harness.counters.visibleUpdates, 2); +}); + +test('completion forces final flush before persistence', () => { + const harness = createConversationHarness(); + chunks.forEach((chunk) => harness.append(chunk)); + assert.equal(harness.messages[1].content, ''); + harness.complete(); + assert.equal(harness.persisted.length, 1); + assert.equal(harness.persisted[0][1].content, chunks.join('')); + assert.equal(harness.counters.visibleUpdates, 1); +}); + +test('error forces authoritative content plus error before persistence', () => { + const harness = createConversationHarness(); + harness.append('partial'); + harness.append(' answer'); + harness.fail('network failed'); + assert.equal(harness.persisted.length, 1); + assert.equal(harness.persisted[0][1].content, 'partial answer\n\nError: network failed'); + assert.equal(harness.counters.visibleUpdates, 1); +}); + +console.log(JSON.stringify({ + mode: 'unbatched-baseline', + chunks: CHUNK_COUNT, + visibleUpdates: baseline.visibleUpdates, + layoutMeasurements: baseline.layoutMeasurements, + markdownReparseChars: baseline.markdownReparseChars, + elapsedMs: Number(baseline.elapsedMs.toFixed(3)), +}, null, 2)); + +console.log(JSON.stringify({ + mode: 'batched-burst', + chunks: CHUNK_COUNT, + flushWindowMs: AI_CHAT_STREAM_FLUSH_MS, + visibleUpdates: batched.visibleUpdates, + layoutMeasurements: batched.layoutMeasurements, + markdownReparseChars: batched.markdownReparseChars, + elapsedMs: Number(batched.elapsedMs.toFixed(3)), +}, null, 2)); diff --git a/src/renderer/src/hooks/useAiChat.ts b/src/renderer/src/hooks/useAiChat.ts index fea32c94..072cfb4d 100644 --- a/src/renderer/src/hooks/useAiChat.ts +++ b/src/renderer/src/hooks/useAiChat.ts @@ -20,6 +20,7 @@ import type { AiChatMessage as AiMessage, AiChatSnapshot, } from '../../types/electron'; +import { createAiChatStreamBuffer } from '../utils/ai-chat-stream-buffer'; export type { AiConversation, AiMessage }; @@ -80,9 +81,65 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC const streamingMessageIdRef = useRef(null); const activeConversationIdRef = useRef(null); const messagesRef = useRef([]); + const streamBufferRef = useRef | null>(null); const aiInputRef = useRef(null); const aiResponseRef = useRef(null); + const setMessagesSnapshot = useCallback((nextMessages: AiMessage[]) => { + messagesRef.current = nextMessages; + setMessages(nextMessages); + }, []); + + const updateMessagesSnapshot = useCallback((updater: (current: AiMessage[]) => AiMessage[]) => { + const current = messagesRef.current; + const next = updater(current); + if (next === current) return current; + messagesRef.current = next; + setMessages(next); + return next; + }, []); + + const applyStreamingContent = useCallback( + (messageId: string, content: string) => { + updateMessagesSnapshot((current) => { + let changed = false; + const next = current.map((message) => { + if (message.id !== messageId) return message; + if (message.content === content) return message; + changed = true; + return { ...message, content }; + }); + return changed ? next : current; + }); + }, + [updateMessagesSnapshot] + ); + + if (streamBufferRef.current === null) { + streamBufferRef.current = createAiChatStreamBuffer({ + onFlush: (content) => { + const messageId = streamingMessageIdRef.current; + if (messageId) { + applyStreamingContent(messageId, content); + } + }, + }); + } + + const flushStreamingBuffer = useCallback(() => { + streamBufferRef.current?.flushNow(); + }, []); + + const resetStreamingBuffer = useCallback((content = '') => { + streamBufferRef.current?.reset(content); + }, []); + + useEffect(() => { + return () => { + streamBufferRef.current?.cancel(); + }; + }, []); + useEffect(() => { activeConversationIdRef.current = activeConversationId; }, [activeConversationId]); @@ -104,7 +161,7 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC const nextActive = nextConversations.find((conversation) => conversation.id === activeId); if (nextActive) { - setMessages(nextActive.messages); + setMessagesSnapshot(nextActive.messages); return; } @@ -112,7 +169,7 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC activeConversationIdRef.current = null; setActiveConversationId(null); } - }, []); + }, [setMessagesSnapshot]); const refreshSnapshot = useCallback(() => { void window.electron.getAiChatSnapshot() @@ -146,37 +203,29 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC const appendToStreamingMessage = (chunk: string) => { const msgId = streamingMessageIdRef.current; if (!msgId) return; - setMessages((prev) => - prev.map((message) => ( - message.id === msgId - ? { ...message, content: message.content + chunk } - : message - )) - ); + streamBufferRef.current?.append(chunk); }; const finalizeConversation = () => { const conversationId = activeConversationIdRef.current; if (!conversationId) return; - setMessages((current) => { - const existing = conversations.find((conversation) => conversation.id === conversationId); - const updatedConversation: AiConversation = { - id: conversationId, - title: - existing?.title && existing.title !== 'New Chat' - ? existing.title - : makeTitle(current.find((message) => message.role === 'user')?.content || 'New Chat'), - messages: current, - createdAt: existing?.createdAt ?? Date.now(), - updatedAt: Date.now(), - source: existing?.source || 'local', - ...(existing?.sourceConversationId ? { sourceConversationId: existing.sourceConversationId } : {}), - ...(existing?.metadata ? { metadata: existing.metadata } : {}), - }; - persistConversation(updatedConversation); - return current; - }); + const current = messagesRef.current; + const existing = conversations.find((conversation) => conversation.id === conversationId); + const updatedConversation: AiConversation = { + id: conversationId, + title: + existing?.title && existing.title !== 'New Chat' + ? existing.title + : makeTitle(current.find((message) => message.role === 'user')?.content || 'New Chat'), + messages: current, + createdAt: existing?.createdAt ?? Date.now(), + updatedAt: Date.now(), + source: existing?.source || 'local', + ...(existing?.sourceConversationId ? { sourceConversationId: existing.sourceConversationId } : {}), + ...(existing?.metadata ? { metadata: existing.metadata } : {}), + }; + persistConversation(updatedConversation); }; const handleChunk = (data: { requestId: string; chunk: string }) => { @@ -187,10 +236,12 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC const handleDone = (data: { requestId: string }) => { if (data.requestId === aiRequestIdRef.current) { + flushStreamingBuffer(); aiStreamingRef.current = false; setAiStreaming(false); - streamingMessageIdRef.current = null; finalizeConversation(); + streamingMessageIdRef.current = null; + resetStreamingBuffer(); } }; @@ -199,20 +250,14 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC aiStreamingRef.current = false; const msgId = streamingMessageIdRef.current; if (msgId) { - setMessages((prev) => - prev.map((message) => - message.id === msgId - ? { - ...message, - content: message.content + (message.content ? '\n\n' : '') + `Error: ${data.error}`, - } - : message - ) - ); + const currentContent = streamBufferRef.current?.getContent() ?? ''; + streamBufferRef.current?.append(`${currentContent ? '\n\n' : ''}Error: ${data.error}`); + flushStreamingBuffer(); } setAiStreaming(false); - streamingMessageIdRef.current = null; finalizeConversation(); + streamingMessageIdRef.current = null; + resetStreamingBuffer(); } }; @@ -227,7 +272,7 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC removeDone?.(); removeError?.(); }; - }, [hasBeenActivated, conversations, persistConversation]); + }, [hasBeenActivated, conversations, flushStreamingBuffer, persistConversation, resetStreamingBuffer]); useEffect(() => { if (aiResponseRef.current) { @@ -283,16 +328,16 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC content: '', createdAt: Date.now(), }; + resetStreamingBuffer(); streamingMessageIdRef.current = assistantMessage.id; - setMessages((prev) => { - const next = [...prev, userMessage, assistantMessage]; - sendChatTurn([...prev, userMessage]); - return next; - }); + const currentMessages = messagesRef.current; + const nextMessages = [...currentMessages, userMessage, assistantMessage]; + setMessagesSnapshot(nextMessages); + sendChatTurn([...currentMessages, userMessage]); setAiQuery(''); }, - [aiAvailable, persistConversation, sendChatTurn] + [aiAvailable, persistConversation, resetStreamingBuffer, sendChatTurn, setMessagesSnapshot] ); const startAiChat = useCallback( @@ -301,7 +346,8 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC setHasBeenActivated(true); activeConversationIdRef.current = null; setActiveConversationId(null); - setMessages([]); + resetStreamingBuffer(); + setMessagesSnapshot([]); setAiMode(true); const trimmed = searchQuery.trim(); if (trimmed) { @@ -310,10 +356,11 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC setAiQuery(''); } }, - [aiAvailable, setAiMode, sendMessage] + [aiAvailable, resetStreamingBuffer, setAiMode, sendMessage, setMessagesSnapshot] ); const stopStreaming = useCallback(() => { + flushStreamingBuffer(); if (aiRequestIdRef.current && aiStreamingRef.current) { window.electron.aiCancel(aiRequestIdRef.current); } @@ -321,13 +368,21 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC setAiStreaming(false); const messageId = streamingMessageIdRef.current; if (messageId) { - setMessages((prev) => - prev.map((message) => (message.id === messageId ? { ...message, cancelled: true } : message)) - ); + updateMessagesSnapshot((current) => { + let changed = false; + const next = current.map((message) => { + if (message.id !== messageId) return message; + if (message.cancelled) return message; + changed = true; + return { ...message, cancelled: true }; + }); + return changed ? next : current; + }); } streamingMessageIdRef.current = null; aiRequestIdRef.current = null; - }, []); + resetStreamingBuffer(); + }, [flushStreamingBuffer, resetStreamingBuffer, updateMessagesSnapshot]); const newChat = useCallback(() => { if (aiRequestIdRef.current && aiStreamingRef.current) { @@ -338,11 +393,12 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC streamingMessageIdRef.current = null; activeConversationIdRef.current = null; setActiveConversationId(null); - setMessages([]); + resetStreamingBuffer(); + setMessagesSnapshot([]); setAiStreaming(false); setAiQuery(''); setTimeout(() => aiInputRef.current?.focus(), 0); - }, []); + }, [resetStreamingBuffer, setMessagesSnapshot]); const selectConversation = useCallback((id: string) => { if (aiRequestIdRef.current && aiStreamingRef.current) { @@ -351,6 +407,7 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC aiRequestIdRef.current = null; aiStreamingRef.current = false; streamingMessageIdRef.current = null; + resetStreamingBuffer(); setAiStreaming(false); setConversations((current) => { @@ -358,13 +415,13 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC if (conversation) { activeConversationIdRef.current = id; setActiveConversationId(id); - setMessages(conversation.messages); + setMessagesSnapshot(conversation.messages); } return current; }); setAiQuery(''); setTimeout(() => aiInputRef.current?.focus(), 0); - }, []); + }, [resetStreamingBuffer, setMessagesSnapshot]); const deleteConversation = useCallback((id: string) => { setConversations((prev) => prev.filter((conversation) => conversation.id !== id)); @@ -372,7 +429,8 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC if (activeConversationIdRef.current === id) { activeConversationIdRef.current = null; setActiveConversationId(null); - setMessages([]); + resetStreamingBuffer(); + setMessagesSnapshot([]); if (aiRequestIdRef.current && aiStreamingRef.current) { window.electron.aiCancel(aiRequestIdRef.current); } @@ -381,9 +439,10 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC streamingMessageIdRef.current = null; setAiStreaming(false); } - }, []); + }, [resetStreamingBuffer, setMessagesSnapshot]); const exitAiMode = useCallback(() => { + flushStreamingBuffer(); if (aiRequestIdRef.current && aiStreamingRef.current) { window.electron.aiCancel(aiRequestIdRef.current); } @@ -393,8 +452,9 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC setAiMode(false); setAiStreaming(false); setAiQuery(''); + resetStreamingBuffer(); onExitAiMode?.(); - }, [setAiMode, onExitAiMode]); + }, [flushStreamingBuffer, resetStreamingBuffer, setAiMode, onExitAiMode]); useEffect(() => { if (messages.length === 0 && !aiQuery && !aiStreaming) return; diff --git a/src/renderer/src/utils/ai-chat-stream-buffer.ts b/src/renderer/src/utils/ai-chat-stream-buffer.ts new file mode 100644 index 00000000..ec66da81 --- /dev/null +++ b/src/renderer/src/utils/ai-chat-stream-buffer.ts @@ -0,0 +1,73 @@ +export const AI_CHAT_STREAM_FLUSH_MS = 40; + +export interface AiChatStreamBufferOptions { + flushIntervalMs?: number; + onFlush: (content: string) => void; + scheduleFlush?: (callback: () => void, delayMs: number) => unknown; + cancelFlush?: (handle: unknown) => void; +} + +export interface AiChatStreamBuffer { + append: (chunk: string) => void; + cancel: () => void; + flushNow: () => boolean; + getContent: () => string; + hasPendingFlush: () => boolean; + reset: (content?: string) => void; +} + +export function createAiChatStreamBuffer({ + flushIntervalMs = AI_CHAT_STREAM_FLUSH_MS, + onFlush, + scheduleFlush = (callback, delayMs) => setTimeout(callback, delayMs), + cancelFlush = (handle) => clearTimeout(handle as ReturnType), +}: AiChatStreamBufferOptions): AiChatStreamBuffer { + let content = ''; + let visibleContent = ''; + let flushHandle: unknown = null; + + const clearScheduledFlush = () => { + if (flushHandle === null) return; + cancelFlush(flushHandle); + flushHandle = null; + }; + + const flushNow = () => { + clearScheduledFlush(); + if (visibleContent === content) return false; + visibleContent = content; + onFlush(content); + return true; + }; + + const scheduleNextFlush = () => { + if (flushHandle !== null) return; + flushHandle = scheduleFlush(() => { + flushHandle = null; + flushNow(); + }, flushIntervalMs); + }; + + return { + append(chunk) { + if (!chunk) return; + content += chunk; + scheduleNextFlush(); + }, + cancel() { + clearScheduledFlush(); + }, + flushNow, + getContent() { + return content; + }, + hasPendingFlush() { + return flushHandle !== null; + }, + reset(nextContent = '') { + clearScheduledFlush(); + content = nextContent; + visibleContent = nextContent; + }, + }; +} From 18481a20ea8dc17c266a735c6b7daecbf00e703e Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:20:34 +0200 Subject: [PATCH 2/8] Batch cursor prompt stream updates --- .../test-cursor-prompt-stream-batching.mjs | 155 ++++++++++++++++++ .../src/hooks/cursorPromptResultBatcher.ts | 106 ++++++++++++ src/renderer/src/hooks/useCursorPrompt.ts | 35 +++- 3 files changed, 291 insertions(+), 5 deletions(-) create mode 100644 scripts/test-cursor-prompt-stream-batching.mjs create mode 100644 src/renderer/src/hooks/cursorPromptResultBatcher.ts diff --git a/scripts/test-cursor-prompt-stream-batching.mjs b/scripts/test-cursor-prompt-stream-batching.mjs new file mode 100644 index 00000000..b9241522 --- /dev/null +++ b/scripts/test-cursor-prompt-stream-batching.mjs @@ -0,0 +1,155 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { importTs } from './lib/ts-import.mjs'; + +const CHUNK_COUNT = 500; +const { createCursorPromptResultBatcher } = await importTs( + path.resolve('src/renderer/src/hooks/cursorPromptResultBatcher.ts') +); + +function makeChunks(count) { + return Array.from({ length: count }, (_, index) => `chunk-${index};`); +} + +function simulateLegacyCursorPromptStream(chunks) { + let visibleResult = ''; + let visibleUpdateCount = 0; + + for (const chunk of chunks) { + visibleResult += chunk; + visibleUpdateCount += 1; + } + + return { visibleResult, visibleUpdateCount }; +} + +function createManualFrameScheduler() { + let nextFrameId = 1; + const callbacks = new Map(); + + return { + requestFrame(callback) { + const id = nextFrameId; + nextFrameId += 1; + callbacks.set(id, callback); + return id; + }, + cancelFrame(id) { + callbacks.delete(id); + }, + flushFrames() { + const queuedCallbacks = Array.from(callbacks.values()); + callbacks.clear(); + for (const callback of queuedCallbacks) { + callback(); + } + return queuedCallbacks.length; + }, + pendingFrameCount() { + return callbacks.size; + }, + }; +} + +function createBatcherHarness() { + const scheduler = createManualFrameScheduler(); + const resultRef = { current: '' }; + let visibleResult = ''; + let visibleUpdateCount = 0; + const batcher = createCursorPromptResultBatcher({ + resultRef, + setVisibleResult(nextResult) { + visibleResult = nextResult; + visibleUpdateCount += 1; + }, + requestFrame: scheduler.requestFrame, + cancelFrame: scheduler.cancelFrame, + }); + + return { + batcher, + resultRef, + scheduler, + get visibleResult() { + return visibleResult; + }, + get visibleUpdateCount() { + return visibleUpdateCount; + }, + }; +} + +test('baseline cursor prompt streaming updates visible state once per chunk', () => { + const chunks = makeChunks(CHUNK_COUNT); + const expectedResult = chunks.join(''); + const metrics = simulateLegacyCursorPromptStream(chunks); + + assert.equal(metrics.visibleResult, expectedResult); + assert.equal(metrics.visibleUpdateCount, CHUNK_COUNT); + console.log(`baseline visible result updates: ${metrics.visibleUpdateCount} for ${CHUNK_COUNT} chunks`); +}); + +test('cursor prompt result batching coalesces many chunks into one visible update per frame', () => { + const chunks = makeChunks(CHUNK_COUNT); + const expectedResult = chunks.join(''); + const harness = createBatcherHarness(); + + for (const chunk of chunks) { + harness.batcher.appendChunk(chunk); + } + + assert.equal(harness.resultRef.current, expectedResult); + assert.equal(harness.visibleResult, ''); + assert.equal(harness.visibleUpdateCount, 0); + assert.equal(harness.scheduler.pendingFrameCount(), 1); + + assert.equal(harness.scheduler.flushFrames(), 1); + assert.equal(harness.visibleResult, expectedResult); + assert.equal(harness.visibleUpdateCount, 1); + console.log(`batched visible result updates: ${harness.visibleUpdateCount} for ${CHUNK_COUNT} chunks`); +}); + +test('cursor prompt result batching flushes the final pending stream synchronously', () => { + const harness = createBatcherHarness(); + + harness.batcher.appendChunk('final '); + harness.batcher.appendChunk('answer'); + harness.batcher.flush(); + + assert.equal(harness.resultRef.current, 'final answer'); + assert.equal(harness.visibleResult, 'final answer'); + assert.equal(harness.visibleUpdateCount, 1); + assert.equal(harness.scheduler.pendingFrameCount(), 0); + + assert.equal(harness.scheduler.flushFrames(), 0); + assert.equal(harness.visibleUpdateCount, 1); +}); + +test('cursor prompt result batching keeps apply text current before the visible frame flush', () => { + const harness = createBatcherHarness(); + + harness.batcher.appendChunk(' rewrite'); + harness.batcher.appendChunk(' this '); + + const textReadByApply = String(harness.resultRef.current || '').trim(); + assert.equal(textReadByApply, 'rewrite this'); + assert.equal(harness.visibleResult, ''); + assert.equal(harness.visibleUpdateCount, 0); + assert.equal(harness.scheduler.pendingFrameCount(), 1); +}); + +test('cursor prompt result batching can cancel a pending repaint without changing authoritative text', () => { + const harness = createBatcherHarness(); + + harness.batcher.appendChunk('partial'); + harness.batcher.cancelPendingFlush(); + + assert.equal(harness.resultRef.current, 'partial'); + assert.equal(harness.scheduler.pendingFrameCount(), 0); + assert.equal(harness.scheduler.flushFrames(), 0); + assert.equal(harness.visibleResult, ''); + assert.equal(harness.visibleUpdateCount, 0); +}); diff --git a/src/renderer/src/hooks/cursorPromptResultBatcher.ts b/src/renderer/src/hooks/cursorPromptResultBatcher.ts new file mode 100644 index 00000000..0cf68fe3 --- /dev/null +++ b/src/renderer/src/hooks/cursorPromptResultBatcher.ts @@ -0,0 +1,106 @@ +export const CURSOR_PROMPT_RESULT_FLUSH_INTERVAL_MS = 32; + +export interface CursorPromptResultRef { + current: string; +} + +export interface CursorPromptResultBatcherOptions { + resultRef: CursorPromptResultRef; + setVisibleResult: (value: string) => void; + requestFrame?: (callback: () => void) => number; + cancelFrame?: (handle: number) => void; + setTimer?: (callback: () => void, delayMs: number) => ReturnType; + clearTimer?: (handle: ReturnType) => void; + flushDelayMs?: number; +} + +export interface CursorPromptResultBatcher { + appendChunk: (chunk: string) => void; + flush: () => void; + reset: (nextResult?: string) => void; + cancelPendingFlush: () => void; + dispose: () => void; +} + +function createDefaultRequestFrame(): ((callback: () => void) => number) | undefined { + if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') { + return undefined; + } + return (callback) => window.requestAnimationFrame(() => callback()); +} + +function createDefaultCancelFrame(): ((handle: number) => void) | undefined { + if (typeof window === 'undefined' || typeof window.cancelAnimationFrame !== 'function') { + return undefined; + } + return (handle) => window.cancelAnimationFrame(handle); +} + +export function createCursorPromptResultBatcher({ + resultRef, + setVisibleResult, + requestFrame = createDefaultRequestFrame(), + cancelFrame = createDefaultCancelFrame(), + setTimer = globalThis.setTimeout.bind(globalThis), + clearTimer = globalThis.clearTimeout.bind(globalThis), + flushDelayMs = CURSOR_PROMPT_RESULT_FLUSH_INTERVAL_MS, +}: CursorPromptResultBatcherOptions): CursorPromptResultBatcher { + let visibleResult = resultRef.current; + let pendingFrame: number | null = null; + let pendingTimer: ReturnType | null = null; + + const publishVisibleResult = () => { + const nextResult = resultRef.current; + if (nextResult === visibleResult) return; + visibleResult = nextResult; + setVisibleResult(nextResult); + }; + + const cancelPendingFlush = () => { + if (pendingFrame !== null) { + cancelFrame?.(pendingFrame); + pendingFrame = null; + } + if (pendingTimer !== null) { + clearTimer(pendingTimer); + pendingTimer = null; + } + }; + + const runScheduledFlush = () => { + pendingFrame = null; + pendingTimer = null; + publishVisibleResult(); + }; + + const scheduleFlush = () => { + if (pendingFrame !== null || pendingTimer !== null) return; + + if (requestFrame) { + pendingFrame = requestFrame(runScheduledFlush); + return; + } + + pendingTimer = setTimer(runScheduledFlush, flushDelayMs); + }; + + return { + appendChunk(chunk: string) { + if (!chunk) return; + resultRef.current += chunk; + scheduleFlush(); + }, + flush() { + cancelPendingFlush(); + publishVisibleResult(); + }, + reset(nextResult = '') { + cancelPendingFlush(); + resultRef.current = nextResult; + visibleResult = nextResult; + setVisibleResult(nextResult); + }, + cancelPendingFlush, + dispose: cancelPendingFlush, + }; +} diff --git a/src/renderer/src/hooks/useCursorPrompt.ts b/src/renderer/src/hooks/useCursorPrompt.ts index a601bf1e..27e0647b 100644 --- a/src/renderer/src/hooks/useCursorPrompt.ts +++ b/src/renderer/src/hooks/useCursorPrompt.ts @@ -16,6 +16,10 @@ import { useState, useRef, useCallback, useEffect } from 'react'; import { NO_AI_MODEL_ERROR } from '../utils/constants'; +import { + createCursorPromptResultBatcher, + type CursorPromptResultBatcher, +} from './cursorPromptResultBatcher'; // ─── Interfaces ────────────────────────────────────────────────────── @@ -53,7 +57,7 @@ export function useCursorPrompt({ }: UseCursorPromptOptions): UseCursorPromptReturn { const [cursorPromptText, setCursorPromptText] = useState(''); const [cursorPromptStatus, setCursorPromptStatus] = useState<'idle' | 'processing' | 'ready' | 'error'>('idle'); - const [cursorPromptResult, setCursorPromptResult] = useState(''); + const [cursorPromptResult, setCursorPromptResultState] = useState(''); const [cursorPromptError, setCursorPromptError] = useState(''); const [cursorPromptSourceText, setCursorPromptSourceText] = useState(''); @@ -61,6 +65,18 @@ export function useCursorPrompt({ const cursorPromptResultRef = useRef(''); const cursorPromptSourceTextRef = useRef(''); const cursorPromptInputRef = useRef(null); + const cursorPromptResultBatcherRef = useRef(null); + + if (!cursorPromptResultBatcherRef.current) { + cursorPromptResultBatcherRef.current = createCursorPromptResultBatcher({ + resultRef: cursorPromptResultRef, + setVisibleResult: setCursorPromptResultState, + }); + } + + const setCursorPromptResult = useCallback((value: string) => { + cursorPromptResultBatcherRef.current?.reset(value); + }, []); // ── Apply result to the editor ────────────────────────────────── @@ -89,18 +105,19 @@ export function useCursorPrompt({ useEffect(() => { const handleChunk = (data: { requestId: string; chunk: string }) => { if (data.requestId === cursorPromptRequestIdRef.current) { - cursorPromptResultRef.current += data.chunk; - setCursorPromptResult((prev) => prev + data.chunk); + cursorPromptResultBatcherRef.current?.appendChunk(data.chunk); } }; const handleDone = (data: { requestId: string }) => { if (data.requestId === cursorPromptRequestIdRef.current) { + cursorPromptResultBatcherRef.current?.flush(); cursorPromptRequestIdRef.current = null; void applyCursorPromptResultToEditor(); } }; const handleError = (data: { requestId: string; error: string }) => { if (data.requestId === cursorPromptRequestIdRef.current) { + cursorPromptResultBatcherRef.current?.flush(); cursorPromptRequestIdRef.current = null; setCursorPromptStatus('error'); setCursorPromptError(data.error || 'Failed to process this prompt.'); @@ -118,6 +135,12 @@ export function useCursorPrompt({ }; }, [applyCursorPromptResultToEditor]); + useEffect(() => { + return () => { + cursorPromptResultBatcherRef.current?.dispose(); + }; + }, []); + // ── Focus cursor prompt input when shown ──────────────────────── useEffect(() => { @@ -203,7 +226,7 @@ export function useCursorPrompt({ `Instruction: ${instruction}`, ].join('\n'); await window.electron.aiAsk(requestId, compositePrompt); - }, [cursorPromptStatus, cursorPromptText, setAiAvailable]); + }, [cursorPromptStatus, cursorPromptText, setAiAvailable, setCursorPromptResult]); const closeCursorPrompt = useCallback(async () => { if (cursorPromptRequestIdRef.current) { @@ -212,6 +235,7 @@ export function useCursorPrompt({ } catch {} cursorPromptRequestIdRef.current = null; } + cursorPromptResultBatcherRef.current?.cancelPendingFlush(); setShowCursorPrompt(false); window.electron.hideWindow(); }, [setShowCursorPrompt]); @@ -223,7 +247,8 @@ export function useCursorPrompt({ setCursorPromptError(''); setCursorPromptSourceText(''); cursorPromptRequestIdRef.current = null; - }, []); + cursorPromptSourceTextRef.current = ''; + }, [setCursorPromptResult]); return { cursorPromptText, From be66d99aadb08493a1e0f068b9d99b5e38992ac9 Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Fri, 3 Jul 2026 22:00:09 +0200 Subject: [PATCH 3/8] perf(ai): batch Raycast useAI streaming --- scripts/test-use-ai-stream-batching.mjs | 528 +++++++++++++++++++ src/renderer/src/raycast-api/hooks/use-ai.ts | 126 ++++- 2 files changed, 651 insertions(+), 3 deletions(-) create mode 100644 scripts/test-use-ai-stream-batching.mjs diff --git a/scripts/test-use-ai-stream-batching.mjs b/scripts/test-use-ai-stream-batching.mjs new file mode 100644 index 00000000..5d98bae0 --- /dev/null +++ b/scripts/test-use-ai-stream-batching.mjs @@ -0,0 +1,528 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { transform } from 'esbuild'; + +const CHUNK_COUNT = 600; + +let activeFrameScheduler = null; + +const testWindow = { + document: { + addEventListener() {}, + removeEventListener() {}, + createElement: () => ({ style: {}, setAttribute() {}, appendChild() {}, remove() {} }), + body: { appendChild() {}, removeChild() {} }, + }, + navigator: { platform: 'test', userAgent: 'node' }, + localStorage: createTestStorage(), + sessionStorage: createTestStorage(), + addEventListener() {}, + removeEventListener() {}, + dispatchEvent: () => true, + electron: {}, + location: { href: 'about:blank', reload() {} }, + requestAnimationFrame(callback) { + if (activeFrameScheduler) return activeFrameScheduler.requestFrame(callback); + return setTimeout(() => callback(Date.now()), 0); + }, + cancelAnimationFrame(handle) { + if (activeFrameScheduler) { + activeFrameScheduler.cancelFrame(handle); + return; + } + clearTimeout(handle); + }, +}; + +globalThis.window = testWindow; +globalThis.document = testWindow.document; +Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: testWindow.navigator, +}); +globalThis.requestAnimationFrame = (callback) => testWindow.requestAnimationFrame(callback); +globalThis.cancelAnimationFrame = (handle) => testWindow.cancelAnimationFrame(handle); + +const REACT_HOOK_IMPORT_STUB = ` +const runtime = () => { + const current = globalThis.__supercmdReactRuntime; + if (!current) throw new Error('React hook runtime is not installed'); + return current; +}; + +const useState = (initialValue) => runtime().useState(initialValue); +const useRef = (initialValue) => runtime().useRef(initialValue); +const useCallback = (callback, deps) => runtime().useCallback(callback, deps); +const useEffect = (effect, deps) => runtime().useEffect(effect, deps); +`; + +const { + createRaycastAIStreamBatcher, + useAI, +} = await importUseAiModule(path.resolve('src/renderer/src/raycast-api/hooks/use-ai.ts')); + +function createTestStorage() { + const values = new Map(); + return { + getItem(key) { + return values.has(String(key)) ? values.get(String(key)) : null; + }, + setItem(key, value) { + values.set(String(key), String(value)); + }, + removeItem(key) { + values.delete(String(key)); + }, + clear() { + values.clear(); + }, + }; +} + +function makeChunks(count = CHUNK_COUNT) { + return Array.from({ length: count }, (_, index) => `chunk-${index};`); +} + +async function importUseAiModule(absPath) { + const source = fs.readFileSync(absPath, 'utf8'); + const stubbedSource = source.replace( + "import { useCallback, useEffect, useRef, useState } from 'react';", + REACT_HOOK_IMPORT_STUB + ); + + assert.notEqual(stubbedSource, source, 'expected use-ai.ts React hook import to be stubbed'); + + const { code } = await transform(stubbedSource, { + loader: 'ts', + format: 'esm', + target: 'es2020', + }); + const dataUrl = 'data:text/javascript;base64,' + Buffer.from(code).toString('base64'); + return import(dataUrl); +} + +function createManualFrameScheduler() { + let nextFrameId = 1; + const callbacks = new Map(); + + return { + requestFrame(callback) { + const id = nextFrameId; + nextFrameId += 1; + callbacks.set(id, callback); + return id; + }, + cancelFrame(id) { + callbacks.delete(id); + }, + flushFrames() { + const queuedCallbacks = Array.from(callbacks.values()); + callbacks.clear(); + for (const callback of queuedCallbacks) callback(Date.now()); + return queuedCallbacks.length; + }, + pendingFrameCount() { + return callbacks.size; + }, + }; +} + +function createFakeRaycastAI(chunks, options = {}) { + const fullText = options.fullText ?? chunks.join(''); + const requests = []; + + return { + requests, + ask(prompt, askOptions = {}) { + const dataHandlers = []; + const signal = askOptions.signal; + let settled = false; + let resolvePromise; + let rejectPromise; + + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve; + rejectPromise = reject; + }); + + const request = { + prompt, + askOptions, + promise, + get settled() { + return settled; + }, + get signal() { + return signal; + }, + emitChunks(nextChunks = chunks) { + for (const chunk of nextChunks) { + if (signal?.aborted) return; + for (const handler of dataHandlers) handler(chunk); + } + }, + resolve(text = fullText) { + if (settled) return; + settled = true; + resolvePromise(text); + }, + reject(error) { + if (settled) return; + settled = true; + rejectPromise(error); + }, + complete(nextChunks = chunks, text = fullText) { + this.emitChunks(nextChunks); + if (!signal?.aborted) this.resolve(text); + }, + }; + + signal?.addEventListener('abort', () => { + request.reject(new Error('aborted')); + }, { once: true }); + + requests.push(request); + + const streamPromise = { + on(event, handler) { + if (event === 'data') dataHandlers.push(handler); + return streamPromise; + }, + then(onFulfilled, onRejected) { + return promise.then(onFulfilled, onRejected); + }, + catch(onRejected) { + return promise.catch(onRejected); + }, + finally(onFinally) { + return promise.finally(onFinally); + }, + }; + + if (options.autoComplete) { + queueMicrotask(() => request.complete()); + } + + return streamPromise; + }, + }; +} + +function depsChanged(previousDeps, nextDeps) { + if (!previousDeps || !nextDeps) return true; + if (previousDeps.length !== nextDeps.length) return true; + return previousDeps.some((value, index) => !Object.is(value, nextDeps[index])); +} + +function createHookRunner(renderHook, initialArgs) { + let args = initialArgs; + let hookIndex = 0; + let mounted = false; + let pendingRender = false; + let result; + let renderCount = 0; + let stateUpdateCount = 0; + let dataVisibleUpdateCount = 0; + let hasDataSnapshot = false; + let lastDataSnapshot; + const hooks = []; + + const runtime = { + useState(initialValue) { + const index = hookIndex; + hookIndex += 1; + + if (!hooks[index]) { + hooks[index] = { + value: typeof initialValue === 'function' ? initialValue() : initialValue, + }; + } + + const setState = (nextValueOrUpdater) => { + const currentValue = hooks[index].value; + const nextValue = typeof nextValueOrUpdater === 'function' + ? nextValueOrUpdater(currentValue) + : nextValueOrUpdater; + + if (Object.is(currentValue, nextValue)) return; + + hooks[index].value = nextValue; + stateUpdateCount += 1; + if (mounted) pendingRender = true; + }; + + return [hooks[index].value, setState]; + }, + useRef(initialValue) { + const index = hookIndex; + hookIndex += 1; + + if (!hooks[index]) hooks[index] = { current: initialValue }; + return hooks[index]; + }, + useCallback(callback, deps) { + const index = hookIndex; + hookIndex += 1; + const previous = hooks[index]; + + if (previous && !depsChanged(previous.deps, deps)) { + return previous.callback; + } + + hooks[index] = { callback, deps }; + return callback; + }, + useEffect(effect, deps) { + const index = hookIndex; + hookIndex += 1; + const previous = hooks[index]; + + hooks[index] = { + cleanup: previous?.cleanup, + deps, + effect, + pending: !previous || depsChanged(previous.deps, deps), + }; + }, + }; + + const render = () => { + hookIndex = 0; + globalThis.__supercmdReactRuntime = runtime; + result = renderHook(...args); + renderCount += 1; + + if (result && typeof result === 'object' && 'data' in result) { + if (hasDataSnapshot && result.data !== lastDataSnapshot) { + dataVisibleUpdateCount += 1; + } + hasDataSnapshot = true; + lastDataSnapshot = result.data; + } + + for (const hook of hooks) { + if (!hook?.pending) continue; + hook.pending = false; + if (typeof hook.cleanup === 'function') hook.cleanup(); + const cleanup = hook.effect(); + hook.cleanup = typeof cleanup === 'function' ? cleanup : undefined; + } + }; + + const flush = () => { + while (pendingRender) { + pendingRender = false; + render(); + } + }; + + return { + mount() { + mounted = true; + render(); + flush(); + return result; + }, + rerender(nextArgs = args) { + args = nextArgs; + pendingRender = true; + flush(); + return result; + }, + flush, + unmount() { + mounted = false; + for (const hook of hooks) { + if (typeof hook?.cleanup === 'function') { + hook.cleanup(); + hook.cleanup = undefined; + } + } + }, + get result() { + return result; + }, + get renderCount() { + return renderCount; + }, + get stateUpdateCount() { + return stateUpdateCount; + }, + get dataVisibleUpdateCount() { + return dataVisibleUpdateCount; + }, + }; +} + +async function drainMicrotasks(turns = 4) { + for (let index = 0; index < turns; index += 1) { + await Promise.resolve(); + } +} + +test('baseline fake Raycast useAI streaming updates visible state once per chunk', async () => { + const chunks = makeChunks(); + const expectedData = chunks.join(''); + const ai = createFakeRaycastAI(chunks); + let visibleData = ''; + let visibleUpdateCount = 0; + + testWindow.__supercmdRaycastAI = ai; + const stream = testWindow.__supercmdRaycastAI.ask('baseline prompt', { + signal: new AbortController().signal, + }); + stream.on('data', (chunk) => { + visibleData += chunk; + visibleUpdateCount += 1; + }); + + ai.requests[0].complete(); + const finalData = await ai.requests[0].promise; + + assert.equal(finalData, expectedData); + assert.equal(visibleData, expectedData); + assert.equal(visibleUpdateCount, CHUNK_COUNT); + console.log(`baseline useAI visible updates: ${visibleUpdateCount} for ${CHUNK_COUNT} chunks`); +}); + +test('Raycast useAI stream batcher coalesces many chunks into one visible frame update', () => { + const chunks = makeChunks(); + const expectedData = chunks.join(''); + const ai = createFakeRaycastAI(chunks); + const scheduler = createManualFrameScheduler(); + const dataRef = { current: '' }; + let visibleData = ''; + let visibleUpdateCount = 0; + const batcher = createRaycastAIStreamBatcher({ + dataRef, + setVisibleData(nextData) { + visibleData = nextData; + visibleUpdateCount += 1; + }, + requestFrame: scheduler.requestFrame, + cancelFrame: scheduler.cancelFrame, + }); + + const stream = ai.ask('batched prompt', { + signal: new AbortController().signal, + }); + stream.on('data', (chunk) => batcher.appendChunk(chunk)); + ai.requests[0].emitChunks(); + + assert.equal(dataRef.current, expectedData); + assert.equal(visibleData, ''); + assert.equal(visibleUpdateCount, 0); + assert.equal(scheduler.pendingFrameCount(), 1); + + assert.equal(scheduler.flushFrames(), 1); + assert.equal(visibleData, expectedData); + assert.equal(visibleUpdateCount, 1); + assert.ok(visibleUpdateCount < CHUNK_COUNT / 10); + console.log(`batched useAI visible updates: ${visibleUpdateCount} for ${CHUNK_COUNT} chunks`); +}); + +test('useAI flushes complete final data and preserves final onData behavior', async () => { + const chunks = makeChunks(); + const expectedData = chunks.join(''); + const scheduler = createManualFrameScheduler(); + const ai = createFakeRaycastAI(chunks, { autoComplete: true }); + const onDataCalls = []; + + activeFrameScheduler = scheduler; + testWindow.__supercmdRaycastAI = ai; + const runner = createHookRunner(useAI, [ + 'final prompt', + { + stream: true, + onData: (data) => onDataCalls.push(data), + }, + ]); + + runner.mount(); + assert.equal(ai.requests.length, 1); + + await ai.requests[0].promise; + await drainMicrotasks(); + runner.flush(); + + assert.equal(runner.result.data, expectedData); + assert.equal(runner.result.isLoading, false); + assert.equal(runner.result.error, undefined); + assert.deepEqual(onDataCalls, [expectedData]); + assert.equal(scheduler.pendingFrameCount(), 0); + assert.equal(runner.dataVisibleUpdateCount, 1); + assert.ok(runner.renderCount < CHUNK_COUNT / 10); + console.log(`hook useAI renders: ${runner.renderCount}; visible data updates: ${runner.dataVisibleUpdateCount} for ${CHUNK_COUNT} chunks`); + + activeFrameScheduler = null; +}); + +test('useAI flushes pending streamed data before surfacing an error', async () => { + const chunks = ['partial ', 'answer']; + const expectedData = chunks.join(''); + const scheduler = createManualFrameScheduler(); + const ai = createFakeRaycastAI(chunks); + const onErrorCalls = []; + + activeFrameScheduler = scheduler; + testWindow.__supercmdRaycastAI = ai; + const runner = createHookRunner(useAI, [ + 'error prompt', + { + stream: true, + onError: (error) => onErrorCalls.push(error), + }, + ]); + + runner.mount(); + const request = ai.requests[0]; + request.emitChunks(); + assert.equal(scheduler.pendingFrameCount(), 1); + + request.reject(new Error('network failed')); + await request.promise.catch(() => {}); + await drainMicrotasks(); + runner.flush(); + + assert.equal(runner.result.data, expectedData); + assert.equal(runner.result.isLoading, false); + assert.equal(runner.result.error?.message, 'network failed'); + assert.equal(onErrorCalls.length, 1); + assert.equal(onErrorCalls[0].message, 'network failed'); + assert.equal(scheduler.pendingFrameCount(), 0); + assert.equal(scheduler.flushFrames(), 0); + + activeFrameScheduler = null; +}); + +test('useAI abort cancels a pending stream flush', async () => { + const chunks = ['stale partial']; + const scheduler = createManualFrameScheduler(); + const ai = createFakeRaycastAI(chunks); + + activeFrameScheduler = scheduler; + testWindow.__supercmdRaycastAI = ai; + const runner = createHookRunner(useAI, ['abort prompt', { stream: true }]); + + runner.mount(); + const request = ai.requests[0]; + request.emitChunks(); + + assert.equal(runner.result.data, ''); + assert.equal(scheduler.pendingFrameCount(), 1); + + runner.unmount(); + await request.promise.catch(() => {}); + await drainMicrotasks(); + + assert.equal(request.signal.aborted, true); + assert.equal(scheduler.pendingFrameCount(), 0); + assert.equal(scheduler.flushFrames(), 0); + assert.equal(runner.result.data, ''); + + activeFrameScheduler = null; +}); diff --git a/src/renderer/src/raycast-api/hooks/use-ai.ts b/src/renderer/src/raycast-api/hooks/use-ai.ts index 1bf07c76..fd8a60ab 100644 --- a/src/renderer/src/raycast-api/hooks/use-ai.ts +++ b/src/renderer/src/raycast-api/hooks/use-ai.ts @@ -7,6 +7,113 @@ import { useCallback, useEffect, useRef, useState } from 'react'; type AICreativity = 'none' | 'low' | 'medium' | 'high' | 'maximum' | number; +export const RAYCAST_AI_STREAM_FLUSH_INTERVAL_MS = 32; + +interface RaycastAIStreamDataRef { + current: string; +} + +export interface RaycastAIStreamBatcherOptions { + dataRef: RaycastAIStreamDataRef; + setVisibleData: (data: string) => void; + requestFrame?: (callback: () => void) => number; + cancelFrame?: (handle: number) => void; + setTimer?: (callback: () => void, delayMs: number) => ReturnType; + clearTimer?: (handle: ReturnType) => void; + flushDelayMs?: number; +} + +export interface RaycastAIStreamBatcher { + appendChunk: (chunk: string) => void; + cancelPendingFlush: () => void; + flush: () => void; + reset: (data?: string) => void; +} + +function createDefaultRequestFrame(): ((callback: () => void) => number) | undefined { + if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') { + return undefined; + } + return (callback) => window.requestAnimationFrame(() => callback()); +} + +function createDefaultCancelFrame(): ((handle: number) => void) | undefined { + if (typeof window === 'undefined' || typeof window.cancelAnimationFrame !== 'function') { + return undefined; + } + return (handle) => window.cancelAnimationFrame(handle); +} + +export function createRaycastAIStreamBatcher({ + dataRef, + setVisibleData, + requestFrame = createDefaultRequestFrame(), + cancelFrame = createDefaultCancelFrame(), + setTimer = globalThis.setTimeout.bind(globalThis), + clearTimer = globalThis.clearTimeout.bind(globalThis), + flushDelayMs = RAYCAST_AI_STREAM_FLUSH_INTERVAL_MS, +}: RaycastAIStreamBatcherOptions): RaycastAIStreamBatcher { + let visibleData = dataRef.current; + let pendingFrame: number | null = null; + let pendingTimer: ReturnType | null = null; + + const publishVisibleData = () => { + const nextData = dataRef.current; + if (nextData === visibleData) return; + visibleData = nextData; + setVisibleData(nextData); + }; + + const cancelPendingFlush = () => { + if (pendingFrame !== null) { + cancelFrame?.(pendingFrame); + pendingFrame = null; + } + if (pendingTimer !== null) { + clearTimer(pendingTimer); + pendingTimer = null; + } + }; + + const flush = () => { + cancelPendingFlush(); + publishVisibleData(); + }; + + const runScheduledFlush = () => { + pendingFrame = null; + pendingTimer = null; + publishVisibleData(); + }; + + const scheduleFlush = () => { + if (pendingFrame !== null || pendingTimer !== null) return; + + if (requestFrame) { + pendingFrame = requestFrame(runScheduledFlush); + return; + } + + pendingTimer = setTimer(runScheduledFlush, flushDelayMs); + }; + + return { + appendChunk(chunk: string) { + if (!chunk) return; + dataRef.current += chunk; + scheduleFlush(); + }, + cancelPendingFlush, + flush, + reset(data = '') { + cancelPendingFlush(); + dataRef.current = data; + visibleData = data; + setVisibleData(data); + }, + }; +} + export function useAI( prompt: string, options?: { @@ -24,17 +131,27 @@ export function useAI( const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(undefined); const abortRef = useRef(null); + const dataRef = useRef(''); + const streamBatcherRef = useRef(null); const promptRef = useRef(prompt); const optionsRef = useRef(options); promptRef.current = prompt; optionsRef.current = options; + if (!streamBatcherRef.current) { + streamBatcherRef.current = createRaycastAIStreamBatcher({ + dataRef, + setVisibleData: setData, + }); + } + const shouldExecute = options?.execute !== false; const stream = options?.stream !== false; const run = useCallback(() => { if (!promptRef.current) return; const opts = optionsRef.current; + const streamBatcher = streamBatcherRef.current; abortRef.current?.abort(); const controller = new AbortController(); @@ -42,7 +159,7 @@ export function useAI( setIsLoading(true); setError(undefined); - setData(''); + streamBatcher?.reset(''); opts?.onWillExecute?.([promptRef.current]); @@ -64,20 +181,22 @@ export function useAI( if (stream) { sp.on('data', (chunk: string) => { if (!controller.signal.aborted) { - setData((prev) => prev + chunk); + streamBatcher?.appendChunk(chunk); } }); } sp.then((fullText: string) => { if (!controller.signal.aborted) { - if (!stream) setData(fullText); + dataRef.current = fullText; + streamBatcher?.flush(); setIsLoading(false); opts?.onData?.(fullText); } }).catch((err: any) => { if (!controller.signal.aborted) { const e = err instanceof Error ? err : new Error(err?.message || 'AI request failed'); + streamBatcher?.flush(); setError(e); setIsLoading(false); opts?.onError?.(e); @@ -91,6 +210,7 @@ export function useAI( } return () => { abortRef.current?.abort(); + streamBatcherRef.current?.cancelPendingFlush(); }; }, [shouldExecute, run]); From 6f06eb4dbd9b8099c76f1009308ce6bf5b9f673e Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:56:16 +0200 Subject: [PATCH 4/8] perf(settings): coalesce AI status polling --- scripts/test-ai-model-status-polling.mjs | 277 ++++++++++++++++++ src/renderer/src/settings/AITab.tsx | 108 ++++--- .../src/settings/aiModelStatusPolling.ts | 110 +++++++ 3 files changed, 438 insertions(+), 57 deletions(-) create mode 100644 scripts/test-ai-model-status-polling.mjs create mode 100644 src/renderer/src/settings/aiModelStatusPolling.ts diff --git a/scripts/test-ai-model-status-polling.mjs b/scripts/test-ai-model-status-polling.mjs new file mode 100644 index 00000000..e20149e2 --- /dev/null +++ b/scripts/test-ai-model-status-polling.mjs @@ -0,0 +1,277 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { importTs } from './lib/ts-import.mjs'; + +const FIVE_SECOND_TICKS = 5; + +const { + applyAiModelStatusPatch, + createEmptyAiModelStatusSnapshot, + fetchAiModelStatusPollPatch, +} = await importTs(path.resolve('src/renderer/src/settings/aiModelStatusPolling.ts')); + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function makeWhisperCppStatus(overrides = {}) { + return { + state: 'downloaded', + modelName: 'base', + path: '/models/ggml-base.bin', + bytesDownloaded: 1024, + totalBytes: 1024, + ...overrides, + }; +} + +function makeParakeetStatus(overrides = {}) { + return { + state: 'downloaded', + modelName: 'parakeet-tdt-0.6b-v3', + path: '/models/parakeet', + progress: 1, + ...overrides, + }; +} + +function makeQwen3Status(overrides = {}) { + return { + state: 'downloaded', + modelName: 'qwen3-asr', + path: '/models/qwen3', + progress: 1, + ...overrides, + }; +} + +function makeSnapshot(overrides = {}) { + return { + whisperCpp: makeWhisperCppStatus(overrides.whisperCpp), + parakeet: makeParakeetStatus(overrides.parakeet), + qwen3: makeQwen3Status(overrides.qwen3), + }; +} + +function createStaticFetchers(snapshot) { + const calls = { + whisperCpp: 0, + parakeet: 0, + qwen3: 0, + }; + + return { + calls, + fetchers: { + async whisperCpp() { + calls.whisperCpp += 1; + return clone(snapshot.whisperCpp); + }, + async parakeet() { + calls.parakeet += 1; + return clone(snapshot.parakeet); + }, + async qwen3() { + calls.qwen3 += 1; + return clone(snapshot.qwen3); + }, + }, + }; +} + +function createFixedPollingHarness(initialSnapshot) { + let current = initialSnapshot; + let stateUpdates = 0; + let profilerCommits = 0; + + function applyPatch(patch) { + const next = applyAiModelStatusPatch(current, patch); + if (next === current) return false; + + current = next; + stateUpdates += 1; + profilerCommits += 1; + return true; + } + + return { + async poll(fetchers) { + const patch = await fetchAiModelStatusPollPatch(fetchers); + applyPatch(patch); + }, + applyPatch, + get current() { + return current; + }, + get metrics() { + return { stateUpdates, profilerCommits }; + }, + }; +} + +async function simulateLegacyIndependentPolling(fetchers, ticks) { + let stateUpdates = 0; + let profilerCommits = 0; + + for (let tick = 0; tick < ticks; tick += 1) { + await fetchers.whisperCpp(); + stateUpdates += 1; + profilerCommits += 1; + + await fetchers.parakeet(); + stateUpdates += 1; + profilerCommits += 1; + + await fetchers.qwen3(); + stateUpdates += 1; + profilerCommits += 1; + } + + return { stateUpdates, profilerCommits }; +} + +test('AI model status polling baseline performs three unchanged state writes per second', async () => { + const snapshot = makeSnapshot(); + const { fetchers, calls } = createStaticFetchers(snapshot); + + const metrics = await simulateLegacyIndependentPolling(fetchers, FIVE_SECOND_TICKS); + + console.log( + `[ai-status baseline] ticks=${FIVE_SECOND_TICKS} stateUpdates=${metrics.stateUpdates} profilerCommits=${metrics.profilerCommits}` + ); + assert.deepEqual(calls, { whisperCpp: 5, parakeet: 5, qwen3: 5 }); + assert.deepEqual(metrics, { stateUpdates: 15, profilerCommits: 15 }); +}); + +test('coalesced AI model status polling skips commits when statuses are unchanged', async () => { + const snapshot = makeSnapshot(); + const { fetchers, calls } = createStaticFetchers(snapshot); + const harness = createFixedPollingHarness(clone(snapshot)); + + for (let tick = 0; tick < FIVE_SECOND_TICKS; tick += 1) { + await harness.poll(fetchers); + } + + console.log( + `[ai-status coalesced unchanged] ticks=${FIVE_SECOND_TICKS} stateUpdates=${harness.metrics.stateUpdates} profilerCommits=${harness.metrics.profilerCommits}` + ); + assert.deepEqual(calls, { whisperCpp: 5, parakeet: 5, qwen3: 5 }); + assert.deepEqual(harness.metrics, { stateUpdates: 0, profilerCommits: 0 }); +}); + +test('coalesced AI model status polling commits once per tick when all statuses change', async () => { + let tickValue = 0; + const calls = { + whisperCpp: 0, + parakeet: 0, + qwen3: 0, + }; + const harness = createFixedPollingHarness(makeSnapshot({ + whisperCpp: { bytesDownloaded: 0, totalBytes: 100 }, + parakeet: { progress: 0 }, + qwen3: { progress: 0 }, + })); + const fetchers = { + async whisperCpp() { + calls.whisperCpp += 1; + return makeWhisperCppStatus({ + state: 'downloading', + bytesDownloaded: tickValue, + totalBytes: 100, + }); + }, + async parakeet() { + calls.parakeet += 1; + return makeParakeetStatus({ + state: 'downloading', + progress: tickValue / FIVE_SECOND_TICKS, + }); + }, + async qwen3() { + calls.qwen3 += 1; + return makeQwen3Status({ + state: 'downloading', + progress: tickValue / FIVE_SECOND_TICKS, + }); + }, + }; + + for (let tick = 1; tick <= FIVE_SECOND_TICKS; tick += 1) { + tickValue = tick; + await harness.poll(fetchers); + } + + console.log( + `[ai-status coalesced changing] ticks=${FIVE_SECOND_TICKS} stateUpdates=${harness.metrics.stateUpdates} profilerCommits=${harness.metrics.profilerCommits}` + ); + assert.deepEqual(calls, { whisperCpp: 5, parakeet: 5, qwen3: 5 }); + assert.deepEqual(harness.metrics, { stateUpdates: 5, profilerCommits: 5 }); + assert.equal(harness.current.whisperCpp.bytesDownloaded, FIVE_SECOND_TICKS); + assert.equal(harness.current.parakeet.progress, 1); + assert.equal(harness.current.qwen3.progress, 1); +}); + +test('download progress status patches still commit when progress fields change', () => { + const harness = createFixedPollingHarness(createEmptyAiModelStatusSnapshot()); + + assert.equal(harness.applyPatch({ + whisperCpp: makeWhisperCppStatus({ + state: 'downloading', + bytesDownloaded: 10, + totalBytes: 100, + }), + }), true); + assert.equal(harness.applyPatch({ + whisperCpp: makeWhisperCppStatus({ + state: 'downloading', + bytesDownloaded: 10, + totalBytes: 100, + }), + }), false); + assert.equal(harness.applyPatch({ + whisperCpp: makeWhisperCppStatus({ + state: 'downloading', + bytesDownloaded: 25, + totalBytes: 100, + }), + }), true); + assert.equal(harness.applyPatch({ + parakeet: makeParakeetStatus({ state: 'downloading', progress: 0.25 }), + }), true); + assert.equal(harness.applyPatch({ + qwen3: makeQwen3Status({ state: 'downloading', progress: 0.5 }), + }), true); + + assert.deepEqual(harness.metrics, { stateUpdates: 4, profilerCommits: 4 }); + assert.equal(harness.current.whisperCpp.bytesDownloaded, 25); + assert.equal(harness.current.parakeet.progress, 0.25); + assert.equal(harness.current.qwen3.progress, 0.5); +}); + +test('coalesced poll starts all status requests before awaiting any one result', async () => { + const started = []; + const resolvers = []; + const fetchers = { + whisperCpp: () => new Promise((resolve) => { + started.push('whisperCpp'); + resolvers.push(() => resolve(makeWhisperCppStatus())); + }), + parakeet: () => new Promise((resolve) => { + started.push('parakeet'); + resolvers.push(() => resolve(makeParakeetStatus())); + }), + qwen3: () => new Promise((resolve) => { + started.push('qwen3'); + resolvers.push(() => resolve(makeQwen3Status())); + }), + }; + + const patchPromise = fetchAiModelStatusPollPatch(fetchers); + + assert.deepEqual(started, ['whisperCpp', 'parakeet', 'qwen3']); + for (const resolve of resolvers) resolve(); + assert.deepEqual(await patchPromise, makeSnapshot()); +}); diff --git a/src/renderer/src/settings/AITab.tsx b/src/renderer/src/settings/AITab.tsx index c27befdd..ab8d1784 100644 --- a/src/renderer/src/settings/AITab.tsx +++ b/src/renderer/src/settings/AITab.tsx @@ -28,9 +28,6 @@ import type { AISettings, EdgeTtsVoice, ElevenLabsVoice, - WhisperCppModelStatus, - ParakeetModelStatus, - Qwen3ModelStatus, } from '../../types/electron'; import { useI18n } from '../i18n'; import { @@ -38,6 +35,14 @@ import { getCachedElevenLabsVoices, setCachedElevenLabsVoices, } from '../utils/voice-cache'; +import { + AI_MODEL_STATUS_POLL_INTERVAL_MS, + applyAiModelStatusPatch, + createEmptyAiModelStatusSnapshot, + fetchAiModelStatusPollPatch, + type AiModelStatusPatch, + type AiModelStatusSnapshot, +} from './aiModelStatusPolling'; const getProviderOptions = (t: (key: string) => string) => [ { id: 'openai' as const, label: t('settings.ai.llm.provider.openai'), description: t('settings.ai.llm.providerDescriptions.openai') }, @@ -269,11 +274,15 @@ const AITab: React.FC = () => { const [elevenLabsVoices, setElevenLabsVoices] = useState([]); const [elevenLabsVoicesLoading, setElevenLabsVoicesLoading] = useState(false); const [elevenLabsVoicesError, setElevenLabsVoicesError] = useState(null); - const [whisperCppModelStatus, setWhisperCppModelStatus] = useState(null); + const [aiModelStatuses, setAiModelStatuses] = useState(() => createEmptyAiModelStatusSnapshot()); + const aiModelStatusesRef = useRef(aiModelStatuses); + const { + whisperCpp: whisperCppModelStatus, + parakeet: parakeetModelStatus, + qwen3: qwen3ModelStatus, + } = aiModelStatuses; const [whisperCppModelLoading, setWhisperCppModelLoading] = useState(false); - const [parakeetModelStatus, setParakeetModelStatus] = useState(null); const [parakeetModelLoading, setParakeetModelLoading] = useState(false); - const [qwen3ModelStatus, setQwen3ModelStatus] = useState(null); const [qwen3ModelLoading, setQwen3ModelLoading] = useState(false); const [whisperCustomMode, setWhisperCustomMode] = useState(false); const whisperSpeakToggleHotkey = (settings?.commandHotkeys || {})[WHISPER_SPEAK_TOGGLE_COMMAND_ID] ?? ''; @@ -292,6 +301,10 @@ const AITab: React.FC = () => { settingsRef.current = settings; }, [settings]); + useEffect(() => { + aiModelStatusesRef.current = aiModelStatuses; + }, [aiModelStatuses]); + const fetchLmStudioModels = useCallback((baseUrl: string) => { if (lmStudioFetchTimerRef.current) clearTimeout(lmStudioFetchTimerRef.current); lmStudioFetchTimerRef.current = setTimeout(() => { @@ -405,15 +418,31 @@ const AITab: React.FC = () => { setTimeout(() => setSaveStatus('idle'), 1600); }; + const applyAiModelStatuses = useCallback((patch: AiModelStatusPatch): boolean => { + const current = aiModelStatusesRef.current; + const next = applyAiModelStatusPatch(current, patch); + if (next === current) return false; + + aiModelStatusesRef.current = next; + setAiModelStatuses(next); + return true; + }, []); + + const fetchAiModelStatusPatch = useCallback(() => fetchAiModelStatusPollPatch({ + whisperCpp: () => window.electron.whisperCppModelStatus(), + parakeet: () => window.electron.parakeetModelStatus(), + qwen3: () => window.electron.qwen3ModelStatus(), + }), []); + const refreshWhisperCppModelStatus = useCallback(async () => { try { const status = await window.electron.whisperCppModelStatus(); - setWhisperCppModelStatus(status); + applyAiModelStatuses({ whisperCpp: status }); return status; } catch { return null; } - }, []); + }, [applyAiModelStatuses]); useEffect(() => { if (activeTab !== 'whisper') return; @@ -421,9 +450,10 @@ const AITab: React.FC = () => { let timer: number | null = null; const tick = async () => { - await refreshWhisperCppModelStatus(); + const patch = await fetchAiModelStatusPatch(); if (cancelled) return; - timer = window.setTimeout(() => { void tick(); }, 1000); + applyAiModelStatuses(patch); + timer = window.setTimeout(() => { void tick(); }, AI_MODEL_STATUS_POLL_INTERVAL_MS); }; void tick(); @@ -431,99 +461,63 @@ const AITab: React.FC = () => { cancelled = true; if (timer !== null) window.clearTimeout(timer); }; - }, [activeTab, refreshWhisperCppModelStatus]); + }, [activeTab, applyAiModelStatuses, fetchAiModelStatusPatch]); const handleWhisperCppDownload = useCallback(async () => { setWhisperCppModelLoading(true); try { const status = await window.electron.whisperCppDownloadModel(); - setWhisperCppModelStatus(status); + applyAiModelStatuses({ whisperCpp: status }); } catch { void refreshWhisperCppModelStatus(); } finally { setWhisperCppModelLoading(false); } - }, [refreshWhisperCppModelStatus]); + }, [applyAiModelStatuses, refreshWhisperCppModelStatus]); const refreshParakeetModelStatus = useCallback(async () => { try { const status = await window.electron.parakeetModelStatus(); - setParakeetModelStatus(status); + applyAiModelStatuses({ parakeet: status }); return status; } catch { return null; } - }, []); - - useEffect(() => { - if (activeTab !== 'whisper') return; - let cancelled = false; - let timer: number | null = null; - - const tick = async () => { - await refreshParakeetModelStatus(); - if (cancelled) return; - timer = window.setTimeout(() => { void tick(); }, 1000); - }; - - void tick(); - return () => { - cancelled = true; - if (timer !== null) window.clearTimeout(timer); - }; - }, [activeTab, refreshParakeetModelStatus]); + }, [applyAiModelStatuses]); const handleParakeetDownload = useCallback(async () => { setParakeetModelLoading(true); try { const status = await window.electron.parakeetDownloadModel(); - setParakeetModelStatus(status); + applyAiModelStatuses({ parakeet: status }); } catch { void refreshParakeetModelStatus(); } finally { setParakeetModelLoading(false); } - }, [refreshParakeetModelStatus]); + }, [applyAiModelStatuses, refreshParakeetModelStatus]); const refreshQwen3ModelStatus = useCallback(async () => { try { const status = await window.electron.qwen3ModelStatus(); - setQwen3ModelStatus(status); + applyAiModelStatuses({ qwen3: status }); return status; } catch { return null; } - }, []); - - useEffect(() => { - if (activeTab !== 'whisper') return; - let cancelled = false; - let timer: number | null = null; - - const tick = async () => { - await refreshQwen3ModelStatus(); - if (cancelled) return; - timer = window.setTimeout(() => { void tick(); }, 1000); - }; - - void tick(); - return () => { - cancelled = true; - if (timer !== null) window.clearTimeout(timer); - }; - }, [activeTab, refreshQwen3ModelStatus]); + }, [applyAiModelStatuses]); const handleQwen3Download = useCallback(async () => { setQwen3ModelLoading(true); try { const status = await window.electron.qwen3DownloadModel(); - setQwen3ModelStatus(status); + applyAiModelStatuses({ qwen3: status }); } catch { void refreshQwen3ModelStatus(); } finally { setQwen3ModelLoading(false); } - }, [refreshQwen3ModelStatus]); + }, [applyAiModelStatuses, refreshQwen3ModelStatus]); const maybeSelectOllamaDefaultModel = useCallback((availableNames: string[], preferredName?: string) => { const currentSettings = settingsRef.current; diff --git a/src/renderer/src/settings/aiModelStatusPolling.ts b/src/renderer/src/settings/aiModelStatusPolling.ts new file mode 100644 index 00000000..387ad2cd --- /dev/null +++ b/src/renderer/src/settings/aiModelStatusPolling.ts @@ -0,0 +1,110 @@ +import type { + ParakeetModelStatus, + Qwen3ModelStatus, + WhisperCppModelStatus, +} from '../../types/electron'; + +export const AI_MODEL_STATUS_POLL_INTERVAL_MS = 1000; + +export type AiModelStatusSnapshot = { + whisperCpp: WhisperCppModelStatus | null; + parakeet: ParakeetModelStatus | null; + qwen3: Qwen3ModelStatus | null; +}; + +export type AiModelStatusPatch = Partial; + +export type AiModelStatusFetchers = { + whisperCpp: () => Promise; + parakeet: () => Promise; + qwen3: () => Promise; +}; + +export function createEmptyAiModelStatusSnapshot(): AiModelStatusSnapshot { + return { + whisperCpp: null, + parakeet: null, + qwen3: null, + }; +} + +function sameOptionalString(left?: string, right?: string): boolean { + return (left || '') === (right || ''); +} + +export function areWhisperCppModelStatusesEqual( + left: WhisperCppModelStatus | null, + right: WhisperCppModelStatus | null +): boolean { + if (left === right) return true; + if (!left || !right) return false; + + return left.state === right.state + && left.modelName === right.modelName + && left.path === right.path + && left.bytesDownloaded === right.bytesDownloaded + && left.totalBytes === right.totalBytes + && sameOptionalString(left.error, right.error); +} + +export function areParakeetModelStatusesEqual( + left: ParakeetModelStatus | null, + right: ParakeetModelStatus | null +): boolean { + if (left === right) return true; + if (!left || !right) return false; + + return left.state === right.state + && left.modelName === right.modelName + && left.path === right.path + && left.progress === right.progress + && sameOptionalString(left.error, right.error); +} + +export function areQwen3ModelStatusesEqual( + left: Qwen3ModelStatus | null, + right: Qwen3ModelStatus | null +): boolean { + if (left === right) return true; + if (!left || !right) return false; + + return left.state === right.state + && left.modelName === right.modelName + && left.path === right.path + && left.progress === right.progress + && sameOptionalString(left.error, right.error); +} + +export function areAiModelStatusSnapshotsEqual( + left: AiModelStatusSnapshot, + right: AiModelStatusSnapshot +): boolean { + return areWhisperCppModelStatusesEqual(left.whisperCpp, right.whisperCpp) + && areParakeetModelStatusesEqual(left.parakeet, right.parakeet) + && areQwen3ModelStatusesEqual(left.qwen3, right.qwen3); +} + +export function applyAiModelStatusPatch( + current: AiModelStatusSnapshot, + patch: AiModelStatusPatch +): AiModelStatusSnapshot { + const next: AiModelStatusSnapshot = { + whisperCpp: patch.whisperCpp === undefined ? current.whisperCpp : patch.whisperCpp, + parakeet: patch.parakeet === undefined ? current.parakeet : patch.parakeet, + qwen3: patch.qwen3 === undefined ? current.qwen3 : patch.qwen3, + }; + + return areAiModelStatusSnapshotsEqual(current, next) ? current : next; +} + +export async function fetchAiModelStatusPollPatch( + fetchers: AiModelStatusFetchers +): Promise { + const [whisperCpp, parakeet, qwen3] = await Promise.all([ + fetchers.whisperCpp().catch(() => undefined), + fetchers.parakeet().catch(() => undefined), + fetchers.qwen3().catch(() => undefined), + ]); + + return { whisperCpp, parakeet, qwen3 }; +} From d3b1978988c043b17c1cd234040558654a72bda2 Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:16:29 +0200 Subject: [PATCH 5/8] perf(ai): unify stream flush scheduling --- scripts/lib/ts-import.mjs | 23 +++-- scripts/test-ai-chat-stream-batching.mjs | 21 ++--- .../test-cursor-prompt-stream-batching.mjs | 73 +++++++++++++++ scripts/test-use-ai-stream-batching.mjs | 71 +++++++++++++- .../src/hooks/cursorPromptResultBatcher.ts | 65 ++++--------- src/renderer/src/raycast-api/hooks/use-ai.ts | 62 ++++--------- .../src/utils/ai-chat-stream-buffer.ts | 37 ++++---- .../src/utils/streamFlushScheduler.ts | 93 +++++++++++++++++++ 8 files changed, 313 insertions(+), 132 deletions(-) create mode 100644 src/renderer/src/utils/streamFlushScheduler.ts diff --git a/scripts/lib/ts-import.mjs b/scripts/lib/ts-import.mjs index 49df6079..cd39884b 100644 --- a/scripts/lib/ts-import.mjs +++ b/scripts/lib/ts-import.mjs @@ -1,16 +1,19 @@ -// Import a self-contained TypeScript module from a test by transpiling it with -// esbuild on the fly. This lets the recovery tests run the *real* production -// source (renderer-recovery.ts, reload-budget.ts) instead of grepping it. -// -// Only works for modules with no relative imports of their own — the recovery -// helpers are deliberately dependency-free for exactly this reason. +// Import a TypeScript module from a test by bundling it with esbuild on the fly. +// This lets focused script tests run real production source without requiring a +// separate build step. -import { transform } from 'esbuild'; -import fs from 'node:fs'; +import { build } from 'esbuild'; export async function importTs(absPath) { - const src = fs.readFileSync(absPath, 'utf8'); - const { code } = await transform(src, { loader: 'ts', format: 'esm' }); + const result = await build({ + bundle: true, + entryPoints: [absPath], + format: 'esm', + platform: 'browser', + target: 'es2020', + write: false, + }); + const code = result.outputFiles[0].text; const dataUrl = 'data:text/javascript;base64,' + Buffer.from(code).toString('base64'); return import(dataUrl); } diff --git a/scripts/test-ai-chat-stream-batching.mjs b/scripts/test-ai-chat-stream-batching.mjs index 066a2c4b..f382f947 100644 --- a/scripts/test-ai-chat-stream-batching.mjs +++ b/scripts/test-ai-chat-stream-batching.mjs @@ -1,32 +1,29 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import fs from 'node:fs'; import path from 'node:path'; import { performance } from 'node:perf_hooks'; import vm from 'node:vm'; import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); -const ts = require('typescript'); +const { buildSync } = require('esbuild'); const CHUNK_COUNT = 240; function loadTsModule(filePath) { const resolvedPath = path.resolve(filePath); - const source = fs.readFileSync(resolvedPath, 'utf8'); - const transpiled = ts.transpileModule(source, { - compilerOptions: { - module: ts.ModuleKind.CommonJS, - target: ts.ScriptTarget.ES2022, - esModuleInterop: true, - importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, - }, - fileName: resolvedPath, + const bundled = buildSync({ + bundle: true, + entryPoints: [resolvedPath], + format: 'cjs', + platform: 'node', + target: 'es2022', + write: false, }); const module = { exports: {} }; - vm.runInNewContext(transpiled.outputText, { + vm.runInNewContext(bundled.outputFiles[0].text, { module, exports: module.exports, require, diff --git a/scripts/test-cursor-prompt-stream-batching.mjs b/scripts/test-cursor-prompt-stream-batching.mjs index b9241522..4bc4430a 100644 --- a/scripts/test-cursor-prompt-stream-batching.mjs +++ b/scripts/test-cursor-prompt-stream-batching.mjs @@ -54,6 +54,34 @@ function createManualFrameScheduler() { }; } +function createManualTimerScheduler() { + let nextTimerId = 1; + const callbacks = new Map(); + + return { + setTimer(callback) { + const id = nextTimerId; + nextTimerId += 1; + callbacks.set(id, callback); + return id; + }, + clearTimer(id) { + callbacks.delete(id); + }, + flushTimers() { + const queuedCallbacks = Array.from(callbacks.values()); + callbacks.clear(); + for (const callback of queuedCallbacks) { + callback(); + } + return queuedCallbacks.length; + }, + pendingTimerCount() { + return callbacks.size; + }, + }; +} + function createBatcherHarness() { const scheduler = createManualFrameScheduler(); const resultRef = { current: '' }; @@ -82,6 +110,38 @@ function createBatcherHarness() { }; } +function createHiddenDocumentBatcherHarness() { + const frameScheduler = createManualFrameScheduler(); + const timerScheduler = createManualTimerScheduler(); + const resultRef = { current: '' }; + let visibleResult = ''; + let visibleUpdateCount = 0; + const batcher = createCursorPromptResultBatcher({ + resultRef, + setVisibleResult(nextResult) { + visibleResult = nextResult; + visibleUpdateCount += 1; + }, + requestFrame: frameScheduler.requestFrame, + cancelFrame: frameScheduler.cancelFrame, + setTimer: timerScheduler.setTimer, + clearTimer: timerScheduler.clearTimer, + isDocumentHidden: () => true, + }); + + return { + batcher, + frameScheduler, + timerScheduler, + get visibleResult() { + return visibleResult; + }, + get visibleUpdateCount() { + return visibleUpdateCount; + }, + }; +} + test('baseline cursor prompt streaming updates visible state once per chunk', () => { const chunks = makeChunks(CHUNK_COUNT); const expectedResult = chunks.join(''); @@ -153,3 +213,16 @@ test('cursor prompt result batching can cancel a pending repaint without changin assert.equal(harness.visibleResult, ''); assert.equal(harness.visibleUpdateCount, 0); }); + +test('cursor prompt result batching uses timer flushes while the document is hidden', () => { + const harness = createHiddenDocumentBatcherHarness(); + + harness.batcher.appendChunk('background '); + harness.batcher.appendChunk('update'); + + assert.equal(harness.frameScheduler.pendingFrameCount(), 0); + assert.equal(harness.timerScheduler.pendingTimerCount(), 1); + assert.equal(harness.timerScheduler.flushTimers(), 1); + assert.equal(harness.visibleResult, 'background update'); + assert.equal(harness.visibleUpdateCount, 1); +}); diff --git a/scripts/test-use-ai-stream-batching.mjs b/scripts/test-use-ai-stream-batching.mjs index 5d98bae0..758750f6 100644 --- a/scripts/test-use-ai-stream-batching.mjs +++ b/scripts/test-use-ai-stream-batching.mjs @@ -4,7 +4,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; -import { transform } from 'esbuild'; +import { build, transform } from 'esbuild'; const CHUNK_COUNT = 600; @@ -101,7 +101,19 @@ async function importUseAiModule(absPath) { format: 'esm', target: 'es2020', }); - const dataUrl = 'data:text/javascript;base64,' + Buffer.from(code).toString('base64'); + const bundled = await build({ + bundle: true, + format: 'esm', + platform: 'browser', + stdin: { + contents: code, + loader: 'js', + resolveDir: path.dirname(absPath), + }, + target: 'es2020', + write: false, + }); + const dataUrl = 'data:text/javascript;base64,' + Buffer.from(bundled.outputFiles[0].text).toString('base64'); return import(dataUrl); } @@ -131,6 +143,32 @@ function createManualFrameScheduler() { }; } +function createManualTimerScheduler() { + let nextTimerId = 1; + const callbacks = new Map(); + + return { + setTimer(callback) { + const id = nextTimerId; + nextTimerId += 1; + callbacks.set(id, callback); + return id; + }, + clearTimer(id) { + callbacks.delete(id); + }, + flushTimers() { + const queuedCallbacks = Array.from(callbacks.values()); + callbacks.clear(); + for (const callback of queuedCallbacks) callback(); + return queuedCallbacks.length; + }, + pendingTimerCount() { + return callbacks.size; + }, + }; +} + function createFakeRaycastAI(chunks, options = {}) { const fullText = options.fullText ?? chunks.join(''); const requests = []; @@ -425,6 +463,35 @@ test('Raycast useAI stream batcher coalesces many chunks into one visible frame console.log(`batched useAI visible updates: ${visibleUpdateCount} for ${CHUNK_COUNT} chunks`); }); +test('Raycast useAI stream batcher uses timer flushes while the document is hidden', () => { + const frameScheduler = createManualFrameScheduler(); + const timerScheduler = createManualTimerScheduler(); + const dataRef = { current: '' }; + let visibleData = ''; + let visibleUpdateCount = 0; + const batcher = createRaycastAIStreamBatcher({ + dataRef, + setVisibleData(nextData) { + visibleData = nextData; + visibleUpdateCount += 1; + }, + requestFrame: frameScheduler.requestFrame, + cancelFrame: frameScheduler.cancelFrame, + setTimer: timerScheduler.setTimer, + clearTimer: timerScheduler.clearTimer, + isDocumentHidden: () => true, + }); + + batcher.appendChunk('background '); + batcher.appendChunk('stream'); + + assert.equal(frameScheduler.pendingFrameCount(), 0); + assert.equal(timerScheduler.pendingTimerCount(), 1); + assert.equal(timerScheduler.flushTimers(), 1); + assert.equal(visibleData, 'background stream'); + assert.equal(visibleUpdateCount, 1); +}); + test('useAI flushes complete final data and preserves final onData behavior', async () => { const chunks = makeChunks(); const expectedData = chunks.join(''); diff --git a/src/renderer/src/hooks/cursorPromptResultBatcher.ts b/src/renderer/src/hooks/cursorPromptResultBatcher.ts index 0cf68fe3..efcfc6bd 100644 --- a/src/renderer/src/hooks/cursorPromptResultBatcher.ts +++ b/src/renderer/src/hooks/cursorPromptResultBatcher.ts @@ -1,3 +1,5 @@ +import { createStreamFlushScheduler } from '../utils/streamFlushScheduler'; + export const CURSOR_PROMPT_RESULT_FLUSH_INTERVAL_MS = 32; export interface CursorPromptResultRef { @@ -12,6 +14,7 @@ export interface CursorPromptResultBatcherOptions { setTimer?: (callback: () => void, delayMs: number) => ReturnType; clearTimer?: (handle: ReturnType) => void; flushDelayMs?: number; + isDocumentHidden?: () => boolean; } export interface CursorPromptResultBatcher { @@ -22,32 +25,17 @@ export interface CursorPromptResultBatcher { dispose: () => void; } -function createDefaultRequestFrame(): ((callback: () => void) => number) | undefined { - if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') { - return undefined; - } - return (callback) => window.requestAnimationFrame(() => callback()); -} - -function createDefaultCancelFrame(): ((handle: number) => void) | undefined { - if (typeof window === 'undefined' || typeof window.cancelAnimationFrame !== 'function') { - return undefined; - } - return (handle) => window.cancelAnimationFrame(handle); -} - export function createCursorPromptResultBatcher({ resultRef, setVisibleResult, - requestFrame = createDefaultRequestFrame(), - cancelFrame = createDefaultCancelFrame(), + requestFrame, + cancelFrame, setTimer = globalThis.setTimeout.bind(globalThis), clearTimer = globalThis.clearTimeout.bind(globalThis), flushDelayMs = CURSOR_PROMPT_RESULT_FLUSH_INTERVAL_MS, + isDocumentHidden, }: CursorPromptResultBatcherOptions): CursorPromptResultBatcher { let visibleResult = resultRef.current; - let pendingFrame: number | null = null; - let pendingTimer: ReturnType | null = null; const publishVisibleResult = () => { const nextResult = resultRef.current; @@ -56,32 +44,17 @@ export function createCursorPromptResultBatcher({ setVisibleResult(nextResult); }; - const cancelPendingFlush = () => { - if (pendingFrame !== null) { - cancelFrame?.(pendingFrame); - pendingFrame = null; - } - if (pendingTimer !== null) { - clearTimer(pendingTimer); - pendingTimer = null; - } - }; - - const runScheduledFlush = () => { - pendingFrame = null; - pendingTimer = null; - publishVisibleResult(); - }; + const scheduler = createStreamFlushScheduler(publishVisibleResult, { + requestFrame, + cancelFrame: cancelFrame as ((handle: unknown) => void) | undefined, + setTimer, + clearTimer, + flushDelayMs, + isDocumentHidden, + }); const scheduleFlush = () => { - if (pendingFrame !== null || pendingTimer !== null) return; - - if (requestFrame) { - pendingFrame = requestFrame(runScheduledFlush); - return; - } - - pendingTimer = setTimer(runScheduledFlush, flushDelayMs); + scheduler.schedule(); }; return { @@ -91,16 +64,16 @@ export function createCursorPromptResultBatcher({ scheduleFlush(); }, flush() { - cancelPendingFlush(); + scheduler.cancel(); publishVisibleResult(); }, reset(nextResult = '') { - cancelPendingFlush(); + scheduler.cancel(); resultRef.current = nextResult; visibleResult = nextResult; setVisibleResult(nextResult); }, - cancelPendingFlush, - dispose: cancelPendingFlush, + cancelPendingFlush: scheduler.cancel, + dispose: scheduler.cancel, }; } diff --git a/src/renderer/src/raycast-api/hooks/use-ai.ts b/src/renderer/src/raycast-api/hooks/use-ai.ts index fd8a60ab..5f0c60a4 100644 --- a/src/renderer/src/raycast-api/hooks/use-ai.ts +++ b/src/renderer/src/raycast-api/hooks/use-ai.ts @@ -4,6 +4,7 @@ */ import { useCallback, useEffect, useRef, useState } from 'react'; +import { createStreamFlushScheduler } from '../../utils/streamFlushScheduler'; type AICreativity = 'none' | 'low' | 'medium' | 'high' | 'maximum' | number; @@ -21,6 +22,7 @@ export interface RaycastAIStreamBatcherOptions { setTimer?: (callback: () => void, delayMs: number) => ReturnType; clearTimer?: (handle: ReturnType) => void; flushDelayMs?: number; + isDocumentHidden?: () => boolean; } export interface RaycastAIStreamBatcher { @@ -30,32 +32,17 @@ export interface RaycastAIStreamBatcher { reset: (data?: string) => void; } -function createDefaultRequestFrame(): ((callback: () => void) => number) | undefined { - if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') { - return undefined; - } - return (callback) => window.requestAnimationFrame(() => callback()); -} - -function createDefaultCancelFrame(): ((handle: number) => void) | undefined { - if (typeof window === 'undefined' || typeof window.cancelAnimationFrame !== 'function') { - return undefined; - } - return (handle) => window.cancelAnimationFrame(handle); -} - export function createRaycastAIStreamBatcher({ dataRef, setVisibleData, - requestFrame = createDefaultRequestFrame(), - cancelFrame = createDefaultCancelFrame(), + requestFrame, + cancelFrame, setTimer = globalThis.setTimeout.bind(globalThis), clearTimer = globalThis.clearTimeout.bind(globalThis), flushDelayMs = RAYCAST_AI_STREAM_FLUSH_INTERVAL_MS, + isDocumentHidden, }: RaycastAIStreamBatcherOptions): RaycastAIStreamBatcher { let visibleData = dataRef.current; - let pendingFrame: number | null = null; - let pendingTimer: ReturnType | null = null; const publishVisibleData = () => { const nextData = dataRef.current; @@ -64,37 +51,22 @@ export function createRaycastAIStreamBatcher({ setVisibleData(nextData); }; - const cancelPendingFlush = () => { - if (pendingFrame !== null) { - cancelFrame?.(pendingFrame); - pendingFrame = null; - } - if (pendingTimer !== null) { - clearTimer(pendingTimer); - pendingTimer = null; - } - }; + const scheduler = createStreamFlushScheduler(publishVisibleData, { + requestFrame, + cancelFrame: cancelFrame as ((handle: unknown) => void) | undefined, + setTimer, + clearTimer, + flushDelayMs, + isDocumentHidden, + }); const flush = () => { - cancelPendingFlush(); - publishVisibleData(); - }; - - const runScheduledFlush = () => { - pendingFrame = null; - pendingTimer = null; + scheduler.cancel(); publishVisibleData(); }; const scheduleFlush = () => { - if (pendingFrame !== null || pendingTimer !== null) return; - - if (requestFrame) { - pendingFrame = requestFrame(runScheduledFlush); - return; - } - - pendingTimer = setTimer(runScheduledFlush, flushDelayMs); + scheduler.schedule(); }; return { @@ -103,10 +75,10 @@ export function createRaycastAIStreamBatcher({ dataRef.current += chunk; scheduleFlush(); }, - cancelPendingFlush, + cancelPendingFlush: scheduler.cancel, flush, reset(data = '') { - cancelPendingFlush(); + scheduler.cancel(); dataRef.current = data; visibleData = data; setVisibleData(data); diff --git a/src/renderer/src/utils/ai-chat-stream-buffer.ts b/src/renderer/src/utils/ai-chat-stream-buffer.ts index ec66da81..b1d96ffd 100644 --- a/src/renderer/src/utils/ai-chat-stream-buffer.ts +++ b/src/renderer/src/utils/ai-chat-stream-buffer.ts @@ -1,3 +1,5 @@ +import { createStreamFlushScheduler } from './streamFlushScheduler'; + export const AI_CHAT_STREAM_FLUSH_MS = 40; export interface AiChatStreamBufferOptions { @@ -24,28 +26,29 @@ export function createAiChatStreamBuffer({ }: AiChatStreamBufferOptions): AiChatStreamBuffer { let content = ''; let visibleContent = ''; - let flushHandle: unknown = null; - - const clearScheduledFlush = () => { - if (flushHandle === null) return; - cancelFlush(flushHandle); - flushHandle = null; - }; - const flushNow = () => { - clearScheduledFlush(); + const publishVisibleContent = () => { if (visibleContent === content) return false; visibleContent = content; onFlush(content); return true; }; + const scheduler = createStreamFlushScheduler(publishVisibleContent, { + flushDelayMs: flushIntervalMs, + requestFrame: undefined, + cancelFrame: undefined, + setTimer: (callback, delayMs) => scheduleFlush(callback, delayMs) as ReturnType, + clearTimer: (handle) => cancelFlush(handle), + }); + + const flushNow = () => { + scheduler.cancel(); + return publishVisibleContent(); + }; + const scheduleNextFlush = () => { - if (flushHandle !== null) return; - flushHandle = scheduleFlush(() => { - flushHandle = null; - flushNow(); - }, flushIntervalMs); + scheduler.schedule(); }; return { @@ -55,17 +58,17 @@ export function createAiChatStreamBuffer({ scheduleNextFlush(); }, cancel() { - clearScheduledFlush(); + scheduler.cancel(); }, flushNow, getContent() { return content; }, hasPendingFlush() { - return flushHandle !== null; + return scheduler.isPending(); }, reset(nextContent = '') { - clearScheduledFlush(); + scheduler.cancel(); content = nextContent; visibleContent = nextContent; }, diff --git a/src/renderer/src/utils/streamFlushScheduler.ts b/src/renderer/src/utils/streamFlushScheduler.ts new file mode 100644 index 00000000..71813465 --- /dev/null +++ b/src/renderer/src/utils/streamFlushScheduler.ts @@ -0,0 +1,93 @@ +export const DEFAULT_STREAM_FLUSH_INTERVAL_MS = 32; + +type TimerHandle = ReturnType; + +export interface StreamFlushSchedulerOptions { + requestFrame?: (callback: () => void) => unknown; + cancelFrame?: (handle: unknown) => void; + setTimer?: (callback: () => void, delayMs: number) => TimerHandle; + clearTimer?: (handle: TimerHandle) => void; + flushDelayMs?: number; + isDocumentHidden?: () => boolean; +} + +export interface StreamFlushScheduler { + cancel: () => void; + isPending: () => boolean; + schedule: () => void; +} + +function createDefaultRequestFrame(): ((callback: () => void) => unknown) | undefined { + if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') { + return undefined; + } + return (callback) => window.requestAnimationFrame(() => callback()); +} + +function createDefaultCancelFrame(): ((handle: unknown) => void) | undefined { + if (typeof window === 'undefined' || typeof window.cancelAnimationFrame !== 'function') { + return undefined; + } + return (handle) => window.cancelAnimationFrame(handle as number); +} + +function createDefaultDocumentHiddenGetter(): () => boolean { + return () => { + if (typeof document !== 'undefined') { + return document.hidden === true; + } + if (typeof window !== 'undefined' && window.document) { + return window.document.hidden === true; + } + return false; + }; +} + +export function createStreamFlushScheduler( + onFlush: () => void, + { + requestFrame = createDefaultRequestFrame(), + cancelFrame = createDefaultCancelFrame(), + setTimer = globalThis.setTimeout.bind(globalThis), + clearTimer = globalThis.clearTimeout.bind(globalThis), + flushDelayMs = DEFAULT_STREAM_FLUSH_INTERVAL_MS, + isDocumentHidden = createDefaultDocumentHiddenGetter(), + }: StreamFlushSchedulerOptions = {} +): StreamFlushScheduler { + let pendingFrame: unknown = null; + let pendingTimer: TimerHandle | null = null; + + const cancel = () => { + if (pendingFrame !== null) { + cancelFrame?.(pendingFrame); + pendingFrame = null; + } + if (pendingTimer !== null) { + clearTimer(pendingTimer); + pendingTimer = null; + } + }; + + const run = () => { + pendingFrame = null; + pendingTimer = null; + onFlush(); + }; + + return { + cancel, + isPending() { + return pendingFrame !== null || pendingTimer !== null; + }, + schedule() { + if (pendingFrame !== null || pendingTimer !== null) return; + + if (requestFrame && !isDocumentHidden()) { + pendingFrame = requestFrame(run); + return; + } + + pendingTimer = setTimer(run, flushDelayMs); + }, + }; +} From d6a9bf8980b8e21cc7df73b0935c7b4dc52d81b5 Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:20:32 +0200 Subject: [PATCH 6/8] test(ai): preserve chat stream timer batching --- scripts/test-ai-chat-stream-batching.mjs | 33 ++++++++++++++++++- .../src/utils/ai-chat-stream-buffer.ts | 1 + .../src/utils/streamFlushScheduler.ts | 4 ++- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/scripts/test-ai-chat-stream-batching.mjs b/scripts/test-ai-chat-stream-batching.mjs index f382f947..28f81712 100644 --- a/scripts/test-ai-chat-stream-batching.mjs +++ b/scripts/test-ai-chat-stream-batching.mjs @@ -11,7 +11,7 @@ const { buildSync } = require('esbuild'); const CHUNK_COUNT = 240; -function loadTsModule(filePath) { +function loadTsModule(filePath, context = {}) { const resolvedPath = path.resolve(filePath); const bundled = buildSync({ bundle: true, @@ -30,6 +30,7 @@ function loadTsModule(filePath) { console, setTimeout, clearTimeout, + ...context, }, { filename: resolvedPath }); return module.exports; } @@ -220,6 +221,36 @@ test('scheduled flush exposes partial streaming content', () => { assert.equal(harness.counters.visibleUpdates, 2); }); +test('chat stream batching preserves timer scheduling when requestAnimationFrame exists', () => { + let frameRequests = 0; + const browserLikeModule = loadTsModule('src/renderer/src/utils/ai-chat-stream-buffer.ts', { + window: { + document: { hidden: false }, + requestAnimationFrame(callback) { + frameRequests += 1; + return setTimeout(callback, 0); + }, + cancelAnimationFrame(handle) { + clearTimeout(handle); + }, + }, + document: { hidden: false }, + }); + const scheduler = createManualScheduler(); + const buffer = browserLikeModule.createAiChatStreamBuffer({ + flushIntervalMs: browserLikeModule.AI_CHAT_STREAM_FLUSH_MS, + onFlush() {}, + scheduleFlush: scheduler.schedule, + cancelFlush: scheduler.cancel, + }); + + buffer.append('visible browser chunk'); + + assert.equal(frameRequests, 0); + assert.equal(scheduler.size, 1); + buffer.cancel(); +}); + test('completion forces final flush before persistence', () => { const harness = createConversationHarness(); chunks.forEach((chunk) => harness.append(chunk)); diff --git a/src/renderer/src/utils/ai-chat-stream-buffer.ts b/src/renderer/src/utils/ai-chat-stream-buffer.ts index b1d96ffd..44bf2075 100644 --- a/src/renderer/src/utils/ai-chat-stream-buffer.ts +++ b/src/renderer/src/utils/ai-chat-stream-buffer.ts @@ -40,6 +40,7 @@ export function createAiChatStreamBuffer({ cancelFrame: undefined, setTimer: (callback, delayMs) => scheduleFlush(callback, delayMs) as ReturnType, clearTimer: (handle) => cancelFlush(handle), + useAnimationFrame: false, }); const flushNow = () => { diff --git a/src/renderer/src/utils/streamFlushScheduler.ts b/src/renderer/src/utils/streamFlushScheduler.ts index 71813465..4f535fac 100644 --- a/src/renderer/src/utils/streamFlushScheduler.ts +++ b/src/renderer/src/utils/streamFlushScheduler.ts @@ -9,6 +9,7 @@ export interface StreamFlushSchedulerOptions { clearTimer?: (handle: TimerHandle) => void; flushDelayMs?: number; isDocumentHidden?: () => boolean; + useAnimationFrame?: boolean; } export interface StreamFlushScheduler { @@ -52,6 +53,7 @@ export function createStreamFlushScheduler( clearTimer = globalThis.clearTimeout.bind(globalThis), flushDelayMs = DEFAULT_STREAM_FLUSH_INTERVAL_MS, isDocumentHidden = createDefaultDocumentHiddenGetter(), + useAnimationFrame = true, }: StreamFlushSchedulerOptions = {} ): StreamFlushScheduler { let pendingFrame: unknown = null; @@ -82,7 +84,7 @@ export function createStreamFlushScheduler( schedule() { if (pendingFrame !== null || pendingTimer !== null) return; - if (requestFrame && !isDocumentHidden()) { + if (useAnimationFrame && requestFrame && !isDocumentHidden()) { pendingFrame = requestFrame(run); return; } From cd949c5a8dd476c0b5334e86209334918c8acfce Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:46:55 +0200 Subject: [PATCH 7/8] ci: make fork checks cross-platform --- .github/workflows/claude-code-review.yml | 2 +- .github/workflows/test.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index b5e8cfd4..51f66a3d 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -12,6 +12,7 @@ on: jobs: claude-review: + if: github.event.pull_request.head.repo.full_name == github.repository # Optional: Filter by PR author # if: | # github.event.pull_request.user.login == 'external-contributor' || @@ -41,4 +42,3 @@ jobs: prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://code.claude.com/docs/en/cli-reference for available options - diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ecfdadf2..04e21a91 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,7 +34,7 @@ jobs: cache: npm - name: Install dependencies - run: npm ci + run: npm ci --force - name: Run node test suite run: npm test From 49f95c85acc75ad43e9baa1c625e73911350b46c Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:48:52 +0200 Subject: [PATCH 8/8] test: bundle main modules for Node --- scripts/lib/ts-import.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/lib/ts-import.mjs b/scripts/lib/ts-import.mjs index cd39884b..32433c79 100644 --- a/scripts/lib/ts-import.mjs +++ b/scripts/lib/ts-import.mjs @@ -9,8 +9,8 @@ export async function importTs(absPath) { bundle: true, entryPoints: [absPath], format: 'esm', - platform: 'browser', - target: 'es2020', + platform: 'node', + target: 'node20', write: false, }); const code = result.outputFiles[0].text;