From dd77649053769b91886897ec64fbd1c15e7a7a75 Mon Sep 17 00:00:00 2001 From: viper151 Date: Mon, 8 Jun 2026 05:58:08 +0000 Subject: [PATCH 01/21] chore(release): v1.33.2 --- CHANGELOG.md | 12 ++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dea0876..1ce504b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,18 @@ All notable changes to CloudCLI UI will be documented in this file. +## [1.33.2](https://github.com/siteboon/claudecodeui/compare/v1.33.1...v1.33.2) (2026-06-08) + +### New Features + +* **chat:** open cost modal from token usage ([f238050](https://github.com/siteboon/claudecodeui/commit/f238050b85c3b99a702a8635059735e1a3b3a4f4)) +* **i18n:** add Traditional Chinese (zh-TW) locale ([#773](https://github.com/siteboon/claudecodeui/issues/773)) ([c21a9f4](https://github.com/siteboon/claudecodeui/commit/c21a9f45610eb1eeb650d8e6cf8650e798f77f6f)) + +### Bug Fixes + +* do not show model description in chat view ([d638a89](https://github.com/siteboon/claudecodeui/commit/d638a8982c7f75b08fc7f65f01d6d54989c790d1)) +* include Claude cache tokens in usage ([ed9cdf0](https://github.com/siteboon/claudecodeui/commit/ed9cdf01145fa0d063580bb76d30cfa7ee67af86)) + ## [1.33.1](https://github.com/siteboon/claudecodeui/compare/v1.33.0...v1.33.1) (2026-06-05) ### New Features diff --git a/package-lock.json b/package-lock.json index 407237bd..0cd364b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cloudcli-ai/cloudcli", - "version": "1.33.1", + "version": "1.33.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cloudcli-ai/cloudcli", - "version": "1.33.1", + "version": "1.33.2", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "dependencies": { diff --git a/package.json b/package.json index 6de5e147..93a358d4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cloudcli-ai/cloudcli", - "version": "1.33.1", + "version": "1.33.2", "description": "A web-based UI for Claude Code CLI", "type": "module", "main": "dist-server/server/index.js", From c235b05e1d3b626667dba4043b685512e3cd3d5d Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Mon, 8 Jun 2026 14:52:09 +0300 Subject: [PATCH 02/21] feat: add file tree upload progress Users need a visible upload path from the explorer itself, not only drag and drop behavior with no progress feedback. Routing picker and drop uploads through one XHR-backed hook keeps progress, validation, refresh, and success counts consistent for every upload source. The 200MB limit is mirrored in the client, multer, and nginx template so large uploads fail predictably instead of being blocked by whichever layer sees the request first. The server also returns explicit requested and uploaded counts so partial or multi-file batches can render accurate status text. --- docs/nginx-subpath-template.conf | 2 +- server/index.js | 24 +- .../file-tree/constants/constants.ts | 8 + .../file-tree/hooks/useFileTreeUpload.ts | 432 ++++++++++++++---- src/components/file-tree/view/FileTree.tsx | 21 +- .../file-tree/view/FileTreeHeader.tsx | 65 ++- .../file-tree/view/FileTreeUploadProgress.tsx | 90 ++++ 7 files changed, 535 insertions(+), 107 deletions(-) create mode 100644 src/components/file-tree/view/FileTreeUploadProgress.tsx diff --git a/docs/nginx-subpath-template.conf b/docs/nginx-subpath-template.conf index 15f4f067..e4132615 100644 --- a/docs/nginx-subpath-template.conf +++ b/docs/nginx-subpath-template.conf @@ -72,7 +72,7 @@ http { set $cloudcli_upstream http://127.0.0.1:3001; # Allow larger file uploads through the code editor/project file APIs. - client_max_body_size 100m; + client_max_body_size 200m; # Redirect /ai to /ai/ so relative browser URL resolution is stable. # [SUBPATH LITERAL] Change `/ai` if you change $cloudcli_subpath. diff --git a/server/index.js b/server/index.js index 58c4ce74..cb8ecc31 100755 --- a/server/index.js +++ b/server/index.js @@ -84,6 +84,9 @@ const __dirname = getModuleDir(import.meta.url); // Resolving the app root once keeps every repo-level lookup below aligned across both layouts. const APP_ROOT = findAppRoot(__dirname); const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm'; +const MAX_FILE_UPLOAD_SIZE_MB = 200; +const MAX_FILE_UPLOAD_SIZE_BYTES = MAX_FILE_UPLOAD_SIZE_MB * 1024 * 1024; +const MAX_FILE_UPLOAD_COUNT = 20; console.log('SERVER_PORT from env:', process.env.SERVER_PORT); @@ -897,27 +900,27 @@ const uploadFilesHandler = async (req, res) => { } }), limits: { - fileSize: 50 * 1024 * 1024, // 50MB limit - files: 20 // Max 20 files at once + fileSize: MAX_FILE_UPLOAD_SIZE_BYTES, + files: MAX_FILE_UPLOAD_COUNT } }); // Use multer middleware - uploadMiddleware.array('files', 20)(req, res, async (err) => { + uploadMiddleware.array('files', MAX_FILE_UPLOAD_COUNT)(req, res, async (err) => { if (err) { console.error('Multer error:', err); if (err.code === 'LIMIT_FILE_SIZE') { - return res.status(400).json({ error: 'File too large. Maximum size is 50MB.' }); + return res.status(400).json({ error: `File too large. Maximum size is ${MAX_FILE_UPLOAD_SIZE_MB}MB.` }); } if (err.code === 'LIMIT_FILE_COUNT') { - return res.status(400).json({ error: 'Too many files. Maximum is 20 files.' }); + return res.status(400).json({ error: `Too many files. Maximum is ${MAX_FILE_UPLOAD_COUNT} files.` }); } return res.status(500).json({ error: err.message }); } try { const { projectId } = req.params; - const { targetPath, relativePaths } = req.body; + const { targetPath, relativePaths, requestedFileCount: requestedFileCountRaw } = req.body; // Parse relative paths if provided (for folder uploads) let filePaths = []; @@ -941,6 +944,11 @@ const uploadFilesHandler = async (req, res) => { return res.status(400).json({ error: 'No files provided' }); } + const parsedRequestedFileCount = Number.parseInt(requestedFileCountRaw, 10); + const requestedFileCount = Number.isFinite(parsedRequestedFileCount) && parsedRequestedFileCount > 0 + ? parsedRequestedFileCount + : req.files.length; + // Resolve the project directory through the DB using the new projectId. const projectRoot = await projectsDb.getProjectPathById(projectId); if (!projectRoot) { @@ -1019,8 +1027,10 @@ const uploadFilesHandler = async (req, res) => { res.json({ success: true, files: uploadedFiles, + uploadedCount: uploadedFiles.length, + requestedFileCount, targetPath: resolvedTargetDir, - message: `Uploaded ${uploadedFiles.length} file(s) successfully` + message: `Uploaded ${uploadedFiles.length} ${uploadedFiles.length === 1 ? 'file' : 'files'} successfully` }); } catch (error) { console.error('Error uploading files:', error); diff --git a/src/components/file-tree/constants/constants.ts b/src/components/file-tree/constants/constants.ts index a56bf6bd..71cc9f35 100644 --- a/src/components/file-tree/constants/constants.ts +++ b/src/components/file-tree/constants/constants.ts @@ -6,6 +6,14 @@ export const FILE_TREE_DEFAULT_VIEW_MODE: FileTreeViewMode = 'detailed'; export const FILE_TREE_VIEW_MODES: FileTreeViewMode[] = ['simple', 'compact', 'detailed']; +export const MAX_FILE_UPLOAD_SIZE_MB = 200; + +export const MAX_FILE_UPLOAD_SIZE_BYTES = MAX_FILE_UPLOAD_SIZE_MB * 1024 * 1024; + +export const MAX_FILE_UPLOAD_SIZE_LABEL = `${MAX_FILE_UPLOAD_SIZE_MB}MB`; + +export const MAX_FILE_UPLOAD_COUNT = 20; + export const IMAGE_FILE_EXTENSIONS = new Set([ 'png', 'jpg', diff --git a/src/components/file-tree/hooks/useFileTreeUpload.ts b/src/components/file-tree/hooks/useFileTreeUpload.ts index 6879e3ae..37cd936d 100644 --- a/src/components/file-tree/hooks/useFileTreeUpload.ts +++ b/src/components/file-tree/hooks/useFileTreeUpload.ts @@ -1,6 +1,13 @@ -import { useCallback, useState, useRef } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { DragEvent } from 'react'; + +import { IS_PLATFORM } from '../../../constants/config'; import type { Project } from '../../../types/app'; -import { api } from '../../../utils/api'; +import { + MAX_FILE_UPLOAD_COUNT, + MAX_FILE_UPLOAD_SIZE_BYTES, + MAX_FILE_UPLOAD_SIZE_LABEL, +} from '../constants/constants'; type UseFileTreeUploadOptions = { selectedProject: Project | null; @@ -8,6 +15,141 @@ type UseFileTreeUploadOptions = { showToast: (message: string, type: 'success' | 'error') => void; }; +export type FileTreeUploadProgressState = { + status: 'uploading' | 'complete' | 'error'; + progress: number; + fileCount: number; + uploadedCount?: number; + fileName?: string; + targetPath?: string; + error?: string; +}; + +type UploadResponse = { + error?: string; + message?: string; + files?: unknown[]; + uploadedCount?: number; + requestedFileCount?: number; +}; + +const COMPLETE_PROGRESS_CLEAR_DELAY_MS = 1400; +const ERROR_PROGRESS_CLEAR_DELAY_MS = 3200; + +const pluralizeFiles = (count: number) => (count === 1 ? 'file' : 'files'); + +const getRelativePath = (file: File) => { + const fileWithRelativePath = file as File & { webkitRelativePath?: string }; + return fileWithRelativePath.webkitRelativePath || file.name; +}; + +const getFileDisplayName = (file: File) => { + const relativePath = getRelativePath(file); + return relativePath.split(/[\\/]/).pop() || file.name; +}; + +const validateFilesForUpload = (files: File[]): string | null => { + if (files.length > MAX_FILE_UPLOAD_COUNT) { + return `You can upload up to ${MAX_FILE_UPLOAD_COUNT} files at once.`; + } + + const oversizedFile = files.find((file) => file.size > MAX_FILE_UPLOAD_SIZE_BYTES); + if (oversizedFile) { + return `${getFileDisplayName(oversizedFile)} is larger than ${MAX_FILE_UPLOAD_SIZE_LABEL}.`; + } + + return null; +}; + +const parseUploadResponse = (xhr: XMLHttpRequest): UploadResponse => { + if (!xhr.responseText) { + return {}; + } + + try { + return JSON.parse(xhr.responseText) as UploadResponse; + } catch { + return {}; + } +}; + +const formatUploadSuccessMessage = (uploadedCount: number, requestedFileCount: number) => { + if (uploadedCount !== requestedFileCount) { + return `Uploaded ${uploadedCount} of ${requestedFileCount} ${pluralizeFiles(requestedFileCount)}`; + } + + return `Uploaded ${uploadedCount} ${pluralizeFiles(uploadedCount)} successfully`; +}; + +const buildUploadFormData = (files: File[], targetPath: string) => { + const formData = new FormData(); + const relativePaths: string[] = []; + + formData.append('targetPath', targetPath); + formData.append('requestedFileCount', String(files.length)); + + files.forEach((file) => { + const relativePath = getRelativePath(file); + const cleanFile = new File([file], relativePath.split(/[\\/]/).pop() || file.name, { + type: file.type, + lastModified: file.lastModified, + }); + + formData.append('files', cleanFile); + relativePaths.push(relativePath); + }); + + formData.append('relativePaths', JSON.stringify(relativePaths)); + + return formData; +}; + +const uploadFormDataWithProgress = ( + projectId: string, + formData: FormData, + onProgress: (progress: number) => void, +) => + new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + + xhr.open('POST', `/api/projects/${encodeURIComponent(projectId)}/files/upload`); + + const token = localStorage.getItem('auth-token'); + if (!IS_PLATFORM && token) { + xhr.setRequestHeader('Authorization', `Bearer ${token}`); + } + + xhr.upload.onprogress = (event) => { + if (!event.lengthComputable) { + return; + } + + // Keep 100% for the server response so the UI can distinguish transfer + // completion from the final write/refresh step. + onProgress(Math.min(99, Math.round((event.loaded / event.total) * 100))); + }; + + xhr.onload = () => { + const refreshedToken = xhr.getResponseHeader('X-Refreshed-Token'); + if (refreshedToken) { + localStorage.setItem('auth-token', refreshedToken); + } + + const payload = parseUploadResponse(xhr); + if (xhr.status >= 200 && xhr.status < 300) { + resolve(payload); + return; + } + + reject(new Error(payload.error || payload.message || `Upload failed with status ${xhr.status}`)); + }; + + xhr.onerror = () => reject(new Error('Upload failed. Check your connection and try again.')); + xhr.onabort = () => reject(new Error('Upload canceled.')); + + xhr.send(formData); + }); + // Helper function to read all files from a directory entry recursively const readAllDirectoryEntries = async (directoryEntry: FileSystemDirectoryEntry, basePath = ''): Promise => { const files: File[] = []; @@ -57,6 +199,48 @@ const readAllDirectoryEntries = async (directoryEntry: FileSystemDirectoryEntry, return files; }; +const collectDroppedFiles = async (dataTransfer: DataTransfer) => { + const files: File[] = []; + + // Use DataTransferItemList for folder support + const { items } = dataTransfer; + if (items) { + for (const item of Array.from(items)) { + if (item.kind !== 'file') { + continue; + } + + const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null; + if (!entry) { + const file = item.getAsFile(); + if (file) { + files.push(file); + } + continue; + } + + if (entry.isFile) { + const file = await new Promise((resolve, reject) => { + (entry as FileSystemFileEntry).file(resolve, reject); + }); + files.push(file); + } else if (entry.isDirectory) { + // Pass the directory name as basePath so files include the folder path + const dirFiles = await readAllDirectoryEntries(entry as FileSystemDirectoryEntry, entry.name); + files.push(...dirFiles); + } + } + return files; + } + + // Fallback for browsers that don't support webkitGetAsEntry + for (const file of Array.from(dataTransfer.files)) { + files.push(file); + } + + return files; +}; + export const useFileTreeUpload = ({ selectedProject, onRefresh, @@ -65,20 +249,150 @@ export const useFileTreeUpload = ({ const [isDragOver, setIsDragOver] = useState(false); const [dropTarget, setDropTarget] = useState(null); const [operationLoading, setOperationLoading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(null); const treeRef = useRef(null); + const clearProgressTimerRef = useRef | null>(null); - const handleDragEnter = useCallback((e: React.DragEvent) => { + const clearProgressTimer = useCallback(() => { + if (clearProgressTimerRef.current) { + clearTimeout(clearProgressTimerRef.current); + clearProgressTimerRef.current = null; + } + }, []); + + const scheduleProgressClear = useCallback( + (delay: number) => { + clearProgressTimer(); + clearProgressTimerRef.current = setTimeout(() => { + setUploadProgress(null); + clearProgressTimerRef.current = null; + }, delay); + }, + [clearProgressTimer], + ); + + useEffect(() => clearProgressTimer, [clearProgressTimer]); + + const setUploadError = useCallback( + (message: string, fileCount: number, targetPath = '', fileName?: string, progress = 0) => { + setUploadProgress({ + status: 'error', + progress, + fileCount, + fileName, + targetPath, + error: message, + }); + scheduleProgressClear(ERROR_PROGRESS_CLEAR_DELAY_MS); + }, + [scheduleProgressClear], + ); + + const uploadFiles = useCallback( + async (files: File[], targetPath = '') => { + if (files.length === 0) { + setDropTarget(null); + return; + } + + const fileName = files.length === 1 ? getFileDisplayName(files[0]) : undefined; + + if (!selectedProject) { + const message = 'Select a project before uploading files.'; + showToast(message, 'error'); + setUploadError(message, files.length, targetPath, fileName); + return; + } + + const validationError = validateFilesForUpload(files); + if (validationError) { + showToast(validationError, 'error'); + setUploadError(validationError, files.length, targetPath, fileName); + return; + } + + clearProgressTimer(); + setOperationLoading(true); + setUploadProgress({ + status: 'uploading', + progress: 0, + fileCount: files.length, + fileName, + targetPath, + }); + + let latestProgress = 0; + + try { + const response = await uploadFormDataWithProgress( + selectedProject.projectId, + buildUploadFormData(files, targetPath), + (progress) => { + latestProgress = progress; + setUploadProgress((current) => + current && current.status === 'uploading' + ? { ...current, progress } + : current, + ); + }, + ); + + const uploadedCount = + typeof response.uploadedCount === 'number' ? response.uploadedCount : response.files?.length ?? files.length; + const requestedFileCount = + typeof response.requestedFileCount === 'number' ? response.requestedFileCount : files.length; + + setUploadProgress({ + status: 'complete', + progress: 100, + fileCount: requestedFileCount, + uploadedCount, + fileName, + targetPath, + }); + + showToast(formatUploadSuccessMessage(uploadedCount, requestedFileCount), 'success'); + scheduleProgressClear(COMPLETE_PROGRESS_CLEAR_DELAY_MS); + onRefresh(); + } catch (err) { + const message = err instanceof Error ? err.message : 'Upload failed'; + console.error('Upload error:', err); + showToast(message, 'error'); + setUploadError(message, files.length, targetPath, fileName, latestProgress); + } finally { + setOperationLoading(false); + setDropTarget(null); + } + }, + [ + clearProgressTimer, + onRefresh, + scheduleProgressClear, + selectedProject, + setUploadError, + showToast, + ], + ); + + const handleFileSelect = useCallback( + async (fileList: FileList | File[]) => { + await uploadFiles(Array.from(fileList), ''); + }, + [uploadFiles], + ); + + const handleDragEnter = useCallback((e: DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragOver(true); }, []); - const handleDragOver = useCallback((e: React.DragEvent) => { + const handleDragOver = useCallback((e: DragEvent) => { e.preventDefault(); e.stopPropagation(); }, []); - const handleDragLeave = useCallback((e: React.DragEvent) => { + const handleDragLeave = useCallback((e: DragEvent) => { e.preventDefault(); e.stopPropagation(); // Only set isDragOver to false if we're leaving the entire tree @@ -88,103 +402,35 @@ export const useFileTreeUpload = ({ } }, []); - const handleDrop = useCallback(async (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragOver(false); + const handleDrop = useCallback( + async (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); - const targetPath = dropTarget || ''; - setOperationLoading(true); + const targetPath = dropTarget || ''; - try { - const files: File[] = []; - - // Use DataTransferItemList for folder support - const items = e.dataTransfer.items; - if (items) { - for (const item of Array.from(items)) { - if (item.kind === 'file') { - const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null; - - if (entry) { - if (entry.isFile) { - const file = await new Promise((resolve, reject) => { - (entry as FileSystemFileEntry).file(resolve, reject); - }); - files.push(file); - } else if (entry.isDirectory) { - // Pass the directory name as basePath so files include the folder path - const dirFiles = await readAllDirectoryEntries(entry as FileSystemDirectoryEntry, entry.name); - files.push(...dirFiles); - } - } - } - } - } else { - // Fallback for browsers that don't support webkitGetAsEntry - const fileList = e.dataTransfer.files; - for (const file of Array.from(fileList)) { - files.push(file); - } - } - - if (files.length === 0) { - setOperationLoading(false); + try { + const files = await collectDroppedFiles(e.dataTransfer); + await uploadFiles(files, targetPath); + } catch (err) { + const message = err instanceof Error ? err.message : 'Could not read dropped files'; + console.error('Upload error:', err); + showToast(message, 'error'); + setUploadError(message, 0, targetPath); setDropTarget(null); - return; } + }, + [dropTarget, setUploadError, showToast, uploadFiles], + ); - const formData = new FormData(); - formData.append('targetPath', targetPath); - - // Store relative paths separately since FormData strips path info from File.name - const relativePaths: string[] = []; - files.forEach((file) => { - // Create a new file with just the filename (without path) for FormData - // but store the relative path separately - const cleanFile = new File([file], file.name.split('/').pop()!, { - type: file.type, - lastModified: file.lastModified - }); - formData.append('files', cleanFile); - relativePaths.push(file.name); // Keep the full relative path - }); - - // Send relative paths as a JSON array - formData.append('relativePaths', JSON.stringify(relativePaths)); - - const response = await api.post( - // File upload endpoint is keyed by DB projectId post-migration. - `/projects/${encodeURIComponent(selectedProject!.projectId)}/files/upload`, - formData - ); - - if (!response.ok) { - const data = await response.json(); - throw new Error(data.error || 'Upload failed'); - } - - showToast( - `Uploaded ${files.length} file(s)`, - 'success' - ); - onRefresh(); - } catch (err) { - console.error('Upload error:', err); - showToast(err instanceof Error ? err.message : 'Upload failed', 'error'); - } finally { - setOperationLoading(false); - setDropTarget(null); - } - }, [dropTarget, selectedProject, onRefresh, showToast]); - - const handleItemDragOver = useCallback((e: React.DragEvent, itemPath: string) => { + const handleItemDragOver = useCallback((e: DragEvent, itemPath: string) => { e.preventDefault(); e.stopPropagation(); setDropTarget(itemPath); }, []); - const handleItemDrop = useCallback((e: React.DragEvent, itemPath: string) => { + const handleItemDrop = useCallback((e: DragEvent, itemPath: string) => { e.preventDefault(); e.stopPropagation(); setDropTarget(itemPath); @@ -194,7 +440,9 @@ export const useFileTreeUpload = ({ isDragOver, dropTarget, operationLoading, + uploadProgress, treeRef, + handleFileSelect, handleDragEnter, handleDragOver, handleDragLeave, diff --git a/src/components/file-tree/view/FileTree.tsx b/src/components/file-tree/view/FileTree.tsx index b42e1014..bd90c36b 100644 --- a/src/components/file-tree/view/FileTree.tsx +++ b/src/components/file-tree/view/FileTree.tsx @@ -1,6 +1,7 @@ import { useCallback, useState, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { AlertTriangle, Check, X, Loader2, Folder, Upload } from 'lucide-react'; + import { cn } from '../../../lib/utils'; import { ICON_SIZE_CLASS, getFileIconData } from '../constants/fileIcons'; import { useExpandedDirectories } from '../hooks/useExpandedDirectories'; @@ -13,10 +14,12 @@ import type { FileTreeImageSelection, FileTreeNode } from '../types/types'; import { formatFileSize, formatRelativeTime, isImageFile } from '../utils/fileTreeUtils'; import { Project } from '../../../types/app'; import { ScrollArea, Input } from '../../../shared/view/ui'; + import FileTreeBody from './FileTreeBody'; import FileTreeDetailedColumns from './FileTreeDetailedColumns'; import FileTreeHeader from './FileTreeHeader'; import FileTreeLoadingState from './FileTreeLoadingState'; +import FileTreeUploadProgress from './FileTreeUploadProgress'; import ImageViewer from './ImageViewer'; @@ -66,6 +69,7 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps) onRefresh: refreshFiles, showToast, }); + const operationLoading = operations.operationLoading || upload.operationLoading; // Focus input when creating new item useEffect(() => { @@ -146,14 +150,19 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps) onViewModeChange={changeViewMode} searchQuery={searchQuery} onSearchQueryChange={setSearchQuery} + onUploadFiles={upload.handleFileSelect} onNewFile={() => operations.handleStartCreate('', 'file')} onNewFolder={() => operations.handleStartCreate('', 'directory')} onRefresh={refreshFiles} onCollapseAll={collapseAll} loading={loading} - operationLoading={operations.operationLoading} + operationLoading={operationLoading} + isUploading={upload.uploadProgress?.status === 'uploading'} + uploadProgress={upload.uploadProgress?.progress ?? null} /> + + {viewMode === 'detailed' && filteredFiles.length > 0 && } @@ -184,7 +193,7 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps) }, 100); }} className="h-6 flex-1 text-sm" - disabled={operations.operationLoading} + disabled={operationLoading} /> )} @@ -213,7 +222,7 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps) handleConfirmRename={operations.handleConfirmRename} handleCancelRename={operations.handleCancelRename} renameInputRef={renameInputRef} - operationLoading={operations.operationLoading} + operationLoading={operationLoading} /> @@ -251,17 +260,17 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
diff --git a/src/components/file-tree/view/FileTreeHeader.tsx b/src/components/file-tree/view/FileTreeHeader.tsx index 80516215..db2bd2f8 100644 --- a/src/components/file-tree/view/FileTreeHeader.tsx +++ b/src/components/file-tree/view/FileTreeHeader.tsx @@ -1,7 +1,11 @@ -import { ChevronDown, Eye, FileText, FolderPlus, List, RefreshCw, Search, TableProperties, X } from 'lucide-react'; +import { useRef } from 'react'; +import type { ChangeEvent } from 'react'; +import { ChevronDown, Eye, FileText, FolderPlus, List, Loader2, RefreshCw, Search, TableProperties, Upload, X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; + import { Button, Input } from '../../../shared/view/ui'; import { cn } from '../../../lib/utils'; +import { MAX_FILE_UPLOAD_SIZE_LABEL } from '../constants/constants'; import type { FileTreeViewMode } from '../types/types'; type FileTreeHeaderProps = { @@ -12,11 +16,14 @@ type FileTreeHeaderProps = { // Toolbar actions onNewFile?: () => void; onNewFolder?: () => void; + onUploadFiles?: (files: FileList) => void; onRefresh?: () => void; onCollapseAll?: () => void; // Loading state loading?: boolean; operationLoading?: boolean; + isUploading?: boolean; + uploadProgress?: number | null; }; export default function FileTreeHeader({ @@ -26,12 +33,24 @@ export default function FileTreeHeader({ onSearchQueryChange, onNewFile, onNewFolder, + onUploadFiles, onRefresh, onCollapseAll, loading, operationLoading, + isUploading, + uploadProgress, }: FileTreeHeaderProps) { const { t } = useTranslation(); + const uploadInputRef = useRef(null); + + const handleUploadInputChange = (event: ChangeEvent) => { + const { files } = event.target; + if (files && files.length > 0) { + onUploadFiles?.(files); + } + event.target.value = ''; + }; return (
@@ -40,6 +59,50 @@ export default function FileTreeHeader({

{t('fileTree.files')}

{/* Action buttons */} + {onUploadFiles && ( + <> + + + + )} {onNewFile && ( +
+

{t('notifications.events.title')}

diff --git a/src/i18n/locales/de/settings.json b/src/i18n/locales/de/settings.json index 6358c708..d2189e83 100644 --- a/src/i18n/locales/de/settings.json +++ b/src/i18n/locales/de/settings.json @@ -97,6 +97,14 @@ "plugins": "Plugins", "about": "Info" }, + "notifications": { + "sound": { + "title": "Ton", + "description": "Spielt einen kurzen Ton ab, wenn ein Chat-Lauf abgeschlossen ist.", + "enabled": "Aktiviert", + "test": "Ton testen" + } + }, "appearanceSettings": { "darkMode": { "label": "Darkmode", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index b80d17d2..c663638e 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -110,6 +110,12 @@ "unsupported": "Push notifications are not supported in this browser.", "denied": "Push notifications are blocked. Please allow them in your browser settings." }, + "sound": { + "title": "Sound", + "description": "Play a short tone when a chat run finishes.", + "enabled": "Enabled", + "test": "Test sound" + }, "events": { "title": "Event Types", "actionRequired": "Action required", diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index 670798ec..d283bdb9 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -110,6 +110,12 @@ "unsupported": "Le notifiche push non sono supportate in questo browser.", "denied": "Le notifiche push sono bloccate. Abilitale nelle impostazioni del browser." }, + "sound": { + "title": "Suono", + "description": "Riproduci un breve tono quando termina un'esecuzione della chat.", + "enabled": "Attivato", + "test": "Prova suono" + }, "events": { "title": "Tipi di evento", "actionRequired": "Azione richiesta", diff --git a/src/i18n/locales/ja/settings.json b/src/i18n/locales/ja/settings.json index 53cec8d1..0ad10c46 100644 --- a/src/i18n/locales/ja/settings.json +++ b/src/i18n/locales/ja/settings.json @@ -110,6 +110,12 @@ "unsupported": "このブラウザではプッシュ通知がサポートされていません。", "denied": "プッシュ通知がブロックされています。ブラウザの設定で許可してください。" }, + "sound": { + "title": "サウンド", + "description": "チャット実行が完了したときに短い音を再生します。", + "enabled": "有効", + "test": "サウンドをテスト" + }, "events": { "title": "イベント種別", "actionRequired": "対応が必要", diff --git a/src/i18n/locales/ko/settings.json b/src/i18n/locales/ko/settings.json index 0d3d2d30..3fd7a285 100644 --- a/src/i18n/locales/ko/settings.json +++ b/src/i18n/locales/ko/settings.json @@ -110,6 +110,12 @@ "unsupported": "이 브라우저에서는 푸시 알림이 지원되지 않습니다.", "denied": "푸시 알림이 차단되었습니다. 브라우저 설정에서 허용해 주세요." }, + "sound": { + "title": "소리", + "description": "채팅 실행이 완료되면 짧은 알림음을 재생합니다.", + "enabled": "사용", + "test": "소리 테스트" + }, "events": { "title": "이벤트 유형", "actionRequired": "작업 필요", diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index 98944876..109161f8 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -97,6 +97,14 @@ "plugins": "Плагины", "about": "О программе" }, + "notifications": { + "sound": { + "title": "Звук", + "description": "Воспроизводить короткий сигнал при завершении запуска чата.", + "enabled": "Включено", + "test": "Проверить звук" + } + }, "appearanceSettings": { "darkMode": { "label": "Темная тема", diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index 662b177c..01763542 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -110,6 +110,12 @@ "unsupported": "Bu tarayıcıda push bildirimleri desteklenmiyor.", "denied": "Push bildirimleri engellendi. Lütfen tarayıcı ayarlarından izin ver." }, + "sound": { + "title": "Ses", + "description": "Sohbet çalışması tamamlandığında kısa bir ton çal.", + "enabled": "Etkin", + "test": "Sesi test et" + }, "events": { "title": "Etkinlik Türleri", "actionRequired": "Aksiyon gerekli", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index cfcc8ee2..5567695c 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -110,6 +110,12 @@ "unsupported": "此浏览器不支持推送通知。", "denied": "推送通知已被阻止,请在浏览器设置中允许。" }, + "sound": { + "title": "声音", + "description": "聊天运行完成时播放短提示音。", + "enabled": "已启用", + "test": "测试声音" + }, "events": { "title": "事件类型", "actionRequired": "需要处理", diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index d38aa931..4fe469bd 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -110,6 +110,12 @@ "unsupported": "此瀏覽器不支援推播通知。", "denied": "推播通知已被封鎖,請在瀏覽器設定中允許。" }, + "sound": { + "title": "聲音", + "description": "聊天執行完成時播放短提示音。", + "enabled": "已啟用", + "test": "測試聲音" + }, "events": { "title": "事件類型", "actionRequired": "需要處理", diff --git a/src/utils/notificationSound.ts b/src/utils/notificationSound.ts new file mode 100644 index 00000000..78af2d99 --- /dev/null +++ b/src/utils/notificationSound.ts @@ -0,0 +1,83 @@ +const NOTIFICATION_SOUND_ENABLED_STORAGE_KEY = 'notificationSoundEnabled'; +const AudioContextConstructor = + typeof window !== 'undefined' + ? window.AudioContext || (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext + : undefined; + +let audioContext: AudioContext | null = null; + +export const isNotificationSoundEnabled = (): boolean => { + if (typeof localStorage === 'undefined') { + return true; + } + + return localStorage.getItem(NOTIFICATION_SOUND_ENABLED_STORAGE_KEY) !== 'false'; +}; + +export const setNotificationSoundEnabled = (enabled: boolean): void => { + if (typeof localStorage === 'undefined') { + return; + } + + localStorage.setItem(NOTIFICATION_SOUND_ENABLED_STORAGE_KEY, String(enabled)); +}; + +const getAudioContext = (): AudioContext | null => { + if (!AudioContextConstructor) { + return null; + } + + if (!audioContext) { + audioContext = new AudioContextConstructor(); + } + + return audioContext; +}; + +const playTone = ( + context: AudioContext, + frequency: number, + startsAt: number, + duration: number, + peakVolume: number, +): void => { + const oscillator = context.createOscillator(); + const gain = context.createGain(); + + oscillator.type = 'sine'; + oscillator.frequency.setValueAtTime(frequency, startsAt); + + // Shape the volume so the synthesized tone starts and stops cleanly. + gain.gain.setValueAtTime(0.0001, startsAt); + gain.gain.exponentialRampToValueAtTime(peakVolume, startsAt + 0.015); + gain.gain.exponentialRampToValueAtTime(0.0001, startsAt + duration); + + oscillator.connect(gain); + gain.connect(context.destination); + oscillator.start(startsAt); + oscillator.stop(startsAt + duration + 0.02); +}; + +export const playChatCompletionSound = async ({ force = false } = {}): Promise => { + if (!force && !isNotificationSoundEnabled()) { + return; + } + + const context = getAudioContext(); + if (!context) { + return; + } + + try { + if (context.state === 'suspended') { + await context.resume(); + } + + const now = context.currentTime; + playTone(context, 740, now, 0.12, 0.075); + playTone(context, 988, now + 0.11, 0.16, 0.06); + } catch (error) { + // Browsers may block audio until the page receives a user gesture. + console.warn('Unable to play notification sound:', error); + } +}; diff --git a/src/utils/pageTitleNotification.ts b/src/utils/pageTitleNotification.ts new file mode 100644 index 00000000..07f00b6b --- /dev/null +++ b/src/utils/pageTitleNotification.ts @@ -0,0 +1,86 @@ +const COMPLETION_TITLE_INDICATOR = '[Done]'; +const TITLE_INDICATOR_CLEAR_DELAY_MS = 2000; + +let clearTimer: number | null = null; +let returnListenersAttached = false; + +const getIndicatorPrefix = () => `${COMPLETION_TITLE_INDICATOR} `; + +const stripIndicator = (title: string): string => { + const prefix = getIndicatorPrefix(); + return title.startsWith(prefix) ? title.slice(prefix.length) : title; +}; + +const pageIsActive = (): boolean => ( + document.visibilityState === 'visible' && document.hasFocus() +); + +const removeReturnListeners = (): void => { + if (!returnListenersAttached || typeof window === 'undefined') { + return; + } + + document.removeEventListener('visibilitychange', handleUserReturn); + window.removeEventListener('focus', handleUserReturn, true); + window.removeEventListener('click', handleUserReturn, true); + returnListenersAttached = false; +}; + +const clearTitleIndicator = (): void => { + if (clearTimer !== null) { + window.clearTimeout(clearTimer); + clearTimer = null; + } + + removeReturnListeners(); + + if (document.title.startsWith(getIndicatorPrefix())) { + document.title = stripIndicator(document.title); + } +}; + +const scheduleClear = (): void => { + if (clearTimer !== null) { + window.clearTimeout(clearTimer); + } + + clearTimer = window.setTimeout(() => { + clearTitleIndicator(); + }, TITLE_INDICATOR_CLEAR_DELAY_MS); +}; + +function handleUserReturn(): void { + if (!pageIsActive()) { + return; + } + + // Background completions keep the marker indefinitely. A tab click normally + // surfaces as visibility/focus, while an in-page click is a useful fallback. + scheduleClear(); +} + +export const showCompletionTitleIndicator = (): void => { + if (typeof document === 'undefined' || typeof window === 'undefined') { + return; + } + + const baseTitle = stripIndicator(document.title || 'CloudCLI UI'); + document.title = `${getIndicatorPrefix()}${baseTitle}`; + + if (pageIsActive()) { + scheduleClear(); + return; + } + + if (clearTimer !== null) { + window.clearTimeout(clearTimer); + clearTimer = null; + } + + if (!returnListenersAttached) { + document.addEventListener('visibilitychange', handleUserReturn); + window.addEventListener('focus', handleUserReturn, true); + window.addEventListener('click', handleUserReturn, true); + returnListenersAttached = true; + } +}; From ca8fd0ee235b6a3210157bd0d9af83024d4a2248 Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:44:42 +0300 Subject: [PATCH 08/21] fix: align prism plugin name and id with manifest.json --- src/components/plugins/view/PluginSettingsTab.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/plugins/view/PluginSettingsTab.tsx b/src/components/plugins/view/PluginSettingsTab.tsx index 8ebf3dbf..f668e30b 100644 --- a/src/components/plugins/view/PluginSettingsTab.tsx +++ b/src/components/plugins/view/PluginSettingsTab.tsx @@ -74,10 +74,10 @@ const UNOFFICIAL_PLUGIN_RECOMMENDATIONS: PluginRecommendation[] = [ source: 'unofficial', }, { - id: 'prism-cloudcli', + id: 'prism', translationKey: 'prismCloudCLI', repoUrl: PRISM_CLOUDCLI_PLUGIN_URL, - installedNames: ['prism-cloudcli'], + installedNames: ['prism'], icon: Activity, source: 'unofficial' } From f7c0024fe15057ad049c71e15e88adb482a4497f Mon Sep 17 00:00:00 2001 From: szmidtpiotr <91347162+szmidtpiotr@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:56:31 +0200 Subject: [PATCH 09/21] fix: slash command suggestions trigger at any / in input, not only at start (#843) Previously the regex ^\/(\S*)$ only matched when the entire text before the cursor was a bare /command. Typing a slash mid-sentence (e.g. "please run /he") produced no suggestions. Changed pattern to (?:^|\s)(\/\S*)$ which matches / at the start of input or after any whitespace. Also compute slashPos from match.index instead of hardcoding 0, so insertCommandIntoInput replaces the correct slice of the input when the command is mid-sentence. Co-authored-by: Claude Sonnet 4.6 --- src/components/chat/hooks/useSlashCommands.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/chat/hooks/useSlashCommands.ts b/src/components/chat/hooks/useSlashCommands.ts index db6eefaa..b64ac6e0 100644 --- a/src/components/chat/hooks/useSlashCommands.ts +++ b/src/components/chat/hooks/useSlashCommands.ts @@ -393,7 +393,8 @@ export function useSlashCommands({ return; } - const slashPattern = /^\/(\S*)$/; + // Match / at start of input OR after whitespace, capturing the /word up to cursor. + const slashPattern = /(?:^|\s)(\/\S*)$/; const match = textBeforeCursor.match(slashPattern); if (!match) { @@ -401,8 +402,9 @@ export function useSlashCommands({ return; } - const slashPos = 0; - const query = match[1]; + // Compute actual position of / in the full input string. + const slashPos = match.index! + (match[0].length - match[1].length); + const query = match[1].slice(1); // strip leading / setSlashPosition(slashPos); setShowCommandMenu(true); From 33a4e72ca4f84df60aadfc4ff3f3467d6f5ae948 Mon Sep 17 00:00:00 2001 From: ShockStruck Date: Sun, 24 May 2026 18:28:05 -0400 Subject: [PATCH 10/21] fix(chat): re-anchor initial scroll across lazy content reflow The previous initial-scroll behavior fired one scrollToBottom() at +200ms after the session load and cleared the pending flag. When markdown, syntax highlighting, or images finished rendering after that window, scrollHeight grew but nothing re-anchored the viewport. The chat tab appeared "scrolled way up" with the latest assistant message off-screen until the user manually scrolled or sent a new message. This replaces the setTimeout with a requestAnimationFrame loop that re-scrolls every frame while scrollHeight is still growing, capped at ~1s (60 frames) or 3 consecutive stable frames. The loop cancels cleanly on session change via the existing pendingInitialScrollRef flag, and the cleanup function cancels any in-flight rAF on unmount. No behavior change for sessions whose content layout is already stable at the first frame. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../chat/hooks/useChatSessionState.ts | 41 +++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/src/components/chat/hooks/useChatSessionState.ts b/src/components/chat/hooks/useChatSessionState.ts index 20f42551..d11ff3cb 100644 --- a/src/components/chat/hooks/useChatSessionState.ts +++ b/src/components/chat/hooks/useChatSessionState.ts @@ -383,12 +383,47 @@ export function useChatSessionState({ setIsUserScrolledUp(false); }, [selectedProject?.projectId, selectedSession?.id]); - // Initial scroll to bottom + // Initial scroll to bottom — robust to lazy content reflow. + // The previous implementation fired one scrollToBottom() at +200ms and + // cleared the pending flag. When markdown blocks, code highlighting, or + // images finished rendering after that window, scrollHeight grew but + // nothing re-anchored the viewport, leaving the chat tab visually + // "scrolled way up" with the latest assistant message off-screen. + // + // This version re-scrolls every animation frame while scrollHeight is + // still growing, capped at ~1s (60 frames) or 3 consecutive stable + // frames. Cancels cleanly on session change via the pending flag. useEffect(() => { if (!pendingInitialScrollRef.current || !scrollContainerRef.current || isLoadingSessionMessages) return; if (chatMessages.length === 0) { pendingInitialScrollRef.current = false; return; } - pendingInitialScrollRef.current = false; - if (!searchScrollActiveRef.current) setTimeout(() => scrollToBottom(), 200); + if (searchScrollActiveRef.current) { pendingInitialScrollRef.current = false; return; } + + const container = scrollContainerRef.current; + let frame = 0; + let lastHeight = 0; + let stableCount = 0; + let rafId = 0; + + const tick = () => { + if (!pendingInitialScrollRef.current || !scrollContainerRef.current) return; + container.scrollTop = container.scrollHeight; + if (container.scrollHeight === lastHeight) { + stableCount++; + } else { + stableCount = 0; + lastHeight = container.scrollHeight; + } + frame++; + if (stableCount < 3 && frame < 60) { + rafId = requestAnimationFrame(tick); + } else { + pendingInitialScrollRef.current = false; + } + }; + rafId = requestAnimationFrame(tick); + return () => { + if (rafId) cancelAnimationFrame(rafId); + }; }, [chatMessages.length, isLoadingSessionMessages, scrollToBottom]); // Main session loading effect — store-based From beae8c6513daa7518b9de40d8bfde3bf08e7bc87 Mon Sep 17 00:00:00 2001 From: Jake Date: Tue, 9 Jun 2026 10:38:27 -0500 Subject: [PATCH 11/21] fix: keep editor toolbar in view on long unwrapped lines --- src/components/code-editor/view/EditorSidebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/code-editor/view/EditorSidebar.tsx b/src/components/code-editor/view/EditorSidebar.tsx index 91ea9690..6b2b4668 100644 --- a/src/components/code-editor/view/EditorSidebar.tsx +++ b/src/components/code-editor/view/EditorSidebar.tsx @@ -102,7 +102,7 @@ export default function EditorSidebar({ const useFlexLayout = editorExpanded || (fillSpace && !hasManualWidth); return ( -
+
{!editorExpanded && (
Date: Tue, 9 Jun 2026 16:04:15 +0000 Subject: [PATCH 12/21] fix: address notification review feedback --- src/i18n/locales/de/settings.json | 18 ++++++++++++++++++ src/i18n/locales/ru/settings.json | 18 ++++++++++++++++++ src/utils/pageTitleNotification.ts | 26 ++++++++++++++++++++++++++ 3 files changed, 62 insertions(+) diff --git a/src/i18n/locales/de/settings.json b/src/i18n/locales/de/settings.json index d2189e83..237e5950 100644 --- a/src/i18n/locales/de/settings.json +++ b/src/i18n/locales/de/settings.json @@ -94,15 +94,33 @@ "git": "Git", "apiTokens": "API & Token", "tasks": "Aufgaben", + "notifications": "Benachrichtigungen", "plugins": "Plugins", "about": "Info" }, "notifications": { + "title": "Benachrichtigungen", + "description": "Lege fest, welche Benachrichtigungen du erhältst.", + "webPush": { + "title": "Web-Push-Benachrichtigungen", + "enable": "Push-Benachrichtigungen aktivieren", + "disable": "Push-Benachrichtigungen deaktivieren", + "enabled": "Push-Benachrichtigungen sind aktiviert", + "loading": "Wird aktualisiert...", + "unsupported": "Push-Benachrichtigungen werden in diesem Browser nicht unterstützt.", + "denied": "Push-Benachrichtigungen sind blockiert. Bitte erlaube sie in den Browsereinstellungen." + }, "sound": { "title": "Ton", "description": "Spielt einen kurzen Ton ab, wenn ein Chat-Lauf abgeschlossen ist.", "enabled": "Aktiviert", "test": "Ton testen" + }, + "events": { + "title": "Ereignistypen", + "actionRequired": "Aktion erforderlich", + "stop": "Lauf gestoppt", + "error": "Lauf fehlgeschlagen" } }, "appearanceSettings": { diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index 109161f8..94e88f37 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -94,15 +94,33 @@ "git": "Git", "apiTokens": "API и токены", "tasks": "Задачи", + "notifications": "Уведомления", "plugins": "Плагины", "about": "О программе" }, "notifications": { + "title": "Уведомления", + "description": "Управляйте тем, какие события уведомлений вы получаете.", + "webPush": { + "title": "Web Push уведомления", + "enable": "Включить Push уведомления", + "disable": "Отключить Push уведомления", + "enabled": "Push уведомления включены", + "loading": "Обновление...", + "unsupported": "Push уведомления не поддерживаются в этом браузере.", + "denied": "Push уведомления заблокированы. Разрешите их в настройках браузера." + }, "sound": { "title": "Звук", "description": "Воспроизводить короткий сигнал при завершении запуска чата.", "enabled": "Включено", "test": "Проверить звук" + }, + "events": { + "title": "Типы событий", + "actionRequired": "Требуется действие", + "stop": "Запуск остановлен", + "error": "Запуск завершился с ошибкой" } }, "appearanceSettings": { diff --git a/src/utils/pageTitleNotification.ts b/src/utils/pageTitleNotification.ts index 07f00b6b..dd786a0b 100644 --- a/src/utils/pageTitleNotification.ts +++ b/src/utils/pageTitleNotification.ts @@ -33,12 +33,17 @@ const clearTitleIndicator = (): void => { } removeReturnListeners(); + removePageInactiveListener(); if (document.title.startsWith(getIndicatorPrefix())) { document.title = stripIndicator(document.title); } }; +const removePageInactiveListener = (): void => { + document.removeEventListener('visibilitychange', handlePageInactive); +}; + const scheduleClear = (): void => { if (clearTimer !== null) { window.clearTimeout(clearTimer); @@ -47,6 +52,9 @@ const scheduleClear = (): void => { clearTimer = window.setTimeout(() => { clearTitleIndicator(); }, TITLE_INDICATOR_CLEAR_DELAY_MS); + + removePageInactiveListener(); + document.addEventListener('visibilitychange', handlePageInactive, { once: true }); }; function handleUserReturn(): void { @@ -59,6 +67,24 @@ function handleUserReturn(): void { scheduleClear(); } +function handlePageInactive(): void { + if (document.visibilityState !== 'hidden') { + return; + } + + if (clearTimer !== null) { + window.clearTimeout(clearTimer); + clearTimer = null; + } + + if (!returnListenersAttached) { + document.addEventListener('visibilitychange', handleUserReturn); + window.addEventListener('focus', handleUserReturn, true); + window.addEventListener('click', handleUserReturn, true); + returnListenersAttached = true; + } +} + export const showCompletionTitleIndicator = (): void => { if (typeof document === 'undefined' || typeof window === 'undefined') { return; From 276639099ba5d527e68f97211efaeac30f166280 Mon Sep 17 00:00:00 2001 From: viper151 Date: Tue, 9 Jun 2026 16:18:59 +0000 Subject: [PATCH 13/21] chore(release): v1.33.3 --- CHANGELOG.md | 21 +++++++++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ce504b8..f981486c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,27 @@ All notable changes to CloudCLI UI will be documented in this file. +## [1.33.3](https://github.com/siteboon/claudecodeui/compare/v1.33.2...v1.33.3) (2026-06-09) + +### New Features + +* add file tree upload progress ([c235b05](https://github.com/siteboon/claudecodeui/commit/c235b05e1d3b626667dba4043b685512e3cd3d5d)) +* signal when chat runs complete ([d70dc07](https://github.com/siteboon/claudecodeui/commit/d70dc077bfbbfcf2ff4fa5514fabf7b4485861fa)) + +### Bug Fixes + +* address notification review feedback ([602e6ad](https://github.com/siteboon/claudecodeui/commit/602e6ad4acba612a7ea66fb3bc7485054f5675ee)) +* align prism plugin name and id with manifest.json ([ca8fd0e](https://github.com/siteboon/claudecodeui/commit/ca8fd0ee235b6a3210157bd0d9af83024d4a2248)) +* **chat:** re-anchor initial scroll across lazy content reflow ([33a4e72](https://github.com/siteboon/claudecodeui/commit/33a4e72ca4f84df60aadfc4ff3f3467d6f5ae948)) +* keep editor toolbar in view on long unwrapped lines ([beae8c6](https://github.com/siteboon/claudecodeui/commit/beae8c6513daa7518b9de40d8bfde3bf08e7bc87)) +* **sandbox:** prevent server SIGHUP on sbx exec exit ([#792](https://github.com/siteboon/claudecodeui/issues/792)) ([f4a1614](https://github.com/siteboon/claudecodeui/commit/f4a1614a0a4ab4b65e8368d5e4221f015cb7555d)), closes [#791](https://github.com/siteboon/claudecodeui/issues/791) +* slash command suggestions trigger at any / in input, not only at start ([#843](https://github.com/siteboon/claudecodeui/issues/843)) ([f7c0024](https://github.com/siteboon/claudecodeui/commit/f7c0024fe15057ad049c71e15e88adb482a4497f)) +* update naming convention ([3cd8995](https://github.com/siteboon/claudecodeui/commit/3cd89956ba06f0fc3e17d349b0c50baab4012658)) + +### Maintenance + +* add prism plugin ([01dbe2a](https://github.com/siteboon/claudecodeui/commit/01dbe2a8bfcb3b265995f01f905b218d5f576f7b)) + ## [1.33.2](https://github.com/siteboon/claudecodeui/compare/v1.33.1...v1.33.2) (2026-06-08) ### New Features diff --git a/package-lock.json b/package-lock.json index 0cd364b1..369d66e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cloudcli-ai/cloudcli", - "version": "1.33.2", + "version": "1.33.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cloudcli-ai/cloudcli", - "version": "1.33.2", + "version": "1.33.3", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "dependencies": { diff --git a/package.json b/package.json index 93a358d4..15325c24 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cloudcli-ai/cloudcli", - "version": "1.33.2", + "version": "1.33.3", "description": "A web-based UI for Claude Code CLI", "type": "module", "main": "dist-server/server/index.js", From ce327b6fa9329aa3e9a3a1da7225ca01d3b06ac5 Mon Sep 17 00:00:00 2001 From: Simos Mikelatos Date: Tue, 9 Jun 2026 20:33:00 +0000 Subject: [PATCH 14/21] feat: adding Fable 5 in claude code --- public/modelConstants.js | 6 ++++++ server/claude-sdk.js | 2 +- .../modules/providers/list/claude/claude-models.provider.ts | 5 +++++ server/routes/agent.js | 2 +- 4 files changed, 13 insertions(+), 2 deletions(-) diff --git a/public/modelConstants.js b/public/modelConstants.js index f05575c4..3fda71bc 100644 --- a/public/modelConstants.js +++ b/public/modelConstants.js @@ -14,6 +14,12 @@ export const CLAUDE_MODELS = { description: "Use the default model (currently Opus 4.8 (1M context)) · $5/$25 per Mtok", }, + { + value: "fable", + label: "Fable", + description: + "Fable 5 · Most capable for your hardest and longest-running tasks · Uses your limits ~2× faster than Opus", + }, { value: "sonnet", label: "Sonnet", diff --git a/server/claude-sdk.js b/server/claude-sdk.js index e59e503d..597e5f47 100644 --- a/server/claude-sdk.js +++ b/server/claude-sdk.js @@ -204,7 +204,7 @@ function mapCliOptionsToSDK(options = {}) { sdkOptions.disallowedTools = settings.disallowedTools || []; // Map model (default to sonnet) - // Valid models: sonnet, opus, haiku, opusplan, sonnet[1m] + // Valid models: sonnet, opus, haiku, opusplan, sonnet[1m], fable sdkOptions.model = options.model || CLAUDE_FALLBACK_MODELS.DEFAULT; // Model logged at query start below diff --git a/server/modules/providers/list/claude/claude-models.provider.ts b/server/modules/providers/list/claude/claude-models.provider.ts index b1b6ba02..f6c4c0c6 100644 --- a/server/modules/providers/list/claude/claude-models.provider.ts +++ b/server/modules/providers/list/claude/claude-models.provider.ts @@ -20,6 +20,11 @@ export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = { label: 'Default (recommended)', description: 'Use the default model (currently Opus 4.8 (1M context)) · $5/$25 per Mtok', }, + { + value: 'fable', + label: 'Fable', + description: 'Fable 5 · Most capable for your hardest and longest-running tasks · Uses your limits ~2× faster than Opus', + }, { value: "sonnet", label: "Sonnet", diff --git a/server/routes/agent.js b/server/routes/agent.js index 85e26c9d..f2273181 100644 --- a/server/routes/agent.js +++ b/server/routes/agent.js @@ -646,7 +646,7 @@ class ResponseCollector { * * @param {string} model - (Optional) Model identifier for providers. * - * Claude models: 'sonnet' (default), 'opus', 'haiku', 'opusplan', 'sonnet[1m]' + * Claude models: 'sonnet' (default), 'opus', 'haiku', 'opusplan', 'sonnet[1m]', 'fable' * Cursor models: 'gpt-5' (default), 'gpt-5.2', 'gpt-5.2-high', 'sonnet-4.5', 'opus-4.5', * 'gemini-3-pro', 'composer-1', 'auto', 'gpt-5.1', 'gpt-5.1-high', * 'gpt-5.1-codex', 'gpt-5.1-codex-high', 'gpt-5.1-codex-max', From b6a45b3183c9b4b79544aabd46f1e7f32d827154 Mon Sep 17 00:00:00 2001 From: viper151 Date: Tue, 9 Jun 2026 20:34:48 +0000 Subject: [PATCH 15/21] chore(release): v1.34.0 --- CHANGELOG.md | 6 ++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f981486c..10a218ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to CloudCLI UI will be documented in this file. +## [](https://github.com/siteboon/claudecodeui/compare/v1.33.3...vnull) (2026-06-09) + +### New Features + +* adding Fable 5 in claude code ([ce327b6](https://github.com/siteboon/claudecodeui/commit/ce327b6fa9329aa3e9a3a1da7225ca01d3b06ac5)) + ## [1.33.3](https://github.com/siteboon/claudecodeui/compare/v1.33.2...v1.33.3) (2026-06-09) ### New Features diff --git a/package-lock.json b/package-lock.json index 369d66e2..3223ea93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cloudcli-ai/cloudcli", - "version": "1.33.3", + "version": "1.34.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cloudcli-ai/cloudcli", - "version": "1.33.3", + "version": "1.34.0", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "dependencies": { diff --git a/package.json b/package.json index 15325c24..3f4ffaa0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cloudcli-ai/cloudcli", - "version": "1.33.3", + "version": "1.34.0", "description": "A web-based UI for Claude Code CLI", "type": "module", "main": "dist-server/server/index.js", From 92de0ed6137bf4571056deb3b930cc9fd22e2a08 Mon Sep 17 00:00:00 2001 From: Simos Mikelatos Date: Tue, 9 Jun 2026 20:51:19 +0000 Subject: [PATCH 16/21] chore: remove unused modelConstants from the project --- README.de.md | 2 +- README.ko.md | 2 +- README.md | 2 +- README.ru.md | 2 +- README.tr.md | 2 +- README.zh-CN.md | 2 +- README.zh-TW.md | 2 +- package.json | 1 - public/api-docs.html | 54 +- public/modelConstants.js | 854 ----------------------------- redirect-package/README.md | 2 +- server/modules/providers/README.md | 2 +- server/shared/types.ts | 2 +- 13 files changed, 46 insertions(+), 883 deletions(-) delete mode 100644 public/modelConstants.js diff --git a/README.de.md b/README.de.md index 8532a3a3..4215c942 100644 --- a/README.de.md +++ b/README.de.md @@ -62,7 +62,7 @@ - **Sitzungsverwaltung** – Gespräche fortsetzen, mehrere Sitzungen verwalten und Verlauf nachverfolgen - **Plugin-System** – CloudCLI mit eigenen Plugins erweitern – neue Tabs, Backend-Dienste und Integrationen hinzufügen. [Eigenes Plugin erstellen →](https://github.com/cloudcli-ai/cloudcli-plugin-starter) - **TaskMaster AI Integration** *(Optional)* – Erweitertes Projektmanagement mit KI-gestützter Aufgabenplanung, PRD-Parsing und Workflow-Automatisierung -- **Modell-Kompatibilität** – Funktioniert mit Claude, GPT und Gemini (vollständige Liste unterstützter Modelle in [`public/modelConstants.js`](public/modelConstants.js)) +- **Modell-Kompatibilität** – Funktioniert mit Claude, GPT und Gemini (vollständige Liste unterstützter Modelle zur Laufzeit über `GET /api/providers/:provider/models`) ## Schnellstart diff --git a/README.ko.md b/README.ko.md index b6f6e7e7..422bfe67 100644 --- a/README.ko.md +++ b/README.ko.md @@ -60,7 +60,7 @@ - **세션 관리** - 대화를 재개하고, 여러 세션을 관리하며 기록을 추적 - **플러그인 시스템** - 커스텀 탭, 백엔드 서비스, 통합을 추가하여 CloudCLI 확장. [직접 빌드 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter) - **TaskMaster AI 통합** *(선택사항)* - AI 중심의 작업 계획, PRD 파싱, 워크플로 자동화를 통한 고급 프로젝트 관리 -- **모델 호환성** - Claude, GPT, Gemini 모델 계열에서 작동 (`public/modelConstants.js`에서 전체 지원 모델 확인) +- **모델 호환성** - Claude, GPT, Gemini 모델 계열에서 작동 (`GET /api/providers/:provider/models` API에서 전체 지원 모델 확인) ## 빠른 시작 diff --git a/README.md b/README.md index 281e4327..abbfe3ef 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ - **Session Management** - Resume conversations, manage multiple sessions, and track history - **Plugin System** - Extend CloudCLI with custom plugins — add new tabs, backend services, and integrations. [Build your own →](https://github.com/cloudcli-ai/cloudcli-plugin-starter) - **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation -- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (see [`public/modelConstants.js`](public/modelConstants.js) for the full list of supported models) +- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (the full list of supported models is available at runtime via `GET /api/providers/:provider/models`) ## Quick Start diff --git a/README.ru.md b/README.ru.md index ff268015..b4f4ecd2 100644 --- a/README.ru.md +++ b/README.ru.md @@ -62,7 +62,7 @@ - **Управление сессиями** - возобновляйте диалоги, управляйте несколькими сессиями и отслеживайте историю - **Система плагинов** - расширяйте CloudCLI кастомными плагинами — добавляйте новые вкладки, бэкенд-сервисы и интеграции. [Создать свой →](https://github.com/cloudcli-ai/cloudcli-plugin-starter) - **Интеграция с TaskMaster AI** *(опционально)* - продвинутое управление проектами с планированием задач на базе AI, разбором PRD и автоматизацией workflow -- **Совместимость с моделями** - работает с семействами моделей Claude, GPT и Gemini (см. [`public/modelConstants.js`](public/modelConstants.js) для полного списка поддерживаемых моделей) +- **Совместимость с моделями** - работает с семействами моделей Claude, GPT и Gemini (полный список поддерживаемых моделей доступен через `GET /api/providers/:provider/models`) ## Быстрый старт diff --git a/README.tr.md b/README.tr.md index f5a155d6..ab6afa32 100644 --- a/README.tr.md +++ b/README.tr.md @@ -62,7 +62,7 @@ - **Oturum Yönetimi** — Konuşmalara devam et, birden fazla oturumu yönet ve geçmişi takip et - **Eklenti Sistemi** — CloudCLI'ı özel eklentilerle genişlet: yeni sekmeler, arka uç servisleri ve entegrasyonlar ekle. [Kendi eklentini yaz →](https://github.com/cloudcli-ai/cloudcli-plugin-starter) - **TaskMaster AI Entegrasyonu** *(İsteğe Bağlı)* — AI destekli görev planlama, PRD ayrıştırma ve iş akışı otomasyonu ile gelişmiş proje yönetimi -- **Model Uyumluluğu** — Claude, GPT ve Gemini model aileleriyle çalışır (desteklenen tüm modeller için [`public/modelConstants.js`](public/modelConstants.js) dosyasına bak) +- **Model Uyumluluğu** — Claude, GPT ve Gemini model aileleriyle çalışır (desteklenen tüm modeller için `GET /api/providers/:provider/models` API'sine bak) ## Hızlı Başlangıç diff --git a/README.zh-CN.md b/README.zh-CN.md index 457d24dd..c774cc7a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -60,7 +60,7 @@ - **会话管理** - 恢复对话、管理多个会话并跟踪历史记录 - **插件系统** - 通过自定义选项卡、后端服务与集成扩展 CloudCLI。 [开始构建 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter) - **TaskMaster AI 集成** *(可选)* - 结合 AI 任务规划、PRD 分析与工作流自动化,实现高级项目管理 -- **模型兼容性** - 支持 Claude、GPT、Gemini 模型家族(完整支持列表见 [`public/modelConstants.js`](public/modelConstants.js)) +- **模型兼容性** - 支持 Claude、GPT、Gemini 模型家族(完整支持列表可通过 `GET /api/providers/:provider/models` 接口获取) ## 快速开始 diff --git a/README.zh-TW.md b/README.zh-TW.md index f0710ff3..af578051 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -60,7 +60,7 @@ - **工作階段管理** — 恢復對話、管理多個工作階段並追蹤歷史紀錄 - **外掛系統** — 透過自訂分頁、後端服務與整合來擴充 CloudCLI。[開始建構 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter) - **TaskMaster AI 整合** *(選用)* — 結合 AI 任務規劃、PRD 分析與工作流程自動化,實現進階專案管理 -- **模型相容性** — 支援 Claude、GPT、Gemini 模型家族(完整支援列表見 [`shared/modelConstants.js`](shared/modelConstants.js)) +- **模型相容性** — 支援 Claude、GPT、Gemini 模型家族(完整支援列表可透過 `GET /api/providers/:provider/models` 介面取得) ## 快速開始 diff --git a/package.json b/package.json index 3f4ffaa0..2220c0f9 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,6 @@ "server/", "shared/", "public/api-docs.html", - "public/modelConstants.js", "dist/", "dist-server/", "scripts/", diff --git a/public/api-docs.html b/public/api-docs.html index 0103b5ca..03dbb9b8 100644 --- a/public/api-docs.html +++ b/public/api-docs.html @@ -820,31 +820,49 @@ data: {"type":"done"}
-