diff --git a/app/components/chat/BaseChat.tsx b/app/components/chat/BaseChat.tsx index 1d1803fd..f027abeb 100644 --- a/app/components/chat/BaseChat.tsx +++ b/app/components/chat/BaseChat.tsx @@ -25,7 +25,6 @@ import ChatAlert from './ChatAlert'; import type { ModelInfo } from '~/lib/modules/llm/types'; import ProgressCompilation from './ProgressCompilation'; import type { ProgressAnnotation } from '~/types/context'; -import type { ActionRunner } from '~/lib/runtime/action-runner'; import { SupabaseChatAlert } from '~/components/chat/SupabaseAlert'; import { expoUrlAtom } from '~/lib/stores/qrCodeStore'; import { useStore } from '@nanostores/react'; @@ -71,7 +70,6 @@ interface BaseChatProps { deployAlert?: DeployAlert; clearDeployAlert?: () => void; data?: JSONValue[] | undefined; - actionRunner?: ActionRunner; chatMode?: 'discuss' | 'build'; setChatMode?: (mode: 'discuss' | 'build') => void; append?: (message: Message) => void; @@ -116,7 +114,6 @@ export const BaseChat = React.forwardRef( supabaseAlert, clearSupabaseAlert, data, - actionRunner, chatMode, setChatMode, append, @@ -483,12 +480,7 @@ export const BaseChat = React.forwardRef( {() => ( - + )} diff --git a/app/components/workbench/DiffView.tsx b/app/components/workbench/DiffView.tsx index 7747c4a8..b8853284 100644 --- a/app/components/workbench/DiffView.tsx +++ b/app/components/workbench/DiffView.tsx @@ -7,7 +7,6 @@ import { diffLines, type Change } from 'diff'; import { getHighlighter } from 'shiki'; import '~/styles/diff-view.css'; import { diffFiles, extractRelativePath } from '~/utils/diff'; -import { ActionRunner } from '~/lib/runtime/action-runner'; import type { FileHistory } from '~/types/actions'; import { getLanguageFromExtension } from '~/utils/getLanguageFromExtension'; import { themeStore } from '~/lib/stores/theme'; @@ -664,7 +663,6 @@ const InlineDiffComparison = memo(({ beforeCode, afterCode, filename, language } interface DiffViewProps { fileHistory: Record; setFileHistory: React.Dispatch>>; - actionRunner: ActionRunner; } export const DiffView = memo(({ fileHistory, setFileHistory }: DiffViewProps) => { diff --git a/app/components/workbench/Workbench.client.tsx b/app/components/workbench/Workbench.client.tsx index 084611d6..e5d30695 100644 --- a/app/components/workbench/Workbench.client.tsx +++ b/app/components/workbench/Workbench.client.tsx @@ -5,7 +5,6 @@ import { memo, useCallback, useEffect, useState, useMemo } from 'react'; import { toast } from 'react-toastify'; import { Popover, Transition } from '@headlessui/react'; import { diffLines, type Change } from 'diff'; -import { ActionRunner } from '~/lib/runtime/action-runner'; import { getLanguageFromExtension } from '~/utils/getLanguageFromExtension'; import type { FileHistory } from '~/types/actions'; import { DiffView } from './DiffView'; @@ -32,7 +31,6 @@ import type { ElementInfo } from './Inspector'; interface WorkspaceProps { chatStarted?: boolean; isStreaming?: boolean; - actionRunner: ActionRunner; metadata?: { gitUrl?: string; }; @@ -281,7 +279,7 @@ const FileModifiedDropdown = memo( ); export const Workbench = memo( - ({ chatStarted, isStreaming, actionRunner, metadata, updateChatMestaData, setSelectedElement }: WorkspaceProps) => { + ({ chatStarted, isStreaming, metadata, updateChatMestaData, setSelectedElement }: WorkspaceProps) => { renderLogger.trace('Workbench'); const [isSyncing, setIsSyncing] = useState(false); @@ -486,7 +484,7 @@ export const Workbench = memo( initial={{ x: '100%' }} animate={{ x: selectedView === 'diff' ? '0%' : selectedView === 'code' ? '100%' : '-100%' }} > - + diff --git a/app/components/workbench/terminal/Terminal.tsx b/app/components/workbench/terminal/Terminal.tsx index 337a72a6..d791363b 100644 --- a/app/components/workbench/terminal/Terminal.tsx +++ b/app/components/workbench/terminal/Terminal.tsx @@ -10,6 +10,7 @@ const logger = createScopedLogger('Terminal'); export interface TerminalRef { reloadStyles: () => void; + getTerminal: () => XTerm | undefined; } export interface TerminalProps { @@ -80,6 +81,9 @@ export const Terminal = memo( const terminal = terminalRef.current!; terminal.options.theme = getTerminalTheme(readonly ? { cursor: '#00000000' } : {}); }, + getTerminal: () => { + return terminalRef.current; + }, }; }, []); diff --git a/app/components/workbench/terminal/TerminalTabs.tsx b/app/components/workbench/terminal/TerminalTabs.tsx index 79988745..d207922f 100644 --- a/app/components/workbench/terminal/TerminalTabs.tsx +++ b/app/components/workbench/terminal/TerminalTabs.tsx @@ -23,7 +23,7 @@ export const TerminalTabs = memo(() => { const terminalToggledByShortcut = useRef(false); const [activeTerminal, setActiveTerminal] = useState(0); - const [terminalCount, setTerminalCount] = useState(1); + const [terminalCount, setTerminalCount] = useState(0); const addTerminal = () => { if (terminalCount < MAX_TERMINALS) { @@ -32,6 +32,50 @@ export const TerminalTabs = memo(() => { } }; + const closeTerminal = (index: number) => { + if (index === 0) { + return; + } // Can't close bolt terminal + + const terminalRef = terminalRefs.current[index]; + + if (terminalRef?.getTerminal) { + const terminal = terminalRef.getTerminal(); + + if (terminal) { + workbenchStore.detachTerminal(terminal); + } + } + + // Remove the terminal from refs + terminalRefs.current.splice(index, 1); + + // Adjust terminal count and active terminal + setTerminalCount(terminalCount - 1); + + // If we closed the active terminal, switch to previous one + if (activeTerminal === index) { + setActiveTerminal(Math.max(0, index - 1)); + } else if (activeTerminal > index) { + setActiveTerminal(activeTerminal - 1); + } + }; + + useEffect(() => { + return () => { + // Cleanup all terminals on unmount + terminalRefs.current.forEach((ref, index) => { + if (index > 0 && ref?.getTerminal) { + const terminal = ref.getTerminal(); + + if (terminal) { + workbenchStore.detachTerminal(terminal); + } + } + }); + }; + }, []); + useEffect(() => { const { current: terminal } = terminalPanelRef; @@ -125,6 +169,15 @@ export const TerminalTabs = memo(() => { >
Terminal {terminalCount > 1 && index} + )} diff --git a/app/lib/stores/terminal.ts b/app/lib/stores/terminal.ts index 9de9f4e5..3075dd8d 100644 --- a/app/lib/stores/terminal.ts +++ b/app/lib/stores/terminal.ts @@ -45,6 +45,21 @@ export class TerminalStore { } } + async detachTerminal(terminal: ITerminal) { + const terminalIndex = this.#terminals.findIndex((t) => t.terminal === terminal); + + if (terminalIndex !== -1) { + const { process } = this.#terminals[terminalIndex]; + + try { + process.kill(); + } catch (error) { + console.warn('Failed to kill terminal process:', error); + } + this.#terminals.splice(terminalIndex, 1); + } + } + onTerminalResize(cols: number, rows: number) { for (const { process } of this.#terminals) { process.resize({ cols, rows }); diff --git a/app/lib/stores/workbench.ts b/app/lib/stores/workbench.ts index e90114cd..e7b69db1 100644 --- a/app/lib/stores/workbench.ts +++ b/app/lib/stores/workbench.ts @@ -147,6 +147,10 @@ export class WorkbenchStore { this.#terminalStore.attachBoltTerminal(terminal); } + detachTerminal(terminal: ITerminal) { + this.#terminalStore.detachTerminal(terminal); + } + onTerminalResize(cols: number, rows: number) { this.#terminalStore.onTerminalResize(cols, rows); } diff --git a/app/routes/api.chat.ts b/app/routes/api.chat.ts index d375a1ad..5c779cc8 100644 --- a/app/routes/api.chat.ts +++ b/app/routes/api.chat.ts @@ -62,6 +62,28 @@ async function chatAction({ context, request }: ActionFunctionArgs) { ); const stream = new SwitchableStream(); + const abortController = new AbortController(); + const activeOperations = new Set>(); + + // Cleanup function + const cleanup = () => { + try { + // Abort any ongoing operations + abortController.abort(); + + // Close streams + if (stream && typeof stream.close === 'function') { + stream.close(); + } + + // Cancel active operations + activeOperations.clear(); + + logger.debug('Chat action cleanup completed'); + } catch (error) { + logger.error('Error during cleanup:', error); + } + }; const cumulativeUsage = { completionTokens: 0, @@ -298,6 +320,30 @@ async function chatAction({ context, request }: ActionFunctionArgs) { messageSliceId, }); + // Track async operations for cleanup + const summaryOperation = summary + ? createSummary({ + messages: [...messages], + env: context.cloudflare?.env, + apiKeys, + providerSettings, + promptId, + contextOptimization, + onFinish(resp) { + if (resp.usage) { + logger.debug('createSummary token usage', JSON.stringify(resp.usage)); + cumulativeUsage.completionTokens += resp.usage.completionTokens || 0; + cumulativeUsage.promptTokens += resp.usage.promptTokens || 0; + cumulativeUsage.totalTokens += resp.usage.totalTokens || 0; + } + }, + }) + : Promise.resolve(); + + if (summaryOperation) { + activeOperations.add(summaryOperation); + } + (async () => { for await (const part of result.fullStream) { if (part.type === 'error') { @@ -310,7 +356,10 @@ async function chatAction({ context, request }: ActionFunctionArgs) { })(); result.mergeIntoDataStream(dataStream); }, - onError: (error: any) => `Custom error: ${error.message}`, + onError: (error: any) => { + cleanup(); + return `Custom error: ${error.message}`; + }, }).pipeThrough( new TransformStream({ transform: (chunk, controller) => { @@ -359,6 +408,9 @@ async function chatAction({ context, request }: ActionFunctionArgs) { }, }); } catch (error: any) { + // Ensure cleanup on error + cleanup(); + logger.error(error); if (error.message?.includes('API key')) { diff --git a/app/utils/file-watcher.ts b/app/utils/file-watcher.ts deleted file mode 100644 index 6917efb3..00000000 --- a/app/utils/file-watcher.ts +++ /dev/null @@ -1,229 +0,0 @@ -import type { WebContainer } from '@webcontainer/api'; -import { WORK_DIR } from './constants'; - -// Global object to track watcher state -const watcherState = { - fallbackEnabled: tryLoadFallbackState(), - watchingPaths: new Set(), - callbacks: new Map void>>(), - pollingInterval: null as NodeJS.Timeout | null, -}; - -// Try to load the fallback state from localStorage -function tryLoadFallbackState(): boolean { - try { - if (typeof localStorage !== 'undefined') { - const state = localStorage.getItem('bolt-file-watcher-fallback'); - return state === 'true'; - } - } catch { - console.warn('[FileWatcher] Failed to load fallback state from localStorage'); - } - return false; -} - -// Save the fallback state to localStorage -function saveFallbackState(state: boolean) { - try { - if (typeof localStorage !== 'undefined') { - localStorage.setItem('bolt-file-watcher-fallback', state ? 'true' : 'false'); - } - } catch { - console.warn('[FileWatcher] Failed to save fallback state to localStorage'); - } -} - -/** - * Safe file watcher that falls back to polling when native file watching fails - * - * @param webcontainer The WebContainer instance - * @param pattern File pattern to watch - * @param callback Function to call when files change - * @returns An object with a close method - */ -export async function safeWatch(webcontainer: WebContainer, pattern: string = '**/*', callback: () => void) { - // Register the callback - if (!watcherState.callbacks.has(pattern)) { - watcherState.callbacks.set(pattern, new Set()); - } - - watcherState.callbacks.get(pattern)!.add(callback); - - // If we're already using fallback mode, don't try native watchers again - if (watcherState.fallbackEnabled) { - // Make sure polling is active - ensurePollingActive(); - - // Return a cleanup function - return { - close: () => { - const callbacks = watcherState.callbacks.get(pattern); - - if (callbacks) { - callbacks.delete(callback); - - if (callbacks.size === 0) { - watcherState.callbacks.delete(pattern); - } - } - }, - }; - } - - // Try to use native file watching - try { - const watcher = await webcontainer.fs.watch(pattern, { persistent: true }); - watcherState.watchingPaths.add(pattern); - - // Use the native watch events - (watcher as any).addEventListener('change', () => { - // Call all callbacks for this pattern - const callbacks = watcherState.callbacks.get(pattern); - - if (callbacks) { - callbacks.forEach((cb) => cb()); - } - }); - - // Return an object with a close method - return { - close: () => { - try { - watcher.close(); - watcherState.watchingPaths.delete(pattern); - - const callbacks = watcherState.callbacks.get(pattern); - - if (callbacks) { - callbacks.delete(callback); - - if (callbacks.size === 0) { - watcherState.callbacks.delete(pattern); - } - } - } catch (error) { - console.warn('[FileWatcher] Error closing watcher:', error); - } - }, - }; - } catch (error) { - console.warn('[FileWatcher] Native file watching failed:', error); - console.info('[FileWatcher] Falling back to polling mechanism for file changes'); - - // Switch to fallback mode for all future watches - watcherState.fallbackEnabled = true; - saveFallbackState(true); - - // Start polling - ensurePollingActive(); - - // Return a mock watcher object - return { - close: () => { - const callbacks = watcherState.callbacks.get(pattern); - - if (callbacks) { - callbacks.delete(callback); - - if (callbacks.size === 0) { - watcherState.callbacks.delete(pattern); - } - } - - // If no more callbacks, stop polling - if (watcherState.callbacks.size === 0 && watcherState.pollingInterval) { - clearInterval(watcherState.pollingInterval); - watcherState.pollingInterval = null; - } - }, - }; - } -} - -// Ensure polling is active -function ensurePollingActive() { - if (watcherState.pollingInterval) { - return; - } - - // Set up a polling interval that calls all callbacks - watcherState.pollingInterval = setInterval(() => { - // Call all registered callbacks - for (const [, callbacks] of watcherState.callbacks.entries()) { - callbacks.forEach((callback) => callback()); - } - }, 3000); // Poll every 3 seconds - - // Clean up interval when window unloads - if (typeof window !== 'undefined') { - window.addEventListener('beforeunload', () => { - if (watcherState.pollingInterval) { - clearInterval(watcherState.pollingInterval); - watcherState.pollingInterval = null; - } - }); - } -} - -// SafeWatchPaths mimics the webcontainer.internal.watchPaths method but with fallback -export function safeWatchPaths( - webcontainer: WebContainer, - config: { include: string[]; exclude?: string[]; includeContent?: boolean }, - callback: any, -) { - // Create a valid mock event to prevent undefined errors - const createMockEvent = () => ({ - type: 'change', - path: `${WORK_DIR}/mock-path.txt`, - buffer: new Uint8Array(0), - }); - - // Start with polling if we already know native watching doesn't work - if (watcherState.fallbackEnabled) { - console.info('[FileWatcher] Using fallback polling for watchPaths'); - ensurePollingActive(); - - const interval = setInterval(() => { - // Use our helper to create a valid event - const mockEvent = createMockEvent(); - - // Wrap in the expected structure of nested arrays - callback([[mockEvent]]); - }, 3000); - - return { - close: () => { - clearInterval(interval); - }, - }; - } - - // Try native watching - try { - return webcontainer.internal.watchPaths(config, callback); - } catch (error) { - console.warn('[FileWatcher] Native watchPaths failed:', error); - console.info('[FileWatcher] Using fallback polling for watchPaths'); - - // Mark as using fallback - watcherState.fallbackEnabled = true; - saveFallbackState(true); - - // Set up polling - ensurePollingActive(); - - const interval = setInterval(() => { - // Use our helper to create a valid event - const mockEvent = createMockEvent(); - - // Wrap in the expected structure of nested arrays - callback([[mockEvent]]); - }, 3000); - - return { - close: () => { - clearInterval(interval); - }, - }; - } -} diff --git a/app/utils/selectStarterTemplate.ts b/app/utils/selectStarterTemplate.ts index 2e725e4a..e070aed0 100644 --- a/app/utils/selectStarterTemplate.ts +++ b/app/utils/selectStarterTemplate.ts @@ -4,7 +4,9 @@ import type { Template } from '~/types/template'; import { STARTER_TEMPLATES } from './constants'; const starterTemplateSelectionPrompt = (templates: Template[]) => ` -You are an experienced developer who helps people choose the best starter template for their projects, Vite is preferred. +You are an experienced developer who helps people choose the best starter template for their projects. +IMPORTANT: Vite is preferred +IMPORTANT: Only choose shadcn templates if the user explicitly asks for shadcn. Available templates: