Revert "fix: resolve chat conversation hanging and stream interruption issues (#1971)"

This reverts commit e68593f22d.
This commit is contained in:
Stijnus
2025-09-07 00:14:13 +02:00
committed by Stijnus
parent e68593f22d
commit 37217a5c7b
61 changed files with 1432 additions and 8811 deletions

View File

@@ -1,299 +0,0 @@
import { memo, useState, useEffect } from 'react';
import * as Dialog from '@radix-ui/react-dialog';
import * as Switch from '@radix-ui/react-switch';
import * as Slider from '@radix-ui/react-slider';
import { classNames } from '~/utils/classNames';
import { motion, AnimatePresence } from 'framer-motion';
interface AutoSaveSettingsProps {
onSettingsChange?: (settings: AutoSaveConfig) => void;
trigger?: React.ReactNode;
}
export interface AutoSaveConfig {
enabled: boolean;
interval: number; // in seconds
minChanges: number;
saveOnBlur: boolean;
saveBeforeRun: boolean;
showNotifications: boolean;
}
const DEFAULT_CONFIG: AutoSaveConfig = {
enabled: false,
interval: 30,
minChanges: 1,
saveOnBlur: true,
saveBeforeRun: true,
showNotifications: true,
};
const PRESET_INTERVALS = [
{ label: '10s', value: 10 },
{ label: '30s', value: 30 },
{ label: '1m', value: 60 },
{ label: '2m', value: 120 },
{ label: '5m', value: 300 },
];
export const AutoSaveSettings = memo(({ onSettingsChange, trigger }: AutoSaveSettingsProps) => {
const [isOpen, setIsOpen] = useState(false);
const [config, setConfig] = useState<AutoSaveConfig>(() => {
// Load from localStorage if available
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('bolt-autosave-config');
if (saved) {
try {
return JSON.parse(saved);
} catch {
// Invalid JSON, use defaults
}
}
}
return DEFAULT_CONFIG;
});
// Save to localStorage whenever config changes
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem('bolt-autosave-config', JSON.stringify(config));
}
onSettingsChange?.(config);
}, [config, onSettingsChange]);
const updateConfig = <K extends keyof AutoSaveConfig>(key: K, value: AutoSaveConfig[K]) => {
setConfig((prev) => ({ ...prev, [key]: value }));
};
return (
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
<Dialog.Trigger asChild>
{trigger || (
<button className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-bolt-elements-background-depth-2 hover:bg-bolt-elements-background-depth-3 text-bolt-elements-textPrimary transition-colors">
<div className="i-ph:gear-duotone" />
<span className="text-sm">Auto-save Settings</span>
</button>
)}
</Dialog.Trigger>
<AnimatePresence>
{isOpen && (
<Dialog.Portal>
<Dialog.Overlay asChild>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50"
/>
</Dialog.Overlay>
<Dialog.Content asChild>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-50 w-full max-w-md"
>
<div className="bg-bolt-elements-background-depth-1 rounded-xl shadow-2xl border border-bolt-elements-borderColor">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-bolt-elements-borderColor">
<Dialog.Title className="text-lg font-semibold text-bolt-elements-textPrimary">
Auto-save Settings
</Dialog.Title>
<Dialog.Close className="p-1 rounded-lg hover:bg-bolt-elements-background-depth-2 transition-colors">
<div className="i-ph:x text-xl text-bolt-elements-textTertiary" />
</Dialog.Close>
</div>
{/* Content */}
<div className="p-6 space-y-6">
{/* Enable/Disable Auto-save */}
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-bolt-elements-textPrimary">Enable Auto-save</label>
<p className="text-xs text-bolt-elements-textTertiary mt-1">
Automatically save files at regular intervals
</p>
</div>
<Switch.Root
checked={config.enabled}
onCheckedChange={(checked) => updateConfig('enabled', checked)}
className={classNames(
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
config.enabled ? 'bg-accent-500' : 'bg-bolt-elements-background-depth-3',
)}
>
<Switch.Thumb className="block h-4 w-4 translate-x-1 rounded-full bg-white transition-transform data-[state=checked]:translate-x-6" />
</Switch.Root>
</div>
{/* Save Interval */}
<div
className={classNames(
'space-y-3 transition-opacity',
!config.enabled ? 'opacity-50 pointer-events-none' : '',
)}
>
<div>
<label className="text-sm font-medium text-bolt-elements-textPrimary">
Save Interval: {config.interval}s
</label>
<p className="text-xs text-bolt-elements-textTertiary mt-1">How often to save changes</p>
</div>
<Slider.Root
value={[config.interval]}
onValueChange={([value]) => updateConfig('interval', value)}
min={5}
max={300}
step={5}
className="relative flex items-center select-none touch-none w-full h-5"
>
<Slider.Track className="bg-bolt-elements-background-depth-3 relative grow rounded-full h-1">
<Slider.Range className="absolute bg-accent-500 rounded-full h-full" />
</Slider.Track>
<Slider.Thumb className="block w-4 h-4 bg-white rounded-full shadow-lg hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-accent-500" />
</Slider.Root>
{/* Preset buttons */}
<div className="flex gap-2">
{PRESET_INTERVALS.map((preset) => (
<button
key={preset.value}
onClick={() => updateConfig('interval', preset.value)}
className={classNames(
'px-2 py-1 text-xs rounded-md transition-colors',
config.interval === preset.value
? 'bg-accent-500 text-white'
: 'bg-bolt-elements-background-depth-2 text-bolt-elements-textTertiary hover:bg-bolt-elements-background-depth-3',
)}
>
{preset.label}
</button>
))}
</div>
</div>
{/* Minimum Changes */}
<div
className={classNames(
'space-y-3 transition-opacity',
!config.enabled ? 'opacity-50 pointer-events-none' : '',
)}
>
<div>
<label className="text-sm font-medium text-bolt-elements-textPrimary">
Minimum Changes: {config.minChanges}
</label>
<p className="text-xs text-bolt-elements-textTertiary mt-1">
Minimum number of files to trigger auto-save
</p>
</div>
<Slider.Root
value={[config.minChanges]}
onValueChange={([value]) => updateConfig('minChanges', value)}
min={1}
max={10}
step={1}
className="relative flex items-center select-none touch-none w-full h-5"
>
<Slider.Track className="bg-bolt-elements-background-depth-3 relative grow rounded-full h-1">
<Slider.Range className="absolute bg-accent-500 rounded-full h-full" />
</Slider.Track>
<Slider.Thumb className="block w-4 h-4 bg-white rounded-full shadow-lg hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-accent-500" />
</Slider.Root>
</div>
{/* Additional Options */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-bolt-elements-textPrimary">
Save on Tab Switch
</label>
<p className="text-xs text-bolt-elements-textTertiary mt-1">
Save when switching to another tab
</p>
</div>
<Switch.Root
checked={config.saveOnBlur}
onCheckedChange={(checked) => updateConfig('saveOnBlur', checked)}
className={classNames(
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
config.saveOnBlur ? 'bg-accent-500' : 'bg-bolt-elements-background-depth-3',
)}
>
<Switch.Thumb className="block h-4 w-4 translate-x-1 rounded-full bg-white transition-transform data-[state=checked]:translate-x-6" />
</Switch.Root>
</div>
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-bolt-elements-textPrimary">Save Before Run</label>
<p className="text-xs text-bolt-elements-textTertiary mt-1">
Save all files before running commands
</p>
</div>
<Switch.Root
checked={config.saveBeforeRun}
onCheckedChange={(checked) => updateConfig('saveBeforeRun', checked)}
className={classNames(
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
config.saveBeforeRun ? 'bg-accent-500' : 'bg-bolt-elements-background-depth-3',
)}
>
<Switch.Thumb className="block h-4 w-4 translate-x-1 rounded-full bg-white transition-transform data-[state=checked]:translate-x-6" />
</Switch.Root>
</div>
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-bolt-elements-textPrimary">
Show Notifications
</label>
<p className="text-xs text-bolt-elements-textTertiary mt-1">
Display toast notifications on save
</p>
</div>
<Switch.Root
checked={config.showNotifications}
onCheckedChange={(checked) => updateConfig('showNotifications', checked)}
className={classNames(
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
config.showNotifications ? 'bg-accent-500' : 'bg-bolt-elements-background-depth-3',
)}
>
<Switch.Thumb className="block h-4 w-4 translate-x-1 rounded-full bg-white transition-transform data-[state=checked]:translate-x-6" />
</Switch.Root>
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between p-6 border-t border-bolt-elements-borderColor">
<button
onClick={() => setConfig(DEFAULT_CONFIG)}
className="px-4 py-2 text-sm text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary transition-colors"
>
Reset to Defaults
</button>
<Dialog.Close className="px-4 py-2 text-sm bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors">
Done
</Dialog.Close>
</div>
</div>
</motion.div>
</Dialog.Content>
</Dialog.Portal>
)}
</AnimatePresence>
</Dialog.Root>
);
});
AutoSaveSettings.displayName = 'AutoSaveSettings';

