diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/owner-cell.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/owner-cell.tsx index 63b3ee1cddc..d133301872f 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/owner-cell.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/owner-cell.tsx @@ -1,7 +1,13 @@ +import { memo } from 'react' import type { ResourceCell } from '@/app/workspace/[workspaceId]/components/resource/resource' import type { WorkspaceMember } from '@/hooks/queries/workspace' -function OwnerAvatar({ name, image }: { name: string; image: string | null }) { +interface OwnerAvatarProps { + name: string + image: string | null +} + +const OwnerAvatar = memo(function OwnerAvatar({ name, image }: OwnerAvatarProps) { if (image) { return ( ) -} +}) /** * Resolves a user ID into a ResourceCell with an avatar icon and display name. diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx index 4a63ce8ed00..68c245b1dd7 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx @@ -11,6 +11,8 @@ import { import { cn } from '@/lib/core/utils/cn' import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inline-rename-input' +const HEADER_PLUS_ICON = + export interface DropdownOption { label: string icon?: React.ElementType @@ -122,7 +124,7 @@ export const ResourceHeader = memo(function ResourceHeader({ variant='subtle' className='px-2 py-1 text-caption' > - + {HEADER_PLUS_ICON} {create.label} )} @@ -132,19 +134,21 @@ export const ResourceHeader = memo(function ResourceHeader({ ) }) -function BreadcrumbSegment({ - icon: Icon, - label, - onClick, - dropdownItems, - editing, -}: { +interface BreadcrumbSegmentProps { icon?: React.ElementType label: string onClick?: () => void dropdownItems?: DropdownOption[] editing?: BreadcrumbEditing -}) { +} + +const BreadcrumbSegment = memo(function BreadcrumbSegment({ + icon: Icon, + label, + onClick, + dropdownItems, + editing, +}: BreadcrumbSegmentProps) { if (editing?.isEditing) { return ( @@ -203,4 +207,4 @@ function BreadcrumbSegment({ {content} ) -} +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx index 5749e243e41..d16f83dc4fe 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx @@ -1,4 +1,4 @@ -import { memo, type ReactNode } from 'react' +import { memo, type ReactNode, useCallback, useRef, useState } from 'react' import * as PopoverPrimitive from '@radix-ui/react-popover' import { ArrowDown, @@ -16,6 +16,12 @@ import { } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' +const SEARCH_ICON = ( + +) +const FILTER_ICON = +const SORT_ICON = + type SortDirection = 'asc' | 'desc' export interface ColumnOption { @@ -79,56 +85,7 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({ return (
- {search && ( -
- -
- {search.tags?.map((tag, i) => ( - - ))} - search.onChange(e.target.value)} - onKeyDown={search.onKeyDown} - onFocus={search.onFocus} - onBlur={search.onBlur} - placeholder={search.tags?.length ? '' : (search.placeholder ?? 'Search...')} - className='min-w-[80px] flex-1 bg-transparent py-1 text-[var(--text-secondary)] text-caption outline-none placeholder:text-[var(--text-subtle)]' - /> -
- {search.tags?.length || search.value ? ( - - ) : null} - {search.dropdown && ( -
- {search.dropdown} -
- )} -
- )} + {search && }
{extras} {filterTags?.map((tag) => ( @@ -146,7 +103,7 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({ @@ -170,14 +127,94 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({ ) }) -function SortDropdown({ config }: { config: SortConfig }) { +const SearchSection = memo(function SearchSection({ search }: { search: SearchConfig }) { + const [localValue, setLocalValue] = useState(search.value) + + const lastReportedRef = useRef(search.value) + + if (search.value !== lastReportedRef.current) { + setLocalValue(search.value) + lastReportedRef.current = search.value + } + + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + const next = e.target.value + setLocalValue(next) + search.onChange(next) + }, + [search.onChange] + ) + + const handleClearAll = useCallback(() => { + setLocalValue('') + lastReportedRef.current = '' + if (search.onClearAll) { + search.onClearAll() + } else { + search.onChange('') + } + }, [search.onClearAll, search.onChange]) + + return ( +
+ {SEARCH_ICON} +
+ {search.tags?.map((tag, i) => ( + + ))} + +
+ {search.tags?.length || localValue ? ( + + ) : null} + {search.dropdown && ( +
+ {search.dropdown} +
+ )} +
+ ) +}) + +const SortDropdown = memo(function SortDropdown({ config }: { config: SortConfig }) { const { options, active, onSort, onClear } = config return ( @@ -218,4 +255,4 @@ function SortDropdown({ config }: { config: SortConfig }) { ) -} +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx index 6882ac89a6d..32792f1f367 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx @@ -8,6 +8,8 @@ import { ResourceHeader } from './components/resource-header' import type { FilterTag, SearchConfig, SortConfig } from './components/resource-options-bar' import { ResourceOptionsBar } from './components/resource-options-bar' +const CREATE_ROW_PLUS_ICON = + export interface ResourceColumn { id: string header: string @@ -69,11 +71,13 @@ interface ResourceProps { const EMPTY_CELL_PLACEHOLDER = '- - -' const SKELETON_ROW_COUNT = 5 +const stopPropagation = (e: React.MouseEvent) => e.stopPropagation() + /** * Shared page shell for resource list pages (tables, files, knowledge, schedules, logs). * Renders the header, toolbar with search, and a data table from column/row definitions. */ -export function Resource({ +export const Resource = memo(function Resource({ icon, title, breadcrumbs, @@ -135,7 +139,7 @@ export function Resource({ />
) -} +}) export interface ResourceTableProps { columns: ResourceColumn[] @@ -229,6 +233,13 @@ export const ResourceTable = memo(function ResourceTable({ const hasCheckbox = selectable != null const totalColSpan = columns.length + (hasCheckbox ? 1 : 0) + const handleSelectAll = useCallback( + (checked: boolean | 'indeterminate') => { + selectable?.onSelectAll(checked as boolean) + }, + [selectable] + ) + if (isLoading) { return ( selectable.onSelectAll(checked as boolean)} + onCheckedChange={handleSelectAll} disabled={selectable.disabled} aria-label='Select all' /> @@ -306,68 +317,20 @@ export const ResourceTable = memo(function ResourceTable({ - {displayRows.map((row) => { - const isSelected = selectable?.selectedIds.has(row.id) ?? false - return ( - onRowClick?.(row.id)} - onMouseEnter={onRowHover ? () => onRowHover(row.id) : undefined} - onContextMenu={(e) => onRowContextMenu?.(e, row.id)} - > - {hasCheckbox && ( - - )} - {columns.map((col, colIdx) => { - const cell = row.cells[col.id] - return ( - - ) - })} - - ) - })} - {create && ( - - - - )} + {displayRows.map((row) => ( + + ))} + {create && }
- - selectable.onSelectRow(row.id, checked as boolean) - } - disabled={selectable.disabled} - aria-label='Select row' - onClick={(e) => e.stopPropagation()} - /> - - -
- - - {create.label} - -
{hasMore && ( @@ -390,7 +353,7 @@ export const ResourceTable = memo(function ResourceTable({ ) }) -function Pagination({ +const Pagination = memo(function Pagination({ currentPage, totalPages, onPageChange, @@ -447,10 +410,17 @@ function Pagination({
) +}) + +interface CellContentProps { + icon?: ReactNode + label: string + content?: ReactNode + primary?: boolean } -function CellContent({ cell, primary }: { cell: ResourceCell; primary?: boolean }) { - if (cell.content) return <>{cell.content} +const CellContent = memo(function CellContent({ icon, label, content, primary }: CellContentProps) { + if (content) return <>{content} return ( - {cell.icon && {cell.icon}} - {cell.label} + {icon && {icon}} + {label} ) +}) + +interface DataRowProps { + row: ResourceRow + columns: ResourceColumn[] + selectedRowId?: string | null + selectable?: SelectableConfig + onRowClick?: (rowId: string) => void + onRowHover?: (rowId: string) => void + onRowContextMenu?: (e: React.MouseEvent, rowId: string) => void + hasCheckbox: boolean } -function ResourceColGroup({ +const DataRow = memo(function DataRow({ + row, columns, + selectedRowId, + selectable, + onRowClick, + onRowHover, + onRowContextMenu, hasCheckbox, -}: { +}: DataRowProps) { + const isSelected = selectable?.selectedIds.has(row.id) ?? false + + const handleClick = useCallback(() => { + onRowClick?.(row.id) + }, [onRowClick, row.id]) + + const handleMouseEnter = useCallback(() => { + onRowHover?.(row.id) + }, [onRowHover, row.id]) + + const handleContextMenu = useCallback( + (e: React.MouseEvent) => { + onRowContextMenu?.(e, row.id) + }, + [onRowContextMenu, row.id] + ) + + const handleSelectRow = useCallback( + (checked: boolean | 'indeterminate') => { + selectable?.onSelectRow(row.id, checked as boolean) + }, + [selectable, row.id] + ) + + return ( + + {hasCheckbox && selectable && ( + + + + )} + {columns.map((col, colIdx) => { + const cell = row.cells[col.id] + return ( + + + + ) + })} + + ) +}) + +interface CreateRowProps { + create: CreateAction + totalColSpan: number +} + +const CreateRow = memo(function CreateRow({ create, totalColSpan }: CreateRowProps) { + return ( + + + + {CREATE_ROW_PLUS_ICON} + {create.label} + + + + ) +}) + +interface ResourceColGroupProps { columns: ResourceColumn[] hasCheckbox?: boolean -}) { +} + +const ResourceColGroup = memo(function ResourceColGroup({ + columns, + hasCheckbox, +}: ResourceColGroupProps) { return ( {hasCheckbox && } @@ -486,17 +569,19 @@ function ResourceColGroup({ ))} ) +}) + +interface DataTableSkeletonProps { + columns: ResourceColumn[] + rowCount: number + hasCheckbox?: boolean } -function DataTableSkeleton({ +const DataTableSkeleton = memo(function DataTableSkeleton({ columns, rowCount, hasCheckbox, -}: { - columns: ResourceColumn[] - rowCount: number - hasCheckbox?: boolean -}) { +}: DataTableSkeletonProps) { return ( <>
@@ -549,4 +634,4 @@ function DataTableSkeleton({
) -} +}) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx index 1c8efc07948..8a89dbf93db 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useRef, useState } from 'react' +import { memo, useEffect, useRef, useState } from 'react' import { zodResolver } from '@hookform/resolvers/zod' import { createLogger } from '@sim/logger' import { Loader2, RotateCcw, X } from 'lucide-react' @@ -78,7 +78,10 @@ interface SubmitStatus { message: string } -export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) { +export const CreateBaseModal = memo(function CreateBaseModal({ + open, + onOpenChange, +}: CreateBaseModalProps) { const params = useParams() const workspaceId = params.workspaceId as string @@ -543,4 +546,4 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) { ) -} +}) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/delete-knowledge-base-modal/delete-knowledge-base-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/delete-knowledge-base-modal/delete-knowledge-base-modal.tsx index 7d1655c3dc5..241e71af54d 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/delete-knowledge-base-modal/delete-knowledge-base-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/delete-knowledge-base-modal/delete-knowledge-base-modal.tsx @@ -1,5 +1,6 @@ 'use client' +import { memo } from 'react' import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn' interface DeleteKnowledgeBaseModalProps { @@ -29,7 +30,7 @@ interface DeleteKnowledgeBaseModalProps { * Delete confirmation modal for knowledge base items. * Displays a warning message and confirmation buttons. */ -export function DeleteKnowledgeBaseModal({ +export const DeleteKnowledgeBaseModal = memo(function DeleteKnowledgeBaseModal({ isOpen, onClose, onConfirm, @@ -67,4 +68,4 @@ export function DeleteKnowledgeBaseModal({ ) -} +}) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/edit-knowledge-base-modal/edit-knowledge-base-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/edit-knowledge-base-modal/edit-knowledge-base-modal.tsx index 8dd48239719..2850bd057be 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/edit-knowledge-base-modal/edit-knowledge-base-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/edit-knowledge-base-modal/edit-knowledge-base-modal.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useState } from 'react' +import { memo, useEffect, useState } from 'react' import { zodResolver } from '@hookform/resolvers/zod' import { createLogger } from '@sim/logger' import { useForm } from 'react-hook-form' @@ -43,7 +43,7 @@ type FormValues = z.infer /** * Modal for editing knowledge base name and description */ -export function EditKnowledgeBaseModal({ +export const EditKnowledgeBaseModal = memo(function EditKnowledgeBaseModal({ open, onOpenChange, knowledgeBaseId, @@ -172,4 +172,4 @@ export function EditKnowledgeBaseModal({ ) -} +}) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-base-context-menu/knowledge-base-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-base-context-menu/knowledge-base-context-menu.tsx index 0d9feaa72c4..9993e71913e 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-base-context-menu/knowledge-base-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-base-context-menu/knowledge-base-context-menu.tsx @@ -1,5 +1,6 @@ 'use client' +import { memo } from 'react' import { DropdownMenu, DropdownMenuContent, @@ -30,7 +31,7 @@ interface KnowledgeBaseContextMenuProps { * Context menu component for knowledge base cards. * Displays open in new tab, view tags, edit, and delete options. */ -export function KnowledgeBaseContextMenu({ +export const KnowledgeBaseContextMenu = memo(function KnowledgeBaseContextMenu({ isOpen, position, onClose, @@ -114,4 +115,4 @@ export function KnowledgeBaseContextMenu({ ) -} +}) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-list-context-menu/knowledge-list-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-list-context-menu/knowledge-list-context-menu.tsx index f720d733e1e..1a257be1236 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-list-context-menu/knowledge-list-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-list-context-menu/knowledge-list-context-menu.tsx @@ -1,5 +1,6 @@ 'use client' +import { memo } from 'react' import { DropdownMenu, DropdownMenuContent, @@ -20,7 +21,7 @@ interface KnowledgeListContextMenuProps { * Context menu component for the knowledge base list page. * Displays "Add knowledge base" option when right-clicking on empty space. */ -export function KnowledgeListContextMenu({ +export const KnowledgeListContextMenu = memo(function KnowledgeListContextMenu({ isOpen, position, onClose, @@ -58,4 +59,4 @@ export function KnowledgeListContextMenu({ ) -} +}) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx index 991fc6d63bf..a65068a67cd 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx @@ -1,11 +1,16 @@ 'use client' -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { useParams, useRouter } from 'next/navigation' import { Database } from '@/components/emcn/icons' import type { KnowledgeBaseData } from '@/lib/knowledge/types' -import type { ResourceColumn, ResourceRow } from '@/app/workspace/[workspaceId]/components' +import type { + CreateAction, + ResourceColumn, + ResourceRow, + SearchConfig, +} from '@/app/workspace/[workspaceId]/components' import { ownerCell, Resource, timeCell } from '@/app/workspace/[workspaceId]/components' import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components' import { @@ -21,7 +26,6 @@ import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sideb import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge' import { useDeleteKnowledgeBase, useUpdateKnowledgeBase } from '@/hooks/queries/kb/knowledge' import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace' -import { useDebounce } from '@/hooks/use-debounce' const logger = createLogger('Knowledge') @@ -38,6 +42,8 @@ const COLUMNS: ResourceColumn[] = [ { id: 'updated', header: 'Last Updated' }, ] +const DATABASE_ICON = + export function Knowledge() { const params = useParams() const router = useRouter() @@ -54,8 +60,16 @@ export function Knowledge() { const { mutateAsync: updateKnowledgeBaseMutation } = useUpdateKnowledgeBase(workspaceId) const { mutateAsync: deleteKnowledgeBaseMutation } = useDeleteKnowledgeBase(workspaceId) - const [searchQuery, setSearchQuery] = useState('') - const debouncedSearchQuery = useDebounce(searchQuery, 300) + const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('') + const searchTimerRef = useRef>(null) + + const handleSearchChange = useCallback((value: string) => { + if (searchTimerRef.current) clearTimeout(searchTimerRef.current) + searchTimerRef.current = setTimeout(() => { + setDebouncedSearchQuery(value) + }, 300) + }, []) + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false) const [activeKnowledgeBase, setActiveKnowledgeBase] = useState( @@ -69,7 +83,6 @@ export function Knowledge() { const { isOpen: isListContextMenuOpen, position: listContextMenuPosition, - menuRef: listMenuRef, handleContextMenu: handleListContextMenu, closeMenu: closeListContextMenu, } = useContextMenu() @@ -77,11 +90,19 @@ export function Knowledge() { const { isOpen: isRowContextMenuOpen, position: rowContextMenuPosition, - menuRef: rowMenuRef, handleContextMenu: handleRowCtxMenu, closeMenu: closeRowContextMenu, } = useContextMenu() + const isRowContextMenuOpenRef = useRef(isRowContextMenuOpen) + isRowContextMenuOpenRef.current = isRowContextMenuOpen + + const knowledgeBasesRef = useRef(knowledgeBases) + knowledgeBasesRef.current = knowledgeBases + + const activeKnowledgeBaseRef = useRef(activeKnowledgeBase) + activeKnowledgeBaseRef.current = activeKnowledgeBase + const handleContentContextMenu = useCallback( (e: React.MouseEvent) => { const target = e.target as HTMLElement @@ -96,7 +117,7 @@ export function Knowledge() { [handleListContextMenu] ) - const handleAddKnowledgeBase = useCallback(() => { + const handleOpenCreateModal = useCallback(() => { setIsCreateModalOpen(true) }, []) @@ -132,7 +153,7 @@ export function Knowledge() { id: kb.id, cells: { name: { - icon: , + icon: DATABASE_ICON, label: kb.name, }, documents: { @@ -158,51 +179,98 @@ export function Knowledge() { const handleRowClick = useCallback( (rowId: string) => { - if (isRowContextMenuOpen) return - const kb = knowledgeBases.find((k) => k.id === rowId) + if (isRowContextMenuOpenRef.current) return + const kb = knowledgeBasesRef.current.find((k) => k.id === rowId) if (!kb) return const urlParams = new URLSearchParams({ kbName: kb.name }) router.push(`/workspace/${workspaceId}/knowledge/${rowId}?${urlParams.toString()}`) }, - [isRowContextMenuOpen, knowledgeBases, router, workspaceId] + [router, workspaceId] ) const handleRowContextMenu = useCallback( (e: React.MouseEvent, rowId: string) => { - const kb = knowledgeBases.find((k) => k.id === rowId) as KnowledgeBaseWithDocCount | undefined + const kb = knowledgeBasesRef.current.find((k) => k.id === rowId) as + | KnowledgeBaseWithDocCount + | undefined setActiveKnowledgeBase(kb ?? null) handleRowCtxMenu(e) }, - [knowledgeBases, handleRowCtxMenu] + [handleRowCtxMenu] ) const handleConfirmDelete = useCallback(async () => { - if (!activeKnowledgeBase) return + const kb = activeKnowledgeBaseRef.current + if (!kb) return setIsDeleting(true) try { - await handleDeleteKnowledgeBase(activeKnowledgeBase.id) + await handleDeleteKnowledgeBase(kb.id) setIsDeleteModalOpen(false) setActiveKnowledgeBase(null) } finally { setIsDeleting(false) } - }, [activeKnowledgeBase, handleDeleteKnowledgeBase]) + }, [handleDeleteKnowledgeBase]) + + const handleCloseDeleteModal = useCallback(() => { + setIsDeleteModalOpen(false) + setActiveKnowledgeBase(null) + }, []) + + const handleOpenInNewTab = useCallback(() => { + const kb = activeKnowledgeBaseRef.current + if (!kb) return + const urlParams = new URLSearchParams({ kbName: kb.name }) + window.open(`/workspace/${workspaceId}/knowledge/${kb.id}?${urlParams.toString()}`, '_blank') + }, [workspaceId]) + + const handleViewTags = useCallback(() => { + setIsTagsModalOpen(true) + }, []) + + const handleCopyId = useCallback(() => { + const kb = activeKnowledgeBaseRef.current + if (kb) { + navigator.clipboard.writeText(kb.id) + } + }, []) + + const handleEdit = useCallback(() => { + setIsEditModalOpen(true) + }, []) + + const handleDelete = useCallback(() => { + setIsDeleteModalOpen(true) + }, []) + + const canEdit = userPermissions.canEdit === true + + const createAction: CreateAction = useMemo( + () => ({ + label: 'New base', + onClick: handleOpenCreateModal, + disabled: !canEdit, + }), + [handleOpenCreateModal, canEdit] + ) + + const searchConfig: SearchConfig = useMemo( + () => ({ + value: debouncedSearchQuery, + onChange: handleSearchChange, + onClearAll: () => handleSearchChange(''), + placeholder: 'Search knowledge bases...', + }), + [handleSearchChange, debouncedSearchQuery] + ) return ( <> setIsCreateModalOpen(true), - disabled: userPermissions.canEdit !== true, - }} - search={{ - value: searchQuery, - onChange: setSearchQuery, - placeholder: 'Search knowledge bases...', - }} + create={createAction} + search={searchConfig} defaultSort='created' columns={COLUMNS} rows={rows} @@ -216,8 +284,8 @@ export function Knowledge() { isOpen={isListContextMenuOpen} position={listContextMenuPosition} onClose={closeListContextMenu} - onAddKnowledgeBase={handleAddKnowledgeBase} - disableAdd={userPermissions.canEdit !== true} + onAddKnowledgeBase={handleOpenCreateModal} + disableAdd={!canEdit} /> {activeKnowledgeBase && ( @@ -225,23 +293,17 @@ export function Knowledge() { isOpen={isRowContextMenuOpen} position={rowContextMenuPosition} onClose={closeRowContextMenu} - onOpenInNewTab={() => { - const urlParams = new URLSearchParams({ kbName: activeKnowledgeBase.name }) - window.open( - `/workspace/${workspaceId}/knowledge/${activeKnowledgeBase.id}?${urlParams.toString()}`, - '_blank' - ) - }} - onViewTags={() => setIsTagsModalOpen(true)} - onCopyId={() => navigator.clipboard.writeText(activeKnowledgeBase.id)} - onEdit={() => setIsEditModalOpen(true)} - onDelete={() => setIsDeleteModalOpen(true)} + onOpenInNewTab={handleOpenInNewTab} + onViewTags={handleViewTags} + onCopyId={handleCopyId} + onEdit={handleEdit} + onDelete={handleDelete} showOpenInNewTab showViewTags showEdit showDelete - disableEdit={!userPermissions.canEdit} - disableDelete={!userPermissions.canEdit} + disableEdit={!canEdit} + disableDelete={!canEdit} /> )} @@ -259,10 +321,7 @@ export function Knowledge() { {activeKnowledgeBase && ( { - setIsDeleteModalOpen(false) - setActiveKnowledgeBase(null) - }} + onClose={handleCloseDeleteModal} onConfirm={handleConfirmDelete} isDeleting={isDeleting} knowledgeBaseName={activeKnowledgeBase.name} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index c54b9256783..1627039ae58 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -310,6 +310,11 @@ export const Sidebar = memo(function Sidebar() { const toggleCollapsed = useSidebarStore((state) => state.toggleCollapsed) const isOnWorkflowPage = !!workflowId + const isCollapsedRef = useRef(isCollapsed) + useLayoutEffect(() => { + isCollapsedRef.current = isCollapsed + }, [isCollapsed]) + // Delay collapsed tooltips until the width transition finishes. const [showCollapsedTooltips, setShowCollapsedTooltips] = useState(isCollapsed) @@ -709,14 +714,14 @@ export const Sidebar = memo(function Sidebar() { icon: Settings, href: getSettingsHref(), onClick: () => { - if (!isCollapsed) { + if (!isCollapsedRef.current) { setSidebarWidth(SIDEBAR_WIDTH.MIN) } navigateToSettings() }, }, ], - [workspaceId, navigateToSettings, getSettingsHref, isCollapsed, setSidebarWidth] + [navigateToSettings, getSettingsHref, setSidebarWidth] ) const handleStartTour = useCallback(() => { @@ -810,12 +815,12 @@ export const Sidebar = memo(function Sidebar() { const navigateToPage = useCallback( (path: string) => { - if (!isCollapsed) { + if (!isCollapsedRef.current) { setSidebarWidth(SIDEBAR_WIDTH.MIN) } router.push(path) }, - [isCollapsed, setSidebarWidth, router] + [setSidebarWidth, router] ) const handleConfirmDeleteTasks = useCallback(() => { @@ -1064,6 +1069,88 @@ export const Sidebar = memo(function Sidebar() { [importWorkspace] ) + // ── Memoised elements & objects for collapsed menus ── + // Prevents new JSX/object references on every render, which would defeat + // React.memo on CollapsedSidebarMenu and its children. + + const tasksCollapsedIcon = useMemo( + () => , + [] + ) + + const workflowIconStyle = useMemo( + () => ({ + backgroundColor: 'var(--text-icon)', + borderColor: 'color-mix(in srgb, var(--text-icon) 60%, transparent)', + backgroundClip: 'padding-box', + }), + [] + ) + + const workflowsCollapsedIcon = useMemo( + () => ( +
+ ), + [workflowIconStyle] + ) + + const tasksPrimaryAction = useMemo( + () => ({ + label: 'New task', + onSelect: () => navigateToPage(`/workspace/${workspaceId}/home`), + }), + [navigateToPage, workspaceId] + ) + + const workflowsPrimaryAction = useMemo( + () => ({ + label: 'New workflow', + onSelect: handleCreateWorkflow, + }), + [handleCreateWorkflow] + ) + + // Stable no-op for collapsed workflow context menu delete (never changes) + const noop = useCallback(() => {}, []) + + // Stable callback for the "New task" button in expanded mode + const handleNewTask = useCallback( + () => navigateToPage(`/workspace/${workspaceId}/home`), + [navigateToPage, workspaceId] + ) + + // Stable callback for "See more" tasks + const handleSeeMoreTasks = useCallback(() => setVisibleTaskCount((prev) => prev + 5), []) + + // Stable callback for DeleteModal close + const handleCloseTaskDeleteModal = useCallback(() => setIsTaskDeleteModalOpen(false), []) + + // Stable handler for help modal open from dropdown + const handleOpenHelpFromMenu = useCallback(() => setIsHelpModalOpen(true), []) + + // Stable handler for opening docs + const handleOpenDocs = useCallback( + () => window.open('https://docs.sim.ai', '_blank', 'noopener,noreferrer'), + [] + ) + + // Stable blur handlers for inline rename inputs + const handleTaskRenameBlur = useCallback( + () => void taskFlyoutRename.saveRename(), + [taskFlyoutRename.saveRename] + ) + + const handleWorkflowRenameBlur = useCallback( + () => void workflowFlyoutRename.saveRename(), + [workflowFlyoutRename.saveRename] + ) + + // Stable style for hidden file inputs + const hiddenStyle = useMemo(() => ({ display: 'none' }) as const, []) + const resolveWorkspaceIdFromPath = useCallback((): string | undefined => { if (workspaceId) return workspaceId if (typeof window === 'undefined') return undefined @@ -1256,7 +1343,7 @@ export const Sidebar = memo(function Sidebar() {
{topNavItems.map((item) => ( {workspaceNavItems.map((item) => ( navigateToPage(`/workspace/${workspaceId}/home`)} + onClick={handleNewTask} > @@ -1316,16 +1403,11 @@ export const Sidebar = memo(function Sidebar() {
{isCollapsed ? ( - } + icon={tasksCollapsedIcon} hover={tasksHover} ariaLabel='Tasks' className='mt-1.5' - primaryAction={{ - label: 'New task', - onSelect: () => navigateToPage(`/workspace/${workspaceId}/home`), - }} + primaryAction={tasksPrimaryAction} > {tasksLoading ? ( @@ -1344,7 +1426,7 @@ export const Sidebar = memo(function Sidebar() { isRenaming={taskFlyoutRename.isSaving} onEditValueChange={taskFlyoutRename.setValue} onEditKeyDown={taskFlyoutRename.handleKeyDown} - onEditBlur={() => void taskFlyoutRename.saveRename()} + onEditBlur={handleTaskRenameBlur} onContextMenu={handleTaskContextMenu} onMorePointerDown={handleTaskMorePointerDown} onMoreClick={handleTaskMoreClick} @@ -1375,7 +1457,7 @@ export const Sidebar = memo(function Sidebar() { value={taskFlyoutRename.value} onChange={(e) => taskFlyoutRename.setValue(e.target.value)} onKeyDown={taskFlyoutRename.handleKeyDown} - onBlur={() => void taskFlyoutRename.saveRename()} + onBlur={handleTaskRenameBlur} className='min-w-0 flex-1 border-none bg-transparent font-base text-[14px] text-[var(--text-body)] outline-none' />
@@ -1401,7 +1483,7 @@ export const Sidebar = memo(function Sidebar() { {tasks.length > visibleTaskCount && (