diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-context-menu/chunk-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-context-menu/chunk-context-menu.tsx index eea79e3f8..ebdf27f53 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-context-menu/chunk-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-context-menu/chunk-context-menu.tsx @@ -39,11 +39,24 @@ interface ChunkContextMenuProps { * Whether add chunk is disabled */ disableAddChunk?: boolean + /** + * Number of selected chunks (for batch operations) + */ + selectedCount?: number + /** + * Number of enabled chunks in selection + */ + enabledCount?: number + /** + * Number of disabled chunks in selection + */ + disabledCount?: number } /** * Context menu for chunks table. * Shows chunk actions when right-clicking a row, or "Create chunk" when right-clicking empty space. + * Supports batch operations when multiple chunks are selected. */ export function ChunkContextMenu({ isOpen, @@ -61,7 +74,20 @@ export function ChunkContextMenu({ disableToggleEnabled = false, disableDelete = false, disableAddChunk = false, + selectedCount = 1, + enabledCount = 0, + disabledCount = 0, }: ChunkContextMenuProps) { + const isMultiSelect = selectedCount > 1 + + const getToggleLabel = () => { + if (isMultiSelect) { + if (disabledCount > 0) return 'Enable' + return 'Disable' + } + return isChunkEnabled ? 'Disable' : 'Enable' + } + return ( {hasChunk ? ( <> - {onOpenInNewTab && ( + {!isMultiSelect && onOpenInNewTab && ( { onOpenInNewTab() @@ -86,7 +112,7 @@ export function ChunkContextMenu({ Open in new tab )} - {onEdit && ( + {!isMultiSelect && onEdit && ( { onEdit() @@ -96,7 +122,7 @@ export function ChunkContextMenu({ Edit )} - {onCopyContent && ( + {!isMultiSelect && onCopyContent && ( { onCopyContent() @@ -114,7 +140,7 @@ export function ChunkContextMenu({ onClose() }} > - {isChunkEnabled ? 'Disable' : 'Enable'} + {getToggleLabel()} )} {onDelete && ( diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx index 87117ebbd..d6675928e 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -15,6 +15,7 @@ import { } from 'lucide-react' import { useParams, useRouter, useSearchParams } from 'next/navigation' import { + Badge, Breadcrumb, Button, Checkbox, @@ -107,14 +108,31 @@ interface DocumentProps { documentName?: string } -function getStatusBadgeStyles(enabled: boolean) { - return enabled - ? 'inline-flex items-center rounded-md bg-green-100 px-2 py-1 text-xs font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400' - : 'inline-flex items-center rounded-md bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300' -} - -function truncateContent(content: string, maxLength = 150): string { +function truncateContent(content: string, maxLength = 150, searchQuery = ''): string { if (content.length <= maxLength) return content + + if (searchQuery.trim()) { + const searchTerms = searchQuery + .trim() + .split(/\s+/) + .filter((term) => term.length > 0) + .map((term) => term.toLowerCase()) + + for (const term of searchTerms) { + const matchIndex = content.toLowerCase().indexOf(term) + if (matchIndex !== -1) { + const contextBefore = 30 + const start = Math.max(0, matchIndex - contextBefore) + const end = Math.min(content.length, start + maxLength) + + let result = content.substring(start, end) + if (start > 0) result = `...${result}` + if (end < content.length) result = `${result}...` + return result + } + } + } + return `${content.substring(0, maxLength)}...` } @@ -655,13 +673,21 @@ export function Document({ /** * Handle right-click on a chunk row + * If right-clicking on an unselected chunk, select only that chunk + * If right-clicking on a selected chunk with multiple selections, keep all selections */ const handleChunkContextMenu = useCallback( (e: React.MouseEvent, chunk: ChunkData) => { + const isCurrentlySelected = selectedChunks.has(chunk.id) + + if (!isCurrentlySelected) { + setSelectedChunks(new Set([chunk.id])) + } + setContextMenuChunk(chunk) baseHandleContextMenu(e) }, - [baseHandleContextMenu] + [selectedChunks, baseHandleContextMenu] ) /** @@ -946,106 +972,114 @@ export function Document({ ) : ( - displayChunks.map((chunk: ChunkData) => ( - handleChunkClick(chunk)} - onContextMenu={(e) => handleChunkContextMenu(e, chunk)} - > - { + const isSelected = selectedChunks.has(chunk.id) + + return ( + handleChunkClick(chunk)} + onContextMenu={(e) => handleChunkContextMenu(e, chunk)} > -
- - handleSelectChunk(chunk.id, checked as boolean) - } - disabled={!userPermissions.canEdit} - aria-label={`Select chunk ${chunk.chunkIndex}`} - onClick={(e) => e.stopPropagation()} - /> -
-
- - {chunk.chunkIndex} - - - - - - - - {chunk.tokenCount > 1000 - ? `${(chunk.tokenCount / 1000).toFixed(1)}k` - : chunk.tokenCount} - - -
- {chunk.enabled ? 'Enabled' : 'Disabled'} -
-
- -
- - - - - - {!userPermissions.canEdit - ? 'Write permission required to modify chunks' - : chunk.enabled - ? 'Disable Chunk' - : 'Enable Chunk'} - - - - - - - - {!userPermissions.canEdit - ? 'Write permission required to delete chunks' - : 'Delete Chunk'} - - -
-
-
- )) +
+ + handleSelectChunk(chunk.id, checked as boolean) + } + disabled={!userPermissions.canEdit} + aria-label={`Select chunk ${chunk.chunkIndex}`} + onClick={(e) => e.stopPropagation()} + /> +
+ + + {chunk.chunkIndex} + + + + + + + + {chunk.tokenCount > 1000 + ? `${(chunk.tokenCount / 1000).toFixed(1)}k` + : chunk.tokenCount.toLocaleString()} + + + + {chunk.enabled ? 'Enabled' : 'Disabled'} + + + +
+ + + + + + {!userPermissions.canEdit + ? 'Write permission required to modify chunks' + : chunk.enabled + ? 'Disable Chunk' + : 'Enable Chunk'} + + + + + + + + {!userPermissions.canEdit + ? 'Write permission required to delete chunks' + : 'Delete Chunk'} + + +
+
+ + ) + }) )} @@ -1206,8 +1240,11 @@ export function Document({ onClose={handleContextMenuClose} hasChunk={contextMenuChunk !== null} isChunkEnabled={contextMenuChunk?.enabled ?? true} + selectedCount={selectedChunks.size} + enabledCount={enabledCount} + disabledCount={disabledCount} onOpenInNewTab={ - contextMenuChunk + contextMenuChunk && selectedChunks.size === 1 ? () => { const url = `/workspace/${workspaceId}/knowledge/${knowledgeBaseId}/${documentId}?chunk=${contextMenuChunk.id}` window.open(url, '_blank') @@ -1215,7 +1252,7 @@ export function Document({ : undefined } onEdit={ - contextMenuChunk + contextMenuChunk && selectedChunks.size === 1 ? () => { setSelectedChunk(contextMenuChunk) setIsModalOpen(true) @@ -1223,7 +1260,7 @@ export function Document({ : undefined } onCopyContent={ - contextMenuChunk + contextMenuChunk && selectedChunks.size === 1 ? () => { navigator.clipboard.writeText(contextMenuChunk.content) } @@ -1231,12 +1268,22 @@ export function Document({ } onToggleEnabled={ contextMenuChunk && userPermissions.canEdit - ? () => handleToggleEnabled(contextMenuChunk.id) + ? selectedChunks.size > 1 + ? () => { + if (disabledCount > 0) { + handleBulkEnable() + } else { + handleBulkDisable() + } + } + : () => handleToggleEnabled(contextMenuChunk.id) : undefined } onDelete={ contextMenuChunk && userPermissions.canEdit - ? () => handleDeleteChunk(contextMenuChunk.id) + ? selectedChunks.size > 1 + ? handleBulkDelete + : () => handleDeleteChunk(contextMenuChunk.id) : undefined } onAddChunk={ diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index 67a8534cd..21c0f4d09 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' +import { useQueryClient } from '@tanstack/react-query' import { format } from 'date-fns' import { AlertCircle, @@ -47,10 +48,12 @@ import { AddDocumentsModal, BaseTagsModal, DocumentContextMenu, + RenameDocumentModal, } from '@/app/workspace/[workspaceId]/knowledge/[id]/components' import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' +import { knowledgeKeys } from '@/hooks/queries/knowledge' import { useKnowledgeBase, useKnowledgeBaseDocuments, @@ -404,6 +407,7 @@ export function KnowledgeBase({ id, knowledgeBaseName: passedKnowledgeBaseName, }: KnowledgeBaseProps) { + const queryClient = useQueryClient() const params = useParams() const workspaceId = params.workspaceId as string const { removeKnowledgeBase } = useKnowledgeBasesList(workspaceId, { enabled: false }) @@ -432,6 +436,8 @@ export function KnowledgeBase({ const [sortBy, setSortBy] = useState('uploadedAt') const [sortOrder, setSortOrder] = useState('desc') const [contextMenuDocument, setContextMenuDocument] = useState(null) + const [showRenameModal, setShowRenameModal] = useState(false) + const [documentToRename, setDocumentToRename] = useState(null) const { isOpen: isContextMenuOpen, @@ -699,6 +705,60 @@ export function KnowledgeBase({ } } + /** + * Opens the rename document modal + */ + const handleRenameDocument = (doc: DocumentData) => { + setDocumentToRename(doc) + setShowRenameModal(true) + } + + /** + * Saves the renamed document + */ + const handleSaveRename = async (documentId: string, newName: string) => { + const currentDoc = documents.find((doc) => doc.id === documentId) + const previousName = currentDoc?.filename + + updateDocument(documentId, { filename: newName }) + queryClient.setQueryData(knowledgeKeys.document(id, documentId), (previous) => + previous ? { ...previous, filename: newName } : previous + ) + + try { + const response = await fetch(`/api/knowledge/${id}/documents/${documentId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ filename: newName }), + }) + + if (!response.ok) { + const result = await response.json() + throw new Error(result.error || 'Failed to rename document') + } + + const result = await response.json() + + if (!result.success) { + throw new Error(result.error || 'Failed to rename document') + } + + logger.info(`Document renamed: ${documentId}`) + } catch (err) { + if (previousName !== undefined) { + updateDocument(documentId, { filename: previousName }) + queryClient.setQueryData( + knowledgeKeys.document(id, documentId), + (previous) => (previous ? { ...previous, filename: previousName } : previous) + ) + } + logger.error('Error renaming document:', err) + throw err + } + } + /** * Opens the delete document confirmation modal */ @@ -968,13 +1028,21 @@ export function KnowledgeBase({ /** * Handle right-click on a document row + * If right-clicking on an unselected document, select only that document + * If right-clicking on a selected document with multiple selections, keep all selections */ const handleDocumentContextMenu = useCallback( (e: React.MouseEvent, doc: DocumentData) => { + const isCurrentlySelected = selectedDocuments.has(doc.id) + + if (!isCurrentlySelected) { + setSelectedDocuments(new Set([doc.id])) + } + setContextMenuDocument(doc) baseHandleContextMenu(e) }, - [baseHandleContextMenu] + [selectedDocuments, baseHandleContextMenu] ) /** @@ -1211,7 +1279,9 @@ export function KnowledgeBase({ { if (doc.processingStatus === 'completed') { @@ -1558,6 +1628,17 @@ export function KnowledgeBase({ chunkingConfig={knowledgeBase?.chunkingConfig} /> + {/* Rename Document Modal */} + {documentToRename && ( + + )} + 0 ? handleBulkEnable : undefined} @@ -1580,8 +1661,11 @@ export function KnowledgeBase({ ? getDocumentTags(contextMenuDocument, tagDefinitions).length > 0 : false } + selectedCount={selectedDocuments.size} + enabledCount={enabledCount} + disabledCount={disabledCount} onOpenInNewTab={ - contextMenuDocument + contextMenuDocument && selectedDocuments.size === 1 ? () => { const urlParams = new URLSearchParams({ kbName: knowledgeBaseName, @@ -1594,13 +1678,26 @@ export function KnowledgeBase({ } : undefined } + onRename={ + contextMenuDocument && selectedDocuments.size === 1 && userPermissions.canEdit + ? () => handleRenameDocument(contextMenuDocument) + : undefined + } onToggleEnabled={ contextMenuDocument && userPermissions.canEdit - ? () => handleToggleEnabled(contextMenuDocument.id) + ? selectedDocuments.size > 1 + ? () => { + if (disabledCount > 0) { + handleBulkEnable() + } else { + handleBulkDisable() + } + } + : () => handleToggleEnabled(contextMenuDocument.id) : undefined } onViewTags={ - contextMenuDocument + contextMenuDocument && selectedDocuments.size === 1 ? () => { const urlParams = new URLSearchParams({ kbName: knowledgeBaseName, @@ -1614,7 +1711,9 @@ export function KnowledgeBase({ } onDelete={ contextMenuDocument && userPermissions.canEdit - ? () => handleDeleteDocument(contextMenuDocument.id) + ? selectedDocuments.size > 1 + ? handleBulkDelete + : () => handleDeleteDocument(contextMenuDocument.id) : undefined } onAddDocument={userPermissions.canEdit ? handleAddDocuments : undefined} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/document-context-menu/document-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/document-context-menu/document-context-menu.tsx index 9916442a6..ca9df6b72 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/document-context-menu/document-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/document-context-menu/document-context-menu.tsx @@ -11,6 +11,7 @@ interface DocumentContextMenuProps { * Document-specific actions (shown when right-clicking on a document) */ onOpenInNewTab?: () => void + onRename?: () => void onToggleEnabled?: () => void onViewTags?: () => void onDelete?: () => void @@ -42,11 +43,24 @@ interface DocumentContextMenuProps { * Whether add document is disabled */ disableAddDocument?: boolean + /** + * Number of selected documents (for batch operations) + */ + selectedCount?: number + /** + * Number of enabled documents in selection + */ + enabledCount?: number + /** + * Number of disabled documents in selection + */ + disabledCount?: number } /** * Context menu for documents table. * Shows document actions when right-clicking a row, or "Add Document" when right-clicking empty space. + * Supports batch operations when multiple documents are selected. */ export function DocumentContextMenu({ isOpen, @@ -54,6 +68,7 @@ export function DocumentContextMenu({ menuRef, onClose, onOpenInNewTab, + onRename, onToggleEnabled, onViewTags, onDelete, @@ -64,7 +79,20 @@ export function DocumentContextMenu({ disableToggleEnabled = false, disableDelete = false, disableAddDocument = false, + selectedCount = 1, + enabledCount = 0, + disabledCount = 0, }: DocumentContextMenuProps) { + const isMultiSelect = selectedCount > 1 + + const getToggleLabel = () => { + if (isMultiSelect) { + if (disabledCount > 0) return 'Enable' + return 'Disable' + } + return isDocumentEnabled ? 'Disable' : 'Enable' + } + return ( {hasDocument ? ( <> - {onOpenInNewTab && ( + {!isMultiSelect && onOpenInNewTab && ( { onOpenInNewTab() @@ -89,7 +117,17 @@ export function DocumentContextMenu({ Open in new tab )} - {hasTags && onViewTags && ( + {!isMultiSelect && onRename && ( + { + onRename() + onClose() + }} + > + Rename + + )} + {!isMultiSelect && hasTags && onViewTags && ( { onViewTags() @@ -107,7 +145,7 @@ export function DocumentContextMenu({ onClose() }} > - {isDocumentEnabled ? 'Disable' : 'Enable'} + {getToggleLabel()} )} {onDelete && ( diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts index 478e7911c..b0e8ca143 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts @@ -2,3 +2,4 @@ export { ActionBar } from './action-bar/action-bar' export { AddDocumentsModal } from './add-documents-modal/add-documents-modal' export { BaseTagsModal } from './base-tags-modal/base-tags-modal' export { DocumentContextMenu } from './document-context-menu' +export { RenameDocumentModal } from './rename-document-modal' diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/rename-document-modal/index.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/rename-document-modal/index.ts new file mode 100644 index 000000000..d1505e6f5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/rename-document-modal/index.ts @@ -0,0 +1 @@ +export { RenameDocumentModal } from './rename-document-modal' diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/rename-document-modal/rename-document-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/rename-document-modal/rename-document-modal.tsx new file mode 100644 index 000000000..8196bfb43 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/rename-document-modal/rename-document-modal.tsx @@ -0,0 +1,136 @@ +'use client' + +import { useEffect, useState } from 'react' +import { createLogger } from '@sim/logger' +import { + Button, + Input, + Label, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, +} from '@/components/emcn' +import { cn } from '@/lib/core/utils/cn' + +const logger = createLogger('RenameDocumentModal') + +interface RenameDocumentModalProps { + open: boolean + onOpenChange: (open: boolean) => void + documentId: string + initialName: string + onSave: (documentId: string, newName: string) => Promise +} + +/** + * Modal for renaming a document. + * Only changes the display name, not the underlying storage key. + */ +export function RenameDocumentModal({ + open, + onOpenChange, + documentId, + initialName, + onSave, +}: RenameDocumentModalProps) { + const [name, setName] = useState(initialName) + const [isSubmitting, setIsSubmitting] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + if (open) { + setName(initialName) + setError(null) + } + }, [open, initialName]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + const trimmedName = name.trim() + + if (!trimmedName) { + setError('Name is required') + return + } + + if (trimmedName === initialName) { + onOpenChange(false) + return + } + + setIsSubmitting(true) + setError(null) + + try { + await onSave(documentId, trimmedName) + onOpenChange(false) + } catch (err) { + logger.error('Error renaming document:', err) + setError(err instanceof Error ? err.message : 'Failed to rename document') + } finally { + setIsSubmitting(false) + } + } + + return ( + + + Rename Document +
+ +
+
+ + { + setName(e.target.value) + setError(null) + }} + placeholder='Enter document name' + className={cn(error && 'border-[var(--text-error)]')} + disabled={isSubmitting} + autoFocus + maxLength={255} + autoComplete='off' + autoCorrect='off' + autoCapitalize='off' + data-lpignore='true' + data-form-type='other' + /> +
+
+
+ +
+ {error ? ( +

+ {error} +

+ ) : ( +
+ )} +
+ + +
+
+ + + + + ) +} diff --git a/apps/sim/components/ui/search-highlight.tsx b/apps/sim/components/ui/search-highlight.tsx index 49fa42752..9a1322f8e 100644 --- a/apps/sim/components/ui/search-highlight.tsx +++ b/apps/sim/components/ui/search-highlight.tsx @@ -11,7 +11,6 @@ export function SearchHighlight({ text, searchQuery, className = '' }: SearchHig return {text} } - // Create regex pattern for all search terms const searchTerms = searchQuery .trim() .split(/\s+/) @@ -35,7 +34,7 @@ export function SearchHighlight({ text, searchQuery, className = '' }: SearchHig return isMatch ? ( {part}