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 01/55] 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 02/55] 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 03/55] 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 a1d8c7bb3072c3fb42aa8cd8921c6f47585abf50 Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:17:20 +0200 Subject: [PATCH 04/55] ci: add project regression checks --- .github/workflows/project-checks.yml | 56 ++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 .github/workflows/project-checks.yml diff --git a/.github/workflows/project-checks.yml b/.github/workflows/project-checks.yml new file mode 100644 index 00000000..28304e1d --- /dev/null +++ b/.github/workflows/project-checks.yml @@ -0,0 +1,56 @@ +name: Project Checks + +on: + pull_request: + branches: + - main + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: project-checks-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test-i18n-build: + name: Tests, i18n, and build + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + CI: true + ELECTRON_SKIP_BINARY_DOWNLOAD: '1' + SUPERCMD_SKIP_ELECTRON_TESTS: '1' + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: package-lock.json + + - name: Install dependencies without native postinstall scripts + # package.json pins darwin esbuild packages for release packaging; --force lets Ubuntu install the lockfile. + run: npm ci --ignore-scripts --force + + - name: Run tests + run: npm test + + - name: Check i18n + run: npm run check:i18n + + - name: Build main process + run: npm run build:main + + - name: Build renderer + run: npm run build:renderer From a1225c679dadad5af9066cd4aca616105be14d42 Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:18:05 +0200 Subject: [PATCH 05/55] Add bundled TS import helper for tests --- scripts/lib/ts-import.mjs | 305 ++++++++++++++++++++++-- scripts/test-browser-search-lag-fix.mjs | 28 ++- 2 files changed, 316 insertions(+), 17 deletions(-) diff --git a/scripts/lib/ts-import.mjs b/scripts/lib/ts-import.mjs index 49df6079..89523936 100644 --- a/scripts/lib/ts-import.mjs +++ b/scripts/lib/ts-import.mjs @@ -1,16 +1,293 @@ -// 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 { transform } from 'esbuild'; -import fs from 'node:fs'; - -export async function importTs(absPath) { - const src = fs.readFileSync(absPath, 'utf8'); - const { code } = await transform(src, { loader: 'ts', format: 'esm' }); - const dataUrl = 'data:text/javascript;base64,' + Buffer.from(code).toString('base64'); +// Import a TypeScript/TSX module from a test by bundling it with esbuild on the +// fly. Bundling lets tests execute selected production modules that have local +// imports instead of falling back to grep-only assertions. + +import { build } from 'esbuild'; +import path from 'node:path'; + +let importNonce = 0; + +const DEFAULT_STUB_MODULES = new Set([ + '@phosphor-icons/react', + '@raycast/api', + 'electron', + 'electron-liquid-glass', + 'lucide-react', + 'react', + 'react-dom', + 'react-dom/client', + 'react/jsx-dev-runtime', + 'react/jsx-runtime', +]); + +const ASSET_RE = /\.(?:css|less|sass|scss|svg|png|jpe?g|gif|webp|ico|icns|woff2?|ttf|otf|eot)$/i; + +export async function importTs(absPath, options = {}) { + const resolvedPath = path.resolve(absPath); + const { code } = await bundleTs(resolvedPath, options); + const dataUrl = [ + 'data:text/javascript;base64,', + Buffer.from(code).toString('base64'), + `#${encodeURIComponent(resolvedPath)}-${importNonce++}`, + ].join(''); return import(dataUrl); } + +export async function bundleTs(absPath, options = {}) { + const result = await build({ + absWorkingDir: options.root || process.cwd(), + entryPoints: [path.resolve(absPath)], + bundle: true, + write: false, + format: 'esm', + platform: 'node', + target: options.target || 'node20', + sourcemap: options.sourcemap || false, + logLevel: 'silent', + banner: options.browserGlobals === false ? undefined : { js: BROWSER_GLOBALS_BANNER }, + define: { + 'process.env.NODE_ENV': '"test"', + ...(options.define || {}), + }, + loader: { + '.js': 'js', + '.jsx': 'jsx', + '.ts': 'ts', + '.tsx': 'tsx', + '.json': 'json', + ...(options.loader || {}), + }, + external: options.external || [], + plugins: [ + tsImportStubPlugin(options), + ...(options.plugins || []), + ], + }); + + return { code: result.outputFiles[0].text }; +} + +function tsImportStubPlugin(options) { + const stubModules = new Set([...DEFAULT_STUB_MODULES, ...Object.keys(options.stubs || {})]); + for (const specifier of options.stubModules || []) stubModules.add(specifier); + + return { + name: 'ts-import-test-stubs', + setup(esbuild) { + esbuild.onResolve({ filter: /.*/ }, (args) => { + if (stubModules.has(args.path) || ASSET_RE.test(args.path)) { + return { path: args.path, namespace: 'ts-import-stub' }; + } + return null; + }); + + esbuild.onLoad({ filter: /.*/, namespace: 'ts-import-stub' }, (args) => ({ + contents: options.stubs?.[args.path] || defaultStubFor(args.path), + loader: 'js', + })); + }, + }; +} + +function defaultStubFor(specifier) { + if (specifier === 'electron') return ELECTRON_STUB; + if (specifier === 'react') return REACT_STUB; + if (specifier === 'react/jsx-runtime' || specifier === 'react/jsx-dev-runtime') return JSX_RUNTIME_STUB; + if (specifier === 'react-dom' || specifier === 'react-dom/client') return REACT_DOM_STUB; + if (ASSET_RE.test(specifier)) return ASSET_STUB; + return GENERIC_PROXY_STUB; +} + +const BROWSER_GLOBALS_BANNER = ` +const __scNoop = () => {}; +const __scMakeStorage = () => { + const map = new Map(); + return { + getItem: (key) => map.has(String(key)) ? map.get(String(key)) : null, + setItem: (key, value) => { map.set(String(key), String(value)); }, + removeItem: (key) => { map.delete(String(key)); }, + clear: () => { map.clear(); }, + }; +}; +var document = globalThis.document || { + addEventListener: __scNoop, + removeEventListener: __scNoop, + createElement: () => ({ style: {}, setAttribute: __scNoop, appendChild: __scNoop, remove: __scNoop }), + body: { appendChild: __scNoop, removeChild: __scNoop }, +}; +var navigator = globalThis.navigator || { platform: 'test', userAgent: 'node' }; +var localStorage = globalThis.localStorage || __scMakeStorage(); +var sessionStorage = globalThis.sessionStorage || __scMakeStorage(); +var window = globalThis.window || { + document, + navigator, + localStorage, + sessionStorage, + addEventListener: __scNoop, + removeEventListener: __scNoop, + dispatchEvent: () => true, + electron: {}, + location: { href: 'about:blank', reload: __scNoop }, +}; +var requestAnimationFrame = globalThis.requestAnimationFrame || ((callback) => setTimeout(() => callback(Date.now()), 0)); +var cancelAnimationFrame = globalThis.cancelAnimationFrame || ((id) => clearTimeout(id)); +`; + +const ELECTRON_STUB = ` +const noop = () => {}; +const asyncNoop = async () => undefined; +const home = process.env.HOME || process.cwd(); +const paths = { + appData: process.cwd(), + desktop: home + '/Desktop', + documents: home + '/Documents', + downloads: home + '/Downloads', + home, + temp: process.env.TMPDIR || process.cwd(), + userData: process.env.SUPERCMD_TEST_USER_DATA || process.cwd(), +}; +const app = { + isPackaged: false, + getAppPath: () => process.cwd(), + getName: () => 'SuperCmd Test', + getPath: (name) => paths[name] || process.cwd(), + getVersion: () => '0.0.0-test', + isReady: () => true, + whenReady: asyncNoop, + on: noop, + once: noop, + quit: noop, + relaunch: noop, + requestSingleInstanceLock: () => true, + setAsDefaultProtocolClient: () => true, +}; +class BrowserWindow { + constructor() { + this.webContents = { + executeJavaScript: asyncNoop, + on: noop, + once: noop, + send: noop, + setWindowOpenHandler: noop, + }; + } + loadFile() { return Promise.resolve(); } + loadURL() { return Promise.resolve(); } + on() {} + once() {} + show() {} + hide() {} + close() {} + destroy() {} + isDestroyed() { return false; } +} +const ipcMain = { handle: noop, on: noop, once: noop, removeAllListeners: noop, removeHandler: noop }; +const ipcRenderer = { invoke: asyncNoop, send: noop, on: noop, once: noop, removeAllListeners: noop, removeListener: noop }; +const shell = { openExternal: asyncNoop, openPath: async () => '', showItemInFolder: noop }; +const dialog = { + showErrorBox: noop, + showMessageBox: async () => ({ response: 0 }), + showOpenDialog: async () => ({ canceled: true, filePaths: [] }), + showSaveDialog: async () => ({ canceled: true, filePath: '' }), +}; +const clipboard = { + readText: () => '', + writeText: noop, + readImage: () => ({ isEmpty: () => true, toDataURL: () => '' }), + writeImage: noop, +}; +const nativeTheme = { shouldUseDarkColors: false, on: noop, removeListener: noop }; +const screen = { + getAllDisplays: () => [], + getPrimaryDisplay: () => ({ bounds: { x: 0, y: 0, width: 1024, height: 768 }, workArea: { x: 0, y: 0, width: 1024, height: 768 }, scaleFactor: 1 }), +}; +const safeStorage = { + decryptString: (value) => Buffer.from(value).toString('utf8'), + encryptString: (value) => Buffer.from(String(value)), + isEncryptionAvailable: () => false, +}; +module.exports = { app, BrowserWindow, clipboard, dialog, ipcMain, ipcRenderer, nativeTheme, safeStorage, screen, shell }; +`; + +const REACT_STUB = ` +const noop = () => {}; +const createElement = (type, props, ...children) => ({ type, props: { ...(props || {}), children } }); +class Component { + constructor(props) { + this.props = props || {}; + this.state = {}; + } + setState(update) { + this.state = { ...this.state, ...(typeof update === 'function' ? update(this.state, this.props) : update) }; + } +} +const createContext = (value) => ({ + Consumer: ({ children }) => typeof children === 'function' ? children(value) : children, + Provider: ({ children }) => children, + _currentValue: value, +}); +const React = { + Component, + PureComponent: Component, + Fragment: Symbol.for('react.fragment'), + createContext, + createElement, + createRef: () => ({ current: null }), + forwardRef: (render) => render, + lazy: (loader) => loader, + memo: (component) => component, + startTransition: (callback) => callback(), + useCallback: (callback) => callback, + useContext: (context) => context?._currentValue, + useEffect: noop, + useId: () => 'test-id', + useLayoutEffect: noop, + useMemo: (factory) => factory(), + useReducer: (reducer, initialArg, init) => [init ? init(initialArg) : initialArg, noop], + useRef: (value) => ({ current: value }), + useState: (initial) => [typeof initial === 'function' ? initial() : initial, noop], +}; +module.exports = React; +module.exports.default = React; +module.exports.__esModule = true; +`; + +const JSX_RUNTIME_STUB = ` +const Fragment = Symbol.for('react.fragment'); +const jsx = (type, props, key) => ({ type, key, props: props || {} }); +module.exports = { Fragment, jsx, jsxs: jsx }; +module.exports.default = module.exports; +module.exports.__esModule = true; +`; + +const REACT_DOM_STUB = ` +const root = { render() {}, unmount() {} }; +module.exports = { + createPortal: (children) => children, + createRoot: () => root, + hydrateRoot: () => root, + render() {}, + unmountComponentAtNode() {}, +}; +module.exports.default = module.exports; +module.exports.__esModule = true; +`; + +const ASSET_STUB = ` +module.exports = ''; +module.exports.default = ''; +`; + +const GENERIC_PROXY_STUB = ` +const makeStub = (name = 'stub') => new Proxy(function stub() {}, { + apply: () => undefined, + construct: () => ({}), + get: (_target, prop) => { + if (prop === '__esModule') return true; + if (prop === Symbol.toStringTag) return 'Module'; + return makeStub(String(prop)); + }, +}); +module.exports = makeStub(); +module.exports.default = module.exports; +`; diff --git a/scripts/test-browser-search-lag-fix.mjs b/scripts/test-browser-search-lag-fix.mjs index bff0d368..1f8eea4d 100644 --- a/scripts/test-browser-search-lag-fix.mjs +++ b/scripts/test-browser-search-lag-fix.mjs @@ -3,6 +3,11 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { importTs } from './lib/ts-import.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const files = { history: fs.readFileSync('src/main/browser-search-history.ts', 'utf8'), @@ -24,9 +29,26 @@ function assertNotIncludes(source, needle) { } test('Browser search lag fix', async (t) => { - await t.test('main history module exports required functions', () => { - assertIncludes(files.history, 'getBrowserSearchRevision'); - assertIncludes(files.history, 'getBrowserSearchStats'); + // Baseline: this used to grep browser-search-history.ts for export names, + // which could not prove the module or its relative imports actually execute. + await t.test('main history module imports exported browser-search API', async () => { + const history = await importTs(path.join(root, 'src/main/browser-search-history.ts')); + + assert.equal(typeof history.getBrowserSearchRevision, 'function'); + assert.equal(typeof history.getBrowserSearchStats, 'function'); + assert.equal(typeof history.resolveInput, 'function'); + + const url = history.resolveInput('example.com'); + assert.equal(url?.type, 'url'); + assert.equal(url?.url, 'https://example.com/'); + assert.equal(url?.host, 'example.com'); + + const search = history.resolveInput('browser search lag'); + assert.equal(search?.type, 'search'); + assert.equal(search?.url, 'https://www.google.com/search?q=browser%20search%20lag'); + }); + + await t.test('profile bookmark import keeps dedupe state', () => { assertIncludes(files.history, 'seenBookmarkKeys'); assertIncludes(files.history, 'existingBookmarkByKey'); }); From 0799e71b339617bac64aa40b15be49d2fa9a967d Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:30:08 +0200 Subject: [PATCH 06/55] fix(types): make renderer typecheck clean --- src/renderer/src/App.tsx | 2 +- src/renderer/src/CameraExtension.tsx | 2 +- src/renderer/src/QuickLinkManager.tsx | 2 +- src/renderer/src/SnippetManager.tsx | 2 +- src/renderer/src/hooks/useAiChat.ts | 2 +- .../src/hooks/useLauncherCommandModel.ts | 2 +- .../src/hooks/useLauncherKeyboardControls.ts | 1 + src/renderer/src/i18n/runtime.ts | 4 +++- .../raycast-api/action-runtime-overlay.tsx | 3 ++- .../src/raycast-api/context-scope-runtime.ts | 2 +- .../src/raycast-api/detail-runtime.tsx | 4 ++-- .../raycast-api/hooks/use-cached-promise.ts | 4 ++-- .../src/raycast-api/icon-runtime-phosphor.tsx | 2 +- src/renderer/src/raycast-api/index.tsx | 20 ++++++++-------- .../raycast-api/list-runtime-renderers.tsx | 4 ++-- src/renderer/src/raycast-api/list-runtime.tsx | 4 ++-- .../raycast-api/oauth/oauth-service-core.ts | 4 ++++ .../raycast-api/oauth/with-access-token.tsx | 7 +++--- src/renderer/src/settings/AITab.tsx | 7 ++++-- src/renderer/src/settings/StoreTab.tsx | 1 + .../src/types/liquid-glass-react.d.ts | 23 +++++++++++++++++++ src/renderer/src/utils/quicklink-icons.tsx | 11 ++++----- 22 files changed, 73 insertions(+), 40 deletions(-) create mode 100644 src/renderer/src/types/liquid-glass-react.d.ts diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index a49482ba..aeb00cd4 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1076,7 +1076,7 @@ const App: React.FC = () => { const pinToggleForCommand = useCallback( async (command: CommandInfo) => { - console.log('[PIN-TOGGLE] called for command:', command?.id, command?.name); + console.log('[PIN-TOGGLE] called for command:', command?.id, command?.title); const currentPinned = pinnedCommandsRef.current; const exists = currentPinned.includes(command.id); console.log('[PIN-TOGGLE] currentPinned:', currentPinned, 'exists:', exists); diff --git a/src/renderer/src/CameraExtension.tsx b/src/renderer/src/CameraExtension.tsx index 7de6a3f8..02d43a3b 100644 --- a/src/renderer/src/CameraExtension.tsx +++ b/src/renderer/src/CameraExtension.tsx @@ -298,7 +298,7 @@ const CameraExtension: React.FC = ({ onClose }) => { }, 5000); capturePreviewClearTimerRef.current = window.setTimeout(() => { setCapturePreviewDataUrl(null); - setCapturePreviewClearTimerRef.current = null; + capturePreviewClearTimerRef.current = null; }, 5300); setFlashVisible(true); if (flashTimerRef.current != null) { diff --git a/src/renderer/src/QuickLinkManager.tsx b/src/renderer/src/QuickLinkManager.tsx index 2f749757..deaea4c2 100644 --- a/src/renderer/src/QuickLinkManager.tsx +++ b/src/renderer/src/QuickLinkManager.tsx @@ -1286,7 +1286,7 @@ const QuickLinkManager: React.FC = ({ onClose, initialVie const inputRef = useRef(null); const inlineArgumentLaneRef = useRef(null); const inlineArgumentClusterRef = useRef(null); - const inlineDynamicInputRefs = useRef>([]); + const inlineDynamicInputRefs = useRef>([]); const firstDynamicInputRef = useRef(null); const listRef = useRef(null); const itemRefs = useRef<(HTMLDivElement | null)[]>([]); diff --git a/src/renderer/src/SnippetManager.tsx b/src/renderer/src/SnippetManager.tsx index 3e2e8c65..e5ef51dd 100644 --- a/src/renderer/src/SnippetManager.tsx +++ b/src/renderer/src/SnippetManager.tsx @@ -432,7 +432,7 @@ const SnippetManager: React.FC = ({ onClose, initialView }) const inputRef = useRef(null); const inlineArgumentLaneRef = useRef(null); const inlineArgumentClusterRef = useRef(null); - const inlineArgumentInputRefs = useRef>([]); + const inlineArgumentInputRefs = useRef>([]); const firstDynamicInputRef = useRef(null); const listRef = useRef(null); const itemRefs = useRef<(HTMLDivElement | null)[]>([]); diff --git a/src/renderer/src/hooks/useAiChat.ts b/src/renderer/src/hooks/useAiChat.ts index fea32c94..bce0b4a5 100644 --- a/src/renderer/src/hooks/useAiChat.ts +++ b/src/renderer/src/hooks/useAiChat.ts @@ -36,7 +36,7 @@ export interface UseAiChatReturn { setAiQuery: (value: string) => void; aiInputRef: React.RefObject; aiResponseRef: React.RefObject; - setAiAvailable: (value: boolean) => void; + setAiAvailable: React.Dispatch>; conversations: AiConversation[]; activeConversationId: string | null; startAiChat: (searchQuery: string) => void; diff --git a/src/renderer/src/hooks/useLauncherCommandModel.ts b/src/renderer/src/hooks/useLauncherCommandModel.ts index 44ca0fbc..ff6174e3 100644 --- a/src/renderer/src/hooks/useLauncherCommandModel.ts +++ b/src/renderer/src/hooks/useLauncherCommandModel.ts @@ -187,7 +187,7 @@ function getFileDepthPenalty(result: IndexedFileSearchResult): number { type BrowserLauncherProfile = { id?: string; - browserId?: BrowserSearchSource | string; + browserId?: BrowserSearchSource; displayName: string; detectedName?: string; profileId: string; diff --git a/src/renderer/src/hooks/useLauncherKeyboardControls.ts b/src/renderer/src/hooks/useLauncherKeyboardControls.ts index a385562f..e965d9f1 100644 --- a/src/renderer/src/hooks/useLauncherKeyboardControls.ts +++ b/src/renderer/src/hooks/useLauncherKeyboardControls.ts @@ -91,6 +91,7 @@ export type UseLauncherKeyboardControlsOptions = { kind?: CommandInfo['browserResultKind']; url?: string; sourceProfileId?: string; + openInSourceProfile?: boolean; windowId?: string | number; tabId?: string | number; } diff --git a/src/renderer/src/i18n/runtime.ts b/src/renderer/src/i18n/runtime.ts index edf181c9..e64e92dd 100644 --- a/src/renderer/src/i18n/runtime.ts +++ b/src/renderer/src/i18n/runtime.ts @@ -12,7 +12,9 @@ import itMessages from './locales/it.json'; export type SupportedAppLocale = 'en' | 'zh-Hans' | 'zh-Hant' | 'ja' | 'ko' | 'fr' | 'de' | 'es' | 'ru' | 'it'; export type AppLanguageSetting = 'system' | SupportedAppLocale; export type TranslationValues = Record; -type MessageTree = Record; +interface MessageTree { + [key: string]: string | MessageTree; +} export const DEFAULT_APP_LANGUAGE: AppLanguageSetting = 'system'; export const FALLBACK_APP_LOCALE: SupportedAppLocale = 'en'; diff --git a/src/renderer/src/raycast-api/action-runtime-overlay.tsx b/src/renderer/src/raycast-api/action-runtime-overlay.tsx index 02896d10..e623136c 100644 --- a/src/renderer/src/raycast-api/action-runtime-overlay.tsx +++ b/src/renderer/src/raycast-api/action-runtime-overlay.tsx @@ -112,7 +112,8 @@ export function createActionOverlayRuntime(deps: OverlayDeps) { } if (source && typeof source === 'object') { - const variants = [source.light, source.dark].filter((value): value is string => typeof value === 'string' && value.trim().length > 0); + const sourceVariants = source as Record; + const variants = [sourceVariants.light, sourceVariants.dark].filter((value): value is string => typeof value === 'string' && value.trim().length > 0); if (variants.length > 0) { const assetLikeVariants = variants.filter((value) => hasImageExtension(value)); if (assetLikeVariants.length === 0) return true; diff --git a/src/renderer/src/raycast-api/context-scope-runtime.ts b/src/renderer/src/raycast-api/context-scope-runtime.ts index 440b0bc1..410737cc 100644 --- a/src/renderer/src/raycast-api/context-scope-runtime.ts +++ b/src/renderer/src/raycast-api/context-scope-runtime.ts @@ -89,7 +89,7 @@ export function withExtensionContext(ctx: ExtensionContextSnapshot | undefine try { const value = fn(); if (value && typeof (value as any).then === 'function') { - return (value as Promise).finally(restore) as T; + return (value as unknown as Promise).finally(restore) as T; } restore(); return value; diff --git a/src/renderer/src/raycast-api/detail-runtime.tsx b/src/renderer/src/raycast-api/detail-runtime.tsx index 88d513ff..0566bb26 100644 --- a/src/renderer/src/raycast-api/detail-runtime.tsx +++ b/src/renderer/src/raycast-api/detail-runtime.tsx @@ -90,7 +90,7 @@ export function createDetailRuntime(deps: CreateDetailRuntimeDeps) { for (const node of allChildren) { if (React.isValidElement(node)) { - const typeRecord = node.type as Record | null; + const typeRecord = node.type as unknown as Record | null; if (typeRecord?.[DETAIL_METADATA_RUNTIME_MARKER] === true) { metadataNodes.push(node); continue; @@ -290,7 +290,7 @@ export function createDetailRuntime(deps: CreateDetailRuntimeDeps) { ); const MetadataComponent = ({ children }: { children?: React.ReactNode }) =>
{children}
; - (MetadataComponent as Record)[DETAIL_METADATA_RUNTIME_MARKER] = true; + (MetadataComponent as unknown as Record)[DETAIL_METADATA_RUNTIME_MARKER] = true; MetadataComponent.displayName = 'Detail.Metadata'; const Metadata = Object.assign( diff --git a/src/renderer/src/raycast-api/hooks/use-cached-promise.ts b/src/renderer/src/raycast-api/hooks/use-cached-promise.ts index 7d1f4c4e..0f125309 100644 --- a/src/renderer/src/raycast-api/hooks/use-cached-promise.ts +++ b/src/renderer/src/raycast-api/hooks/use-cached-promise.ts @@ -96,7 +96,7 @@ export function useCachedPromise( if (pageNum === 0) { setAccumulatedData(Array.isArray(pageData) ? pageData : []); } else { - setAccumulatedData((prev) => { + setAccumulatedData((prev: unknown) => { const prevArr = Array.isArray(prev) ? prev : []; const newArr = Array.isArray(pageData) ? pageData : []; return [...prevArr, ...newArr]; @@ -123,7 +123,7 @@ export function useCachedPromise( if (pageNum === 0) { setAccumulatedData(Array.isArray(pageData) ? pageData : []); } else { - setAccumulatedData((prev) => { + setAccumulatedData((prev: unknown) => { const prevArr = Array.isArray(prev) ? prev : []; const newArr = Array.isArray(pageData) ? pageData : []; return [...prevArr, ...newArr]; diff --git a/src/renderer/src/raycast-api/icon-runtime-phosphor.tsx b/src/renderer/src/raycast-api/icon-runtime-phosphor.tsx index 2716a240..c7dcf1ab 100644 --- a/src/renderer/src/raycast-api/icon-runtime-phosphor.tsx +++ b/src/renderer/src/raycast-api/icon-runtime-phosphor.tsx @@ -5,7 +5,7 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; -import * as Phosphor from '../../../../node_modules/@phosphor-icons/react/dist/index.es.js'; +import * as Phosphor from '@phosphor-icons/react'; import { RAYCAST_ICON_NAMES, RAYCAST_ICON_VALUE_TO_NAME, type RaycastIconName } from './raycast-icon-enum'; type PhosphorIconWeight = 'thin' | 'light' | 'regular' | 'bold' | 'fill' | 'duotone'; diff --git a/src/renderer/src/raycast-api/index.tsx b/src/renderer/src/raycast-api/index.tsx index 95940389..f3e2ddf6 100644 --- a/src/renderer/src/raycast-api/index.tsx +++ b/src/renderer/src/raycast-api/index.tsx @@ -434,8 +434,10 @@ export enum ToastStyle { Failure = 'failure', } +type ToastStyleInput = ToastStyle | 'animated' | 'success' | 'failure'; + export class Toast { - static Style = ToastStyle; + static Style: typeof ToastStyle = ToastStyle; private static _activeToast: Toast | null = null; private _title = ''; @@ -483,7 +485,7 @@ export class Toast { return this._style; } - public set style(value: ToastStyle | Toast.Style | string) { + public set style(value: ToastStyleInput | Toast.Style | string) { this._style = this.normalizeStyle(value); this.refresh(); } @@ -506,7 +508,7 @@ export class Toast { this.refresh(); } - private normalizeStyle(value: ToastStyle | Toast.Style | string | undefined): ToastStyle { + private normalizeStyle(value: ToastStyleInput | Toast.Style | string | undefined): ToastStyle { if (value === ToastStyle.Animated || value === Toast.Style.Animated || value === 'animated') { return ToastStyle.Animated; } @@ -894,16 +896,12 @@ export class Toast { // Toast namespace for types (merged with class) export namespace Toast { - export enum Style { - Animated = 'animated', - Success = 'success', - Failure = 'failure', - } + export type Style = ToastStyle; export interface Options { title: string; message?: string; - style?: ToastStyle | Toast.Style; + style?: ToastStyleInput | Toast.Style; primaryAction?: Alert.ActionOptions; secondaryAction?: Alert.ActionOptions; } @@ -924,9 +922,9 @@ function shouldSuppressBenignGitMissingPathToast(options: Toast.Options): boolea } export async function showToast(options: Toast.Options): Promise; -export async function showToast(style: ToastStyle | Toast.Style, title: string, message?: string): Promise; +export async function showToast(style: ToastStyleInput | Toast.Style, title: string, message?: string): Promise; export async function showToast( - optionsOrStyle: Toast.Options | ToastStyle | Toast.Style, + optionsOrStyle: Toast.Options | ToastStyleInput | Toast.Style, title?: string, message?: string ): Promise { diff --git a/src/renderer/src/raycast-api/list-runtime-renderers.tsx b/src/renderer/src/raycast-api/list-runtime-renderers.tsx index 44d20a55..cd14b3b3 100644 --- a/src/renderer/src/raycast-api/list-runtime-renderers.tsx +++ b/src/renderer/src/raycast-api/list-runtime-renderers.tsx @@ -19,7 +19,7 @@ interface ListRendererDeps { renderIcon: (icon: any, className?: string, assetsPath?: string) => React.ReactNode; resolveTintColor: (tintColor?: string) => string | undefined; resolveReadableTintColor: (tintColor?: string, options?: { minContrast?: number }) => string | undefined; - addHexAlpha: (hex: string, alphaHex?: string) => string | null; + addHexAlpha: (hex: string, alphaHex: string) => string | undefined; } export function createListRenderers(deps: ListRendererDeps) { @@ -102,7 +102,7 @@ export function createListRenderers(deps: ListRendererDeps) { {icon &&
{renderIcon(icon, iconClassName, assetsPath)}
}
{primaryText}
{secondaryText && {secondaryText}} - {accessories?.map((accessory, index) => { + {accessories?.map((accessory: NonNullable[number], index: number) => { const accessoryText = typeof accessory?.text === 'string' ? accessory.text : typeof accessory?.text === 'object' ? accessory.text?.value || '' : ''; const accessoryTextColorRaw = typeof accessory?.text === 'object' ? accessory.text?.color : undefined; const tagText = typeof accessory?.tag === 'string' ? accessory.tag : typeof accessory?.tag === 'object' ? accessory.tag?.value || '' : ''; diff --git a/src/renderer/src/raycast-api/list-runtime.tsx b/src/renderer/src/raycast-api/list-runtime.tsx index d7b665bd..c951e63c 100644 --- a/src/renderer/src/raycast-api/list-runtime.tsx +++ b/src/renderer/src/raycast-api/list-runtime.tsx @@ -34,7 +34,7 @@ interface ListRuntimeDeps { renderIcon: (icon: any, className?: string, assetsPath?: string) => React.ReactNode; resolveTintColor: (value?: string) => string | undefined; resolveReadableTintColor: (value?: string, options?: { minContrast?: number }) => string | undefined; - addHexAlpha: (hex: string, alphaHex?: string) => string | null; + addHexAlpha: (hex: string, alphaHex: string) => string | undefined; getExtensionContext: () => { assetsPath: string; extensionDisplayName?: string; @@ -390,7 +390,7 @@ export function createListRuntime(deps: ListRuntimeDeps) { const detailElement = useMemo(() => { if (!rawDetail || !React.isValidElement(rawDetail)) return rawDetail; if (rawDetail.type !== React.Fragment) return rawDetail; - const children = React.Children.toArray(rawDetail.props.children); + const children = React.Children.toArray((rawDetail.props as { children?: React.ReactNode }).children); let mergedMarkdown: string | undefined; let mergedMetadata: React.ReactElement | undefined; let mergedIsLoading: boolean | undefined; diff --git a/src/renderer/src/raycast-api/oauth/oauth-service-core.ts b/src/renderer/src/raycast-api/oauth/oauth-service-core.ts index 0a923781..7fb945ae 100644 --- a/src/renderer/src/raycast-api/oauth/oauth-service-core.ts +++ b/src/renderer/src/raycast-api/oauth/oauth-service-core.ts @@ -25,6 +25,10 @@ export class OAuthServiceCore { return (override && override.trim()) || this.options.clientId; } + getPersonalAccessToken(): string | undefined { + return this.options.personalAccessToken; + } + setClientIdOverride(value: string): void { const key = oauthClientIdOverrideKey(this.getProviderKey()); const trimmed = (value || '').trim(); diff --git a/src/renderer/src/raycast-api/oauth/with-access-token.tsx b/src/renderer/src/raycast-api/oauth/with-access-token.tsx index 83b356d8..e2440719 100644 --- a/src/renderer/src/raycast-api/oauth/with-access-token.tsx +++ b/src/renderer/src/raycast-api/oauth/with-access-token.tsx @@ -16,10 +16,11 @@ export function withAccessToken(options: any) { const shouldInvokeOnAuthorize = !(options instanceof OAuthService); const authorizeForNoView = async (): Promise => { if (options instanceof OAuthService) { - if (options?.personalAccessToken) { - accessTokenValue = options.personalAccessToken; + const personalAccessToken = options.getPersonalAccessToken(); + if (personalAccessToken) { + accessTokenValue = personalAccessToken; accessTokenType = 'personal'; - await Promise.resolve(options.onAuthorize?.({ token: accessTokenValue, type: 'personal' })); + await Promise.resolve(options.onAuthorize?.({ token: personalAccessToken, type: 'personal' })); return; } diff --git a/src/renderer/src/settings/AITab.tsx b/src/renderer/src/settings/AITab.tsx index c27befdd..6813afd9 100644 --- a/src/renderer/src/settings/AITab.tsx +++ b/src/renderer/src/settings/AITab.tsx @@ -1224,7 +1224,7 @@ const AITab: React.FC = () => {

{t('settings.ai.llm.ollama.models')}

{ollamaRunning && (