feat: update sidebar and knowledge (#3804)

* feat: update sidebar and knowledge

* chore: fix rernders on knowledge

* chore: fix review changes

* chore: fix review changes
This commit is contained in:
Adithya Krishna
2026-03-27 22:09:41 +05:30
committed by GitHub
parent 5f1d5e0618
commit db1798267e
11 changed files with 512 additions and 249 deletions

View File

@@ -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 (
<img
@@ -18,7 +24,7 @@ function OwnerAvatar({ name, image }: { name: string; image: string | null }) {
{name.charAt(0).toUpperCase()}
</span>
)
}
})
/**
* Resolves a user ID into a ResourceCell with an avatar icon and display name.

View File

@@ -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 = <Plus className='mr-1.5 h-[14px] w-[14px] text-[var(--text-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'
>
<Plus className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
{HEADER_PLUS_ICON}
{create.label}
</Button>
)}
@@ -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 (
<span className='inline-flex items-center px-2 py-1'>
@@ -203,4 +207,4 @@ function BreadcrumbSegment({
{content}
</span>
)
}
})

View File

@@ -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 = (
<Search className='pointer-events-none h-[14px] w-[14px] shrink-0 text-[var(--text-icon)]' />
)
const FILTER_ICON = <ListFilter className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
const SORT_ICON = <ArrowUpDown className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
type SortDirection = 'asc' | 'desc'
export interface ColumnOption {
@@ -79,56 +85,7 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({
return (
<div className={cn('border-[var(--border)] border-b py-2.5', search ? 'px-6' : 'px-4')}>
<div className='flex items-center justify-between'>
{search && (
<div className='relative flex flex-1 items-center'>
<Search className='pointer-events-none h-[14px] w-[14px] shrink-0 text-[var(--text-icon)]' />
<div className='flex flex-1 items-center gap-1.5 overflow-x-auto pl-2.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
{search.tags?.map((tag, i) => (
<Button
key={`${tag.label}-${tag.value}-${i}`}
variant='subtle'
className={cn(
'shrink-0 px-2 py-1 text-caption',
search.highlightedTagIndex === i &&
'ring-1 ring-[var(--border-focus)] ring-offset-1'
)}
onClick={tag.onRemove}
>
{tag.label}: {tag.value}
<span className='ml-1 text-[var(--text-icon)] text-micro'></span>
</Button>
))}
<input
ref={search.inputRef}
type='text'
value={search.value}
onChange={(e) => 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)]'
/>
</div>
{search.tags?.length || search.value ? (
<button
type='button'
className='mr-0.5 flex h-[14px] w-[14px] shrink-0 items-center justify-center text-[var(--text-subtle)] transition-colors hover-hover:text-[var(--text-secondary)]'
onClick={search.onClearAll}
>
<span className='text-caption'></span>
</button>
) : null}
{search.dropdown && (
<div
ref={search.dropdownRef}
className='absolute top-full left-0 z-50 mt-1.5 w-full rounded-lg border border-[var(--border)] bg-[var(--bg)] shadow-sm'
>
{search.dropdown}
</div>
)}
</div>
)}
{search && <SearchSection search={search} />}
<div className='flex items-center gap-1.5'>
{extras}
{filterTags?.map((tag) => (
@@ -146,7 +103,7 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({
<PopoverPrimitive.Root>
<PopoverPrimitive.Trigger asChild>
<Button variant='subtle' className='px-2 py-1 text-caption'>
<ListFilter className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
{FILTER_ICON}
Filter
</Button>
</PopoverPrimitive.Trigger>
@@ -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<HTMLInputElement>) => {
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 (
<div className='relative flex flex-1 items-center'>
{SEARCH_ICON}
<div className='flex flex-1 items-center gap-1.5 overflow-x-auto pl-2.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
{search.tags?.map((tag, i) => (
<Button
key={`${tag.label}-${tag.value}-${i}`}
variant='subtle'
className={cn(
'shrink-0 px-2 py-1 text-caption',
search.highlightedTagIndex === i && 'ring-1 ring-[var(--border-focus)] ring-offset-1'
)}
onClick={tag.onRemove}
>
{tag.label}: {tag.value}
<span className='ml-1 text-[var(--text-icon)] text-micro'></span>
</Button>
))}
<input
ref={search.inputRef}
type='text'
value={localValue}
onChange={handleInputChange}
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)]'
/>
</div>
{search.tags?.length || localValue ? (
<button
type='button'
className='mr-0.5 flex h-[14px] w-[14px] shrink-0 items-center justify-center text-[var(--text-subtle)] transition-colors hover-hover:text-[var(--text-secondary)]'
onClick={handleClearAll}
>
<span className='text-caption'></span>
</button>
) : null}
{search.dropdown && (
<div
ref={search.dropdownRef}
className='absolute top-full left-0 z-50 mt-1.5 w-full rounded-lg border border-[var(--border)] bg-[var(--bg)] shadow-sm'
>
{search.dropdown}
</div>
)}
</div>
)
})
const SortDropdown = memo(function SortDropdown({ config }: { config: SortConfig }) {
const { options, active, onSort, onClear } = config
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='subtle' className='px-2 py-1 text-caption'>
<ArrowUpDown className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
{SORT_ICON}
Sort
</Button>
</DropdownMenuTrigger>
@@ -218,4 +255,4 @@ function SortDropdown({ config }: { config: SortConfig }) {
</DropdownMenuContent>
</DropdownMenu>
)
}
})

View File

@@ -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 = <Plus className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
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({
/>
</div>
)
}
})
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 (
<DataTableSkeleton
@@ -259,7 +270,7 @@ export const ResourceTable = memo(function ResourceTable({
<Checkbox
size='sm'
checked={selectable.isAllSelected}
onCheckedChange={(checked) => selectable.onSelectAll(checked as boolean)}
onCheckedChange={handleSelectAll}
disabled={selectable.disabled}
aria-label='Select all'
/>
@@ -306,68 +317,20 @@ export const ResourceTable = memo(function ResourceTable({
<table className='w-full table-fixed text-small'>
<ResourceColGroup columns={columns} hasCheckbox={hasCheckbox} />
<tbody>
{displayRows.map((row) => {
const isSelected = selectable?.selectedIds.has(row.id) ?? false
return (
<tr
key={row.id}
data-resource-row
data-row-id={row.id}
className={cn(
'transition-colors hover-hover:bg-[var(--surface-3)]',
onRowClick && 'cursor-pointer',
(selectedRowId === row.id || isSelected) && 'bg-[var(--surface-3)]'
)}
onClick={() => onRowClick?.(row.id)}
onMouseEnter={onRowHover ? () => onRowHover(row.id) : undefined}
onContextMenu={(e) => onRowContextMenu?.(e, row.id)}
>
{hasCheckbox && (
<td className='w-[52px] py-2.5 pr-0 pl-5 align-middle'>
<Checkbox
size='sm'
checked={isSelected}
onCheckedChange={(checked) =>
selectable.onSelectRow(row.id, checked as boolean)
}
disabled={selectable.disabled}
aria-label='Select row'
onClick={(e) => e.stopPropagation()}
/>
</td>
)}
{columns.map((col, colIdx) => {
const cell = row.cells[col.id]
return (
<td key={col.id} className='px-6 py-2.5 align-middle'>
<CellContent
cell={{ ...cell, label: cell?.label || EMPTY_CELL_PLACEHOLDER }}
primary={colIdx === 0}
/>
</td>
)
})}
</tr>
)
})}
{create && (
<tr
className={cn(
'transition-colors',
create.disabled
? 'cursor-not-allowed'
: 'cursor-pointer hover-hover:bg-[var(--surface-3)]'
)}
onClick={create.disabled ? undefined : create.onClick}
>
<td colSpan={totalColSpan} className='px-6 py-2.5 align-middle'>
<span className='flex items-center gap-3 font-medium text-[var(--text-secondary)] text-sm'>
<Plus className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
{create.label}
</span>
</td>
</tr>
)}
{displayRows.map((row) => (
<DataRow
key={row.id}
row={row}
columns={columns}
selectedRowId={selectedRowId}
selectable={selectable}
onRowClick={onRowClick}
onRowHover={onRowHover}
onRowContextMenu={onRowContextMenu}
hasCheckbox={hasCheckbox}
/>
))}
{create && <CreateRow create={create} totalColSpan={totalColSpan} />}
</tbody>
</table>
{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({
</div>
</div>
)
})
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 (
<span
className={cn(
@@ -458,19 +428,132 @@ function CellContent({ cell, primary }: { cell: ResourceCell; primary?: boolean
primary ? 'text-[var(--text-body)]' : 'text-[var(--text-secondary)]'
)}
>
{cell.icon && <span className='flex-shrink-0 text-[var(--text-icon)]'>{cell.icon}</span>}
<span className='truncate'>{cell.label}</span>
{icon && <span className='flex-shrink-0 text-[var(--text-icon)]'>{icon}</span>}
<span className='truncate'>{label}</span>
</span>
)
})
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 (
<tr
data-resource-row
data-row-id={row.id}
className={cn(
'transition-colors hover-hover:bg-[var(--surface-3)]',
onRowClick && 'cursor-pointer',
(selectedRowId === row.id || isSelected) && 'bg-[var(--surface-3)]'
)}
onClick={onRowClick ? handleClick : undefined}
onMouseEnter={handleMouseEnter}
onContextMenu={onRowContextMenu ? handleContextMenu : undefined}
>
{hasCheckbox && selectable && (
<td className='w-[52px] py-2.5 pr-0 pl-5 align-middle'>
<Checkbox
size='sm'
checked={isSelected}
onCheckedChange={handleSelectRow}
disabled={selectable.disabled}
aria-label='Select row'
onClick={stopPropagation}
/>
</td>
)}
{columns.map((col, colIdx) => {
const cell = row.cells[col.id]
return (
<td key={col.id} className='px-6 py-2.5 align-middle'>
<CellContent
icon={cell?.icon}
label={cell?.label || EMPTY_CELL_PLACEHOLDER}
content={cell?.content}
primary={colIdx === 0}
/>
</td>
)
})}
</tr>
)
})
interface CreateRowProps {
create: CreateAction
totalColSpan: number
}
const CreateRow = memo(function CreateRow({ create, totalColSpan }: CreateRowProps) {
return (
<tr
className={cn(
'transition-colors',
create.disabled ? 'cursor-not-allowed' : 'cursor-pointer hover-hover:bg-[var(--surface-3)]'
)}
onClick={create.disabled ? undefined : create.onClick}
>
<td colSpan={totalColSpan} className='px-6 py-2.5 align-middle'>
<span className='flex items-center gap-3 font-medium text-[var(--text-secondary)] text-sm'>
{CREATE_ROW_PLUS_ICON}
{create.label}
</span>
</td>
</tr>
)
})
interface ResourceColGroupProps {
columns: ResourceColumn[]
hasCheckbox?: boolean
}) {
}
const ResourceColGroup = memo(function ResourceColGroup({
columns,
hasCheckbox,
}: ResourceColGroupProps) {
return (
<colgroup>
{hasCheckbox && <col className='w-[52px]' />}
@@ -486,17 +569,19 @@ function ResourceColGroup({
))}
</colgroup>
)
}
})
function DataTableSkeleton({
columns,
rowCount,
hasCheckbox,
}: {
interface DataTableSkeletonProps {
columns: ResourceColumn[]
rowCount: number
hasCheckbox?: boolean
}) {
}
const DataTableSkeleton = memo(function DataTableSkeleton({
columns,
rowCount,
hasCheckbox,
}: DataTableSkeletonProps) {
return (
<>
<div className='overflow-hidden'>
@@ -549,4 +634,4 @@ function DataTableSkeleton({
</div>
</>
)
}
})

View File

@@ -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) {
</ModalContent>
</Modal>
)
}
})

View File

@@ -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({
</ModalContent>
</Modal>
)
}
})

View File

@@ -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<typeof FormSchema>
/**
* 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({
</ModalContent>
</Modal>
)
}
})

View File

@@ -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({
</DropdownMenuContent>
</DropdownMenu>
)
}
})

View File

@@ -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({
</DropdownMenuContent>
</DropdownMenu>
)
}
})

View File

@@ -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 = <Database className='h-[14px] w-[14px]' />
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<ReturnType<typeof setTimeout>>(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<KnowledgeBaseWithDocCount | null>(
@@ -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: <Database className='h-[14px] w-[14px]' />,
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 (
<>
<Resource
icon={Database}
title='Knowledge Base'
create={{
label: 'New base',
onClick: () => 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 && (
<DeleteKnowledgeBaseModal
isOpen={isDeleteModalOpen}
onClose={() => {
setIsDeleteModalOpen(false)
setActiveKnowledgeBase(null)
}}
onClose={handleCloseDeleteModal}
onConfirm={handleConfirmDelete}
isDeleting={isDeleting}
knowledgeBaseName={activeKnowledgeBase.name}

View File

@@ -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(
() => <Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />,
[]
)
const workflowIconStyle = useMemo<React.CSSProperties>(
() => ({
backgroundColor: 'var(--text-icon)',
borderColor: 'color-mix(in srgb, var(--text-icon) 60%, transparent)',
backgroundClip: 'padding-box',
}),
[]
)
const workflowsCollapsedIcon = useMemo(
() => (
<div
className='h-[16px] w-[16px] flex-shrink-0 rounded-[3px] border-[2px]'
style={workflowIconStyle}
/>
),
[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() {
<div className='mt-2.5 flex flex-shrink-0 flex-col gap-0.5 px-2'>
{topNavItems.map((item) => (
<SidebarNavItem
key={`${item.id}-${isCollapsed}`}
key={item.id}
item={item}
active={item.href ? !!pathname?.startsWith(item.href) : false}
showCollapsedTooltips={showCollapsedTooltips}
@@ -1273,7 +1360,7 @@ export const Sidebar = memo(function Sidebar() {
<div className='flex flex-col gap-0.5 px-2'>
{workspaceNavItems.map((item) => (
<SidebarNavItem
key={`${item.id}-${isCollapsed}`}
key={item.id}
item={item}
active={item.href ? !!pathname?.startsWith(item.href) : false}
showCollapsedTooltips={showCollapsedTooltips}
@@ -1302,7 +1389,7 @@ export const Sidebar = memo(function Sidebar() {
<Button
variant='ghost'
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-active)]'
onClick={() => navigateToPage(`/workspace/${workspaceId}/home`)}
onClick={handleNewTask}
>
<Plus className='h-[16px] w-[16px]' />
</Button>
@@ -1316,16 +1403,11 @@ export const Sidebar = memo(function Sidebar() {
</div>
{isCollapsed ? (
<CollapsedSidebarMenu
icon={
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
}
icon={tasksCollapsedIcon}
hover={tasksHover}
ariaLabel='Tasks'
className='mt-1.5'
primaryAction={{
label: 'New task',
onSelect: () => navigateToPage(`/workspace/${workspaceId}/home`),
}}
primaryAction={tasksPrimaryAction}
>
{tasksLoading ? (
<DropdownMenuItem disabled>
@@ -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'
/>
</div>
@@ -1401,7 +1483,7 @@ export const Sidebar = memo(function Sidebar() {
{tasks.length > visibleTaskCount && (
<button
type='button'
onClick={() => setVisibleTaskCount((prev) => prev + 5)}
onClick={handleSeeMoreTasks}
className='mx-0.5 flex h-[30px] items-center gap-2 rounded-lg px-2 text-[var(--text-icon)] text-sm hover-hover:bg-[var(--surface-active)]'
>
<MoreHorizontal className='h-[16px] w-[16px] flex-shrink-0' />
@@ -1485,23 +1567,11 @@ export const Sidebar = memo(function Sidebar() {
</div>
{isCollapsed ? (
<CollapsedSidebarMenu
icon={
<div
className='h-[16px] w-[16px] flex-shrink-0 rounded-[3px] border-[2px]'
style={{
backgroundColor: 'var(--text-icon)',
borderColor: 'color-mix(in srgb, var(--text-icon) 60%, transparent)',
backgroundClip: 'padding-box',
}}
/>
}
icon={workflowsCollapsedIcon}
hover={workflowsHover}
ariaLabel='Workflows'
className='mt-1.5'
primaryAction={{
label: 'New workflow',
onSelect: handleCreateWorkflow,
}}
primaryAction={workflowsPrimaryAction}
>
{workflowsLoading && regularWorkflows.length === 0 ? (
<DropdownMenuItem disabled>
@@ -1523,7 +1593,7 @@ export const Sidebar = memo(function Sidebar() {
isRenamingWorkflow={workflowFlyoutRename.isSaving}
onEditValueChange={workflowFlyoutRename.setValue}
onEditKeyDown={workflowFlyoutRename.handleKeyDown}
onEditBlur={() => void workflowFlyoutRename.saveRename()}
onEditBlur={handleWorkflowRenameBlur}
onWorkflowContextMenu={handleCollapsedWorkflowContextMenu}
onWorkflowMorePointerDown={handleCollapsedWorkflowMorePointerDown}
onWorkflowMoreClick={handleCollapsedWorkflowMoreClick}
@@ -1540,7 +1610,7 @@ export const Sidebar = memo(function Sidebar() {
isRenaming={workflowFlyoutRename.isSaving}
onEditValueChange={workflowFlyoutRename.setValue}
onEditKeyDown={workflowFlyoutRename.handleKeyDown}
onEditBlur={() => void workflowFlyoutRename.saveRename()}
onEditBlur={handleWorkflowRenameBlur}
onContextMenu={handleCollapsedWorkflowContextMenu}
onMorePointerDown={handleCollapsedWorkflowMorePointerDown}
onMoreClick={handleCollapsedWorkflowMoreClick}
@@ -1601,15 +1671,11 @@ export const Sidebar = memo(function Sidebar() {
)}
</Tooltip.Root>
<DropdownMenuContent align='start' side='top' sideOffset={4}>
<DropdownMenuItem
onSelect={() =>
window.open('https://docs.sim.ai', '_blank', 'noopener,noreferrer')
}
>
<DropdownMenuItem onSelect={handleOpenDocs}>
<BookOpen className='h-[14px] w-[14px]' />
Docs
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setIsHelpModalOpen(true)}>
<DropdownMenuItem onSelect={handleOpenHelpFromMenu}>
<HelpCircle className='h-[14px] w-[14px]' />
Report an issue
</DropdownMenuItem>
@@ -1622,7 +1688,7 @@ export const Sidebar = memo(function Sidebar() {
{footerItems.map((item) => (
<SidebarNavItem
key={`${item.id}-${isCollapsed}`}
key={item.id}
item={item}
active={false}
showCollapsedTooltips={showCollapsedTooltips}
@@ -1673,7 +1739,7 @@ export const Sidebar = memo(function Sidebar() {
onClose={closeCollapsedWorkflowContextMenu}
onOpenInNewTab={handleCollapsedWorkflowOpenInNewTab}
onRename={handleStartCollapsedWorkflowRename}
onDelete={() => {}}
onDelete={noop}
showOpenInNewTab={true}
showRename={true}
showDuplicate={false}
@@ -1685,7 +1751,7 @@ export const Sidebar = memo(function Sidebar() {
{/* Task Delete Confirmation Modal */}
<DeleteModal
isOpen={isTaskDeleteModalOpen}
onClose={() => setIsTaskDeleteModalOpen(false)}
onClose={handleCloseTaskDeleteModal}
onConfirm={handleConfirmDeleteTasks}
isDeleting={deleteTaskMutation.isPending || deleteTasksMutation.isPending}
itemType='task'
@@ -1732,7 +1798,7 @@ export const Sidebar = memo(function Sidebar() {
ref={workspaceFileInputRef}
type='file'
accept='.zip'
style={{ display: 'none' }}
style={hiddenStyle}
onChange={handleWorkspaceFileChange}
/>
</>