View File

@@ -1,145 +0,0 @@
import { useStore } from '@nanostores/react';
import { memo, useMemo } from 'react';
import { workbenchStore } from '~/lib/stores/workbench';
import { classNames } from '~/utils/classNames';
import { motion } from 'framer-motion';
interface FileStatusIndicatorProps {
className?: string;
showDetails?: boolean;
}
export const FileStatusIndicator = memo(({ className = '', showDetails = true }: FileStatusIndicatorProps) => {
const unsavedFiles = useStore(workbenchStore.unsavedFiles);
const files = useStore(workbenchStore.files);
const stats = useMemo(() => {
let totalFiles = 0;
let totalFolders = 0;
let totalSize = 0;
Object.entries(files).forEach(([_path, dirent]) => {
if (dirent?.type === 'file') {
totalFiles++;
totalSize += dirent.content?.length || 0;
} else if (dirent?.type === 'folder') {
totalFolders++;
}
});
return {
totalFiles,
totalFolders,
unsavedCount: unsavedFiles.size,
totalSize: formatFileSize(totalSize),
modifiedPercentage: totalFiles > 0 ? Math.round((unsavedFiles.size / totalFiles) * 100) : 0,
};
}, [files, unsavedFiles]);
function formatFileSize(bytes: number): string {
if (bytes === 0) {
return '0 B';
}
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
const getStatusColor = () => {
if (stats.unsavedCount === 0) {
return 'text-green-500';
}
if (stats.modifiedPercentage > 50) {
return 'text-red-500';
}
if (stats.modifiedPercentage > 20) {
return 'text-yellow-500';
}
return 'text-orange-500';
};
return (
<div
className={classNames(
'flex items-center gap-4 px-3 py-1.5 rounded-lg',
'bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor',
'text-xs text-bolt-elements-textTertiary',
className,
)}
>
{/* Status dot with pulse animation */}
<div className="flex items-center gap-2">
<motion.div
animate={{
scale: stats.unsavedCount > 0 ? [1, 1.2, 1] : 1,
}}
transition={{
duration: 2,
repeat: stats.unsavedCount > 0 ? Infinity : 0,
repeatType: 'loop',
}}
className={classNames(
'w-2 h-2 rounded-full',
getStatusColor(),
stats.unsavedCount > 0 ? 'bg-current' : 'bg-green-500',
)}
/>
<span className={getStatusColor()}>
{stats.unsavedCount === 0 ? 'All saved' : `${stats.unsavedCount} unsaved`}
</span>
</div>
{showDetails && (
<>
{/* File count */}
<div className="flex items-center gap-1.5">
<div className="i-ph:file-duotone" />
<span>{stats.totalFiles} files</span>
</div>
{/* Folder count */}
<div className="flex items-center gap-1.5">
<div className="i-ph:folder-duotone" />
<span>{stats.totalFolders} folders</span>
</div>
{/* Total size */}
<div className="flex items-center gap-1.5">
<div className="i-ph:database-duotone" />
<span>{stats.totalSize}</span>
</div>
{/* Progress bar for unsaved files */}
{stats.unsavedCount > 0 && (
<div className="flex items-center gap-2 ml-auto">
<span className="text-xs">{stats.modifiedPercentage}% modified</span>
<div className="w-20 h-1.5 bg-bolt-elements-background-depth-2 rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${stats.modifiedPercentage}%` }}
transition={{ duration: 0.3 }}
className={classNames(
'h-full rounded-full',
stats.modifiedPercentage > 50
? 'bg-red-500'
: stats.modifiedPercentage > 20
? 'bg-yellow-500'
: 'bg-orange-500',
)}
/>
</div>
</div>
)}
</>
)}
</div>
);
});
FileStatusIndicator.displayName = 'FileStatusIndicator';

View File

@@ -1,43 +0,0 @@
import { useEffect } from 'react';
import { toast } from 'react-toastify';
import { workbenchStore } from '~/lib/stores/workbench';
export function useKeyboardSaveAll() {
useEffect(() => {
const handleKeyPress = async (e: KeyboardEvent) => {
// Ctrl+Shift+S or Cmd+Shift+S to save all
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 's') {
e.preventDefault();
const unsavedFiles = workbenchStore.unsavedFiles.get();
if (unsavedFiles.size === 0) {
toast.info('All files are already saved', {
position: 'bottom-right',
autoClose: 2000,
});
return;
}
try {
const count = unsavedFiles.size;
await workbenchStore.saveAllFiles();
toast.success(`Saved ${count} file${count > 1 ? 's' : ''}`, {
position: 'bottom-right',
autoClose: 2000,
});
} catch {
toast.error('Failed to save some files', {
position: 'bottom-right',
autoClose: 3000,
});
}
}
};
window.addEventListener('keydown', handleKeyPress);
return () => window.removeEventListener('keydown', handleKeyPress);
}, []);
}

View File

@@ -1,305 +0,0 @@
import { useStore } from '@nanostores/react';
import { memo, useCallback, useEffect, useState, useRef } from 'react';
import { toast } from 'react-toastify';
import * as Tooltip from '@radix-ui/react-tooltip';
import { workbenchStore } from '~/lib/stores/workbench';
import { classNames } from '~/utils/classNames';
interface SaveAllButtonProps {
className?: string;
variant?: 'icon' | 'button';
showCount?: boolean;
autoSave?: boolean;
autoSaveInterval?: number;
}
export const SaveAllButton = memo(
({
className = '',
variant = 'icon',
showCount = true,
autoSave = false,
autoSaveInterval = 30000,
}: SaveAllButtonProps) => {
const unsavedFiles = useStore(workbenchStore.unsavedFiles);
const [isSaving, setIsSaving] = useState(false);
const [lastSaved, setLastSaved] = useState<Date | null>(null);
const [timeUntilAutoSave, setTimeUntilAutoSave] = useState<number | null>(null);
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
const countdownTimerRef = useRef<NodeJS.Timeout | null>(null);
const unsavedCount = unsavedFiles.size;
const hasUnsavedFiles = unsavedCount > 0;
// Log unsaved files state changes
useEffect(() => {
console.log('[SaveAllButton] Unsaved files changed:', {
count: unsavedCount,
files: Array.from(unsavedFiles),
hasUnsavedFiles,
});
}, [unsavedFiles, unsavedCount, hasUnsavedFiles]);
// Auto-save logic
useEffect(() => {
if (!autoSave || !hasUnsavedFiles) {
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
autoSaveTimerRef.current = null;
}
if (countdownTimerRef.current) {
clearInterval(countdownTimerRef.current);
countdownTimerRef.current = null;
}
setTimeUntilAutoSave(null);
return;
}
// Set up auto-save timer
console.log('[SaveAllButton] Setting up auto-save timer for', autoSaveInterval, 'ms');
autoSaveTimerRef.current = setTimeout(async () => {
if (hasUnsavedFiles && !isSaving) {
console.log('[SaveAllButton] Auto-save triggered');
await handleSaveAll(true);
}
}, autoSaveInterval);
// Set up countdown timer
const startTime = Date.now();
setTimeUntilAutoSave(Math.ceil(autoSaveInterval / 1000));
countdownTimerRef.current = setInterval(() => {
const elapsed = Date.now() - startTime;
const remaining = Math.max(0, autoSaveInterval - elapsed);
setTimeUntilAutoSave(Math.ceil(remaining / 1000));
if (remaining <= 0 && countdownTimerRef.current) {
clearInterval(countdownTimerRef.current);
countdownTimerRef.current = null;
}
}, 1000);
}, [autoSave, hasUnsavedFiles, autoSaveInterval, isSaving]);
// Cleanup effect
useEffect(() => {
return () => {
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
}
if (countdownTimerRef.current) {
clearInterval(countdownTimerRef.current);
}
};
}, []);
const handleSaveAll = useCallback(
async (isAutoSave = false) => {
if (!hasUnsavedFiles || isSaving) {
console.log('[SaveAllButton] Skipping save:', { hasUnsavedFiles, isSaving });
return;
}
console.log('[SaveAllButton] Starting save:', {
unsavedCount,
isAutoSave,
files: Array.from(unsavedFiles),
});
setIsSaving(true);
const startTime = performance.now();
const savedFiles: string[] = [];
const failedFiles: string[] = [];
try {
// Save each file individually with detailed logging
for (const filePath of unsavedFiles) {
try {
console.log(`[SaveAllButton] Saving file: ${filePath}`);
await workbenchStore.saveFile(filePath);
savedFiles.push(filePath);
console.log(`[SaveAllButton] Successfully saved: ${filePath}`);
} catch (error) {
console.error(`[SaveAllButton] Failed to save ${filePath}:`, error);
failedFiles.push(filePath);
}
}
const endTime = performance.now();
const duration = Math.round(endTime - startTime);
setLastSaved(new Date());
// Check final state
const remainingUnsaved = workbenchStore.unsavedFiles.get();
console.log('[SaveAllButton] Save complete:', {
savedCount: savedFiles.length,
failedCount: failedFiles.length,
remainingUnsaved: Array.from(remainingUnsaved),
duration,
});
// Show appropriate feedback
if (failedFiles.length === 0) {
const message = isAutoSave
? `Auto-saved ${savedFiles.length} file${savedFiles.length > 1 ? 's' : ''}`
: `Saved ${savedFiles.length} file${savedFiles.length > 1 ? 's' : ''}`;
toast.success(message, {
position: 'bottom-right',
autoClose: 2000,
});
} else {
toast.warning(`Saved ${savedFiles.length} files, ${failedFiles.length} failed`, {
position: 'bottom-right',
autoClose: 3000,
});
}
} catch (error) {
console.error('[SaveAllButton] Critical error during save:', error);
toast.error('Failed to save files', {
position: 'bottom-right',
autoClose: 3000,
});
} finally {
setIsSaving(false);
}
},
[hasUnsavedFiles, isSaving, unsavedCount, unsavedFiles],
);
// Keyboard shortcut
useEffect(() => {
const handleKeyPress = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 's') {
e.preventDefault();
handleSaveAll(false);
}
};
window.addEventListener('keydown', handleKeyPress);
return () => window.removeEventListener('keydown', handleKeyPress);
}, [handleSaveAll]);
const formatLastSaved = () => {
if (!lastSaved) {
return null;
}
const now = new Date();
const diff = Math.floor((now.getTime() - lastSaved.getTime()) / 1000);
if (diff < 60) {
return `${diff}s ago`;
}
if (diff < 3600) {
return `${Math.floor(diff / 60)}m ago`;
}
return `${Math.floor(diff / 3600)}h ago`;
};
// Icon-only variant for header
if (variant === 'icon') {
return (
<Tooltip.Provider delayDuration={300}>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
onClick={() => handleSaveAll(false)}
disabled={!hasUnsavedFiles || isSaving}
className={classNames(
'relative p-1.5 rounded-md transition-colors',
hasUnsavedFiles
? 'text-bolt-elements-item-contentDefault hover:text-bolt-elements-item-contentActive hover:bg-bolt-elements-item-backgroundActive'
: 'text-bolt-elements-textTertiary cursor-not-allowed opacity-50',
className,
)}
>
<div className="relative">
<div className={isSaving ? 'animate-spin' : ''}>
<div className="i-ph:floppy-disk text-lg" />
</div>
{hasUnsavedFiles && showCount && !isSaving && (
<div className="absolute -top-1 -right-1 min-w-[12px] h-[12px] bg-red-500 text-white rounded-full flex items-center justify-center text-[8px] font-bold">
{unsavedCount}
</div>
)}
</div>
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
className="bg-bolt-elements-background-depth-3 text-bolt-elements-textPrimary px-3 py-2 rounded-lg shadow-lg border border-bolt-elements-borderColor z-[9999]"
sideOffset={5}
>
<div className="text-xs space-y-1">
<div className="font-semibold">
{hasUnsavedFiles ? `${unsavedCount} unsaved file${unsavedCount > 1 ? 's' : ''}` : 'All files saved'}
</div>
{lastSaved && <div className="text-bolt-elements-textTertiary">Last saved: {formatLastSaved()}</div>}
{autoSave && hasUnsavedFiles && timeUntilAutoSave && (
<div className="text-bolt-elements-textTertiary">Auto-save in: {timeUntilAutoSave}s</div>
)}
<div className="border-t border-bolt-elements-borderColor pt-1 mt-1">
<kbd className="text-xs">Ctrl+Shift+S</kbd> to save all
</div>
</div>
<Tooltip.Arrow className="fill-bolt-elements-background-depth-3" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}
// Button variant
return (
<Tooltip.Provider delayDuration={300}>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
onClick={() => handleSaveAll(false)}
disabled={!hasUnsavedFiles || isSaving}
className={classNames(
'inline-flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
hasUnsavedFiles
? 'bg-accent-500 hover:bg-accent-600 text-white'
: 'bg-bolt-elements-background-depth-1 text-bolt-elements-textTertiary border border-bolt-elements-borderColor cursor-not-allowed opacity-60',
className,
)}
>
<div className={isSaving ? 'animate-spin' : ''}>
<div className="i-ph:floppy-disk" />
</div>
<span>
{isSaving ? 'Saving...' : `Save All${showCount && hasUnsavedFiles ? ` (${unsavedCount})` : ''}`}
</span>
{autoSave && timeUntilAutoSave && hasUnsavedFiles && (
<span className="text-xs opacity-75">({timeUntilAutoSave}s)</span>
)}
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
className="bg-bolt-elements-background-depth-3 text-bolt-elements-textPrimary px-3 py-2 rounded-lg shadow-lg border border-bolt-elements-borderColor z-[9999]"
sideOffset={5}
>
<div className="text-xs">
<kbd>Ctrl+Shift+S</kbd> to save all
</div>
<Tooltip.Arrow className="fill-bolt-elements-background-depth-3" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
},
);
SaveAllButton.displayName = 'SaveAllButton';

View File

@@ -13,7 +13,6 @@ import {
type OnScrollCallback as OnEditorScroll,
} from '~/components/editor/codemirror/CodeMirrorEditor';
import { IconButton } from '~/components/ui/IconButton';
import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton';
import { Slider, type SliderOptions } from '~/components/ui/Slider';
import { workbenchStore, type WorkbenchViewType } from '~/lib/stores/workbench';
import { classNames } from '~/utils/classNames';
@@ -23,11 +22,13 @@ import { EditorPanel } from './EditorPanel';
import { Preview } from './Preview';
import useViewport from '~/lib/hooks';
// import { GitLabDeploymentDialog } from '~/components/deploy/GitLabDeploymentDialog';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { usePreviewStore } from '~/lib/stores/previews';
import { chatStore } from '~/lib/stores/chat';
import type { ElementInfo } from './Inspector';
import { ExportChatButton } from '~/components/chat/chatExportAndImport/ExportChatButton';
import { useChatHistory } from '~/lib/persistence';
import { streamingState } from '~/lib/stores/streaming';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
interface WorkspaceProps {
chatStarted?: boolean;
@@ -279,298 +280,239 @@ const FileModifiedDropdown = memo(
},
);
export const Workbench = memo(({ chatStarted, isStreaming, setSelectedElement }: WorkspaceProps) => {
renderLogger.trace('Workbench');
export const Workbench = memo(
({
chatStarted,
isStreaming,
metadata: _metadata,
updateChatMestaData: _updateChatMestaData,
setSelectedElement,
}: WorkspaceProps) => {
renderLogger.trace('Workbench');
const [isSyncing, setIsSyncing] = useState(false);
const [fileHistory, setFileHistory] = useState<Record<string, FileHistory>>({});
// const [isPushDialogOpen, setIsPushDialogOpen] = useState(false);
const [fileHistory, setFileHistory] = useState<Record<string, FileHistory>>({});
// const modifiedFiles = Array.from(useStore(workbenchStore.unsavedFiles).keys());
// Keyboard shortcut for Save All (Ctrl+Shift+S)
useEffect(() => {
const handleKeyPress = async (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 's') {
e.preventDefault();
const hasPreview = useStore(computed(workbenchStore.previews, (previews) => previews.length > 0));
const showWorkbench = useStore(workbenchStore.showWorkbench);
const selectedFile = useStore(workbenchStore.selectedFile);
const currentDocument = useStore(workbenchStore.currentDocument);
const unsavedFiles = useStore(workbenchStore.unsavedFiles);
const files = useStore(workbenchStore.files);
const selectedView = useStore(workbenchStore.currentView);
const { showChat } = useStore(chatStore);
const canHideChat = showWorkbench || !showChat;
const unsavedFiles = workbenchStore.unsavedFiles.get();
const isSmallViewport = useViewport(1024);
const streaming = useStore(streamingState);
const { exportChat } = useChatHistory();
const [isSyncing, setIsSyncing] = useState(false);
if (unsavedFiles.size > 0) {
try {
await workbenchStore.saveAllFiles();
toast.success(`Saved ${unsavedFiles.size} file${unsavedFiles.size > 1 ? 's' : ''}`, {
position: 'bottom-right',
autoClose: 2000,
});
} catch {
toast.error('Failed to save some files', {
position: 'bottom-right',
autoClose: 3000,
});
}
} else {
toast.info('All files are already saved', {
position: 'bottom-right',
autoClose: 2000,
});
}
}
const setSelectedView = (view: WorkbenchViewType) => {
workbenchStore.currentView.set(view);
};
window.addEventListener('keydown', handleKeyPress);
return () => window.removeEventListener('keydown', handleKeyPress);
}, []);
useEffect(() => {
if (hasPreview) {
setSelectedView('preview');
}
}, [hasPreview]);
// const modifiedFiles = Array.from(useStore(workbenchStore.unsavedFiles).keys());
useEffect(() => {
workbenchStore.setDocuments(files);
}, [files]);
const hasPreview = useStore(computed(workbenchStore.previews, (previews) => previews.length > 0));
const showWorkbench = useStore(workbenchStore.showWorkbench);
const selectedFile = useStore(workbenchStore.selectedFile);
const currentDocument = useStore(workbenchStore.currentDocument);
const unsavedFiles = useStore(workbenchStore.unsavedFiles);
const files = useStore(workbenchStore.files);
const selectedView = useStore(workbenchStore.currentView);
const { showChat } = useStore(chatStore);
const canHideChat = showWorkbench || !showChat;
const onEditorChange = useCallback<OnEditorChange>((update) => {
workbenchStore.setCurrentDocumentContent(update.content);
}, []);
const isSmallViewport = useViewport(1024);
const onEditorScroll = useCallback<OnEditorScroll>((position) => {
workbenchStore.setCurrentDocumentScrollPosition(position);
}, []);
const setSelectedView = (view: WorkbenchViewType) => {
workbenchStore.currentView.set(view);
};
const onFileSelect = useCallback((filePath: string | undefined) => {
workbenchStore.setSelectedFile(filePath);
}, []);
useEffect(() => {
if (hasPreview) {
setSelectedView('preview');
}
}, [hasPreview]);
const onFileSave = useCallback(() => {
workbenchStore
.saveCurrentDocument()
.then(() => {
// Explicitly refresh all previews after a file save
const previewStore = usePreviewStore();
previewStore.refreshAllPreviews();
})
.catch(() => {
toast.error('Failed to update file content');
});
}, []);
useEffect(() => {
workbenchStore.setDocuments(files);
}, [files]);
const onFileReset = useCallback(() => {
workbenchStore.resetCurrentDocument();
}, []);
const onEditorChange = useCallback<OnEditorChange>((update) => {
workbenchStore.setCurrentDocumentContent(update.content);
}, []);
const handleSelectFile = useCallback((filePath: string) => {
workbenchStore.setSelectedFile(filePath);
workbenchStore.currentView.set('diff');
}, []);
const onEditorScroll = useCallback<OnEditorScroll>((position) => {
workbenchStore.setCurrentDocumentScrollPosition(position);
}, []);
const handleSyncFiles = useCallback(async () => {
setIsSyncing(true);
const onFileSelect = useCallback((filePath: string | undefined) => {
workbenchStore.setSelectedFile(filePath);
}, []);
try {
const directoryHandle = await window.showDirectoryPicker();
await workbenchStore.syncFiles(directoryHandle);
toast.success('Files synced successfully');
} catch (error) {
console.error('Error syncing files:', error);
toast.error('Failed to sync files');
} finally {
setIsSyncing(false);
}
}, []);
const onFileSave = useCallback(() => {
workbenchStore
.saveCurrentDocument()
.then(() => {
// Explicitly refresh all previews after a file save
const previewStore = usePreviewStore();
previewStore.refreshAllPreviews();
})
.catch(() => {
toast.error('Failed to update file content');
});
}, []);
const onFileReset = useCallback(() => {
workbenchStore.resetCurrentDocument();
}, []);
const handleSyncFiles = useCallback(async () => {
setIsSyncing(true);
try {
const directoryHandle = await window.showDirectoryPicker();
await workbenchStore.syncFiles(directoryHandle);
toast.success('Files synced successfully');
} catch {
console.error('Error syncing files');
toast.error('Failed to sync files');
} finally {
setIsSyncing(false);
}
}, []);
const handleSelectFile = useCallback((filePath: string) => {
workbenchStore.setSelectedFile(filePath);
workbenchStore.currentView.set('diff');
}, []);
return (
chatStarted && (
<motion.div
initial="closed"
animate={showWorkbench ? 'open' : 'closed'}
variants={workbenchVariants}
className="z-workbench"
>
<div
className={classNames(
'fixed top-[calc(var(--header-height)+1.2rem)] bottom-6 w-[var(--workbench-inner-width)] z-0 transition-[left,width] duration-200 bolt-ease-cubic-bezier',
{
'w-full': isSmallViewport,
'left-0': showWorkbench && isSmallViewport,
'left-[var(--workbench-left)]': showWorkbench,
'left-[100%]': !showWorkbench,
},
)}
return (
chatStarted && (
<motion.div
initial="closed"
animate={showWorkbench ? 'open' : 'closed'}
variants={workbenchVariants}
className="z-workbench"
>
<div className="absolute inset-0 px-2 lg:px-4">
<div className="h-full flex flex-col bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor shadow-sm rounded-lg overflow-hidden">
<div className="flex items-center px-3 py-2 border-b border-bolt-elements-borderColor gap-1.5">
<button
className={`${showChat ? 'i-ph:sidebar-simple-fill' : 'i-ph:sidebar-simple'} text-lg text-bolt-elements-textSecondary mr-1`}
disabled={!canHideChat || isSmallViewport}
onClick={() => {
if (canHideChat) {
chatStore.setKey('showChat', !showChat);
}
}}
/>
<Slider selected={selectedView} options={sliderOptions} setSelected={setSelectedView} />
<div className="ml-auto" />
{selectedView === 'code' && (
<div className="flex overflow-y-auto">
<PanelHeaderButton
className="mr-1 text-sm"
onClick={async () => {
console.log('[SaveAll] Button clicked');
const unsavedFiles = workbenchStore.unsavedFiles.get();
console.log('[SaveAll] Unsaved files:', Array.from(unsavedFiles));
if (unsavedFiles.size > 0) {
try {
console.log('[SaveAll] Starting save...');
await workbenchStore.saveAllFiles();
toast.success(`Saved ${unsavedFiles.size} file${unsavedFiles.size > 1 ? 's' : ''}`, {
position: 'bottom-right',
autoClose: 2000,
});
console.log('[SaveAll] Save successful');
} catch {
console.error('[SaveAll] Save failed');
toast.error('Failed to save files', {
position: 'bottom-right',
autoClose: 3000,
});
}
} else {
console.log('[SaveAll] No unsaved files');
toast.info('All files are already saved', {
position: 'bottom-right',
autoClose: 2000,
});
}
}}
>
<div className="i-ph:floppy-disk" />
Save All
</PanelHeaderButton>
<PanelHeaderButton
className="mr-1 text-sm"
onClick={() => {
workbenchStore.toggleTerminal(!workbenchStore.showTerminal.get());
}}
>
<div className="i-ph:terminal" />
Toggle Terminal
</PanelHeaderButton>
<DropdownMenu.Root>
<DropdownMenu.Trigger className="text-sm flex items-center gap-1 text-bolt-elements-item-contentDefault bg-transparent enabled:hover:text-bolt-elements-item-contentActive rounded-md p-1 enabled:hover:bg-bolt-elements-item-backgroundActive disabled:cursor-not-allowed">
<div className="i-ph:box-arrow-up" />
Sync
</DropdownMenu.Trigger>
<DropdownMenu.Content
className={classNames(
'min-w-[240px] z-[250]',
'bg-white dark:bg-[#141414]',
'rounded-lg shadow-lg',
'border border-gray-200/50 dark:border-gray-800/50',
'animate-in fade-in-0 zoom-in-95',
'py-1',
)}
sideOffset={5}
align="end"
>
<DropdownMenu.Item
className={classNames(
'cursor-pointer flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative',
)}
onClick={handleSyncFiles}
disabled={isSyncing}
>
<div className="flex items-center gap-2">
{isSyncing ? <div className="i-ph:spinner" /> : <div className="i-ph:cloud-arrow-down" />}
<span>{isSyncing ? 'Syncing...' : 'Sync Files'}</span>
</div>
</DropdownMenu.Item>
<DropdownMenu.Item
className={classNames(
'cursor-pointer flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative',
)}
onClick={() => {
/* GitHub push temporarily disabled */
}}
>
<div className="flex items-center gap-2">
<div className="i-ph:git-branch" />
Push to GitHub
</div>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
)}
{selectedView === 'diff' && (
<FileModifiedDropdown fileHistory={fileHistory} onSelectFile={handleSelectFile} />
)}
<IconButton
icon="i-ph:x-circle"
className="-mr-1"
size="xl"
onClick={() => {
workbenchStore.showWorkbench.set(false);
}}
/>
</div>
<div className="relative flex-1 overflow-hidden">
<View initial={{ x: '0%' }} animate={{ x: selectedView === 'code' ? '0%' : '-100%' }}>
<EditorPanel
editorDocument={currentDocument}
isStreaming={isStreaming}
selectedFile={selectedFile}
files={files}
unsavedFiles={unsavedFiles}
fileHistory={fileHistory}
onFileSelect={onFileSelect}
onEditorScroll={onEditorScroll}
onEditorChange={onEditorChange}
onFileSave={onFileSave}
onFileReset={onFileReset}
<div
className={classNames(
'fixed top-[calc(var(--header-height)+1.2rem)] bottom-6 w-[var(--workbench-inner-width)] z-0 transition-[left,width] duration-200 bolt-ease-cubic-bezier',
{
'w-full': isSmallViewport,
'left-0': showWorkbench && isSmallViewport,
'left-[var(--workbench-left)]': showWorkbench,
'left-[100%]': !showWorkbench,
},
)}
>
<div className="absolute inset-0 px-2 lg:px-4">
<div className="h-full flex flex-col bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor shadow-sm rounded-lg overflow-hidden">
<div className="flex items-center px-3 py-2 border-b border-bolt-elements-borderColor gap-1.5">
<button
className={`${showChat ? 'i-ph:sidebar-simple-fill' : 'i-ph:sidebar-simple'} text-lg text-bolt-elements-textSecondary mr-1`}
disabled={!canHideChat || isSmallViewport}
onClick={() => {
if (canHideChat) {
chatStore.setKey('showChat', !showChat);
}
}}
/>
</View>
<View
initial={{ x: '100%' }}
animate={{ x: selectedView === 'diff' ? '0%' : selectedView === 'code' ? '100%' : '-100%' }}
>
<DiffView fileHistory={fileHistory} setFileHistory={setFileHistory} />
</View>
<View initial={{ x: '100%' }} animate={{ x: selectedView === 'preview' ? '0%' : '100%' }}>
<Preview setSelectedElement={setSelectedElement} />
</View>
<Slider selected={selectedView} options={sliderOptions} setSelected={setSelectedView} />
<div className="ml-auto" />
{selectedView === 'code' && (
<div className="flex overflow-y-auto">
{/* Export Chat Button */}
<ExportChatButton exportChat={exportChat} />
{/* Sync Button */}
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden ml-1">
<DropdownMenu.Root>
<DropdownMenu.Trigger
disabled={isSyncing || streaming}
className="rounded-md items-center justify-center [&:is(:disabled,.disabled)]:cursor-not-allowed [&:is(:disabled,.disabled)]:opacity-60 px-3 py-1.5 text-xs bg-accent-500 text-white hover:text-bolt-elements-item-contentAccent [&:not(:disabled,.disabled)]:hover:bg-bolt-elements-button-primary-backgroundHover outline-accent-500 flex gap-1.7"
>
{isSyncing ? 'Syncing...' : 'Sync'}
<span className={classNames('i-ph:caret-down transition-transform')} />
</DropdownMenu.Trigger>
<DropdownMenu.Content
className={classNames(
'min-w-[240px] z-[250]',
'bg-white dark:bg-[#141414]',
'rounded-lg shadow-lg',
'border border-gray-200/50 dark:border-gray-800/50',
'animate-in fade-in-0 zoom-in-95',
'py-1',
)}
sideOffset={5}
align="end"
>
<DropdownMenu.Item
className={classNames(
'cursor-pointer flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative',
)}
onClick={handleSyncFiles}
disabled={isSyncing}
>
<div className="flex items-center gap-2">
{isSyncing ? (
<div className="i-ph:spinner" />
) : (
<div className="i-ph:cloud-arrow-down" />
)}
<span>{isSyncing ? 'Syncing...' : 'Sync Files'}</span>
</div>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
{/* Toggle Terminal Button */}
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden ml-1">
<button
onClick={() => {
workbenchStore.toggleTerminal(!workbenchStore.showTerminal.get());
}}
className="rounded-md items-center justify-center [&:is(:disabled,.disabled)]:cursor-not-allowed [&:is(:disabled,.disabled)]:opacity-60 px-3 py-1.5 text-xs bg-accent-500 text-white hover:text-bolt-elements-item-contentAccent [&:not(:disabled,.disabled)]:hover:bg-bolt-elements-button-primary-backgroundHover outline-accent-500 flex gap-1.7"
>
<div className="i-ph:terminal" />
Toggle Terminal
</button>
</div>
</div>
)}
{selectedView === 'diff' && (
<FileModifiedDropdown fileHistory={fileHistory} onSelectFile={handleSelectFile} />
)}
<IconButton
icon="i-ph:x-circle"
className="-mr-1"
size="xl"
onClick={() => {
workbenchStore.showWorkbench.set(false);
}}
/>
</div>
<div className="relative flex-1 overflow-hidden">
<View initial={{ x: '0%' }} animate={{ x: selectedView === 'code' ? '0%' : '-100%' }}>
<EditorPanel
editorDocument={currentDocument}
isStreaming={isStreaming}
selectedFile={selectedFile}
files={files}
unsavedFiles={unsavedFiles}
fileHistory={fileHistory}
onFileSelect={onFileSelect}
onEditorScroll={onEditorScroll}
onEditorChange={onEditorChange}
onFileSave={onFileSave}
onFileReset={onFileReset}
/>
</View>
<View
initial={{ x: '100%' }}
animate={{ x: selectedView === 'diff' ? '0%' : selectedView === 'code' ? '100%' : '-100%' }}
>
<DiffView fileHistory={fileHistory} setFileHistory={setFileHistory} />
</View>
<View initial={{ x: '100%' }} animate={{ x: selectedView === 'preview' ? '0%' : '100%' }}>
<Preview setSelectedElement={setSelectedElement} />
</View>
</div>
</div>
</div>
</div>
</div>
{/* GitHub push dialog temporarily disabled during merge - will be re-enabled with new GitLab integration */}
</motion.div>
)
);
});
</motion.div>
)
);
},
);
// View component for rendering content with motion transitions
interface ViewProps extends HTMLMotionProps<'div'> {