diff --git a/apps/sim/app/(auth)/components/auth-button-classes.ts b/apps/sim/app/(auth)/components/auth-button-classes.ts index 02d1d5e47ed..a55f334ea8e 100644 --- a/apps/sim/app/(auth)/components/auth-button-classes.ts +++ b/apps/sim/app/(auth)/components/auth-button-classes.ts @@ -1,3 +1,6 @@ -/** Shared className for primary auth form submit buttons across all auth pages. */ -export const AUTH_SUBMIT_BTN = - 'inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50' as const +/** Shared className for primary auth/status CTA buttons on dark auth surfaces. */ +export const AUTH_PRIMARY_CTA_BASE = + 'inline-flex h-[32px] items-center justify-center gap-2 rounded-[5px] border border-[var(--auth-primary-btn-border)] bg-[var(--auth-primary-btn-bg)] px-2.5 font-[430] font-season text-[var(--auth-primary-btn-text)] text-sm transition-colors hover:border-[var(--auth-primary-btn-hover-border)] hover:bg-[var(--auth-primary-btn-hover-bg)] hover:text-[var(--auth-primary-btn-hover-text)] disabled:cursor-not-allowed disabled:opacity-50' as const + +/** Full-width variant used for primary auth form submit buttons. */ +export const AUTH_SUBMIT_BTN = `${AUTH_PRIMARY_CTA_BASE} w-full` as const diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css index e781191f6f9..0c80aef072a 100644 --- a/apps/sim/app/_styles/globals.css +++ b/apps/sim/app/_styles/globals.css @@ -15,6 +15,12 @@ --toolbar-triggers-height: 300px; /* TOOLBAR_TRIGGERS_HEIGHT.DEFAULT */ --editor-connections-height: 172px; /* EDITOR_CONNECTIONS_HEIGHT.DEFAULT */ --terminal-height: 206px; /* TERMINAL_HEIGHT.DEFAULT */ + --auth-primary-btn-bg: #ffffff; + --auth-primary-btn-border: #ffffff; + --auth-primary-btn-text: #000000; + --auth-primary-btn-hover-bg: #e0e0e0; + --auth-primary-btn-hover-border: #e0e0e0; + --auth-primary-btn-hover-text: #000000; /* z-index scale for layered UI Popover must be above modal so dropdowns inside modals render correctly */ diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts index 762f9be66cf..4eaa3353f1d 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts @@ -15,6 +15,8 @@ const GetChunksQuerySchema = z.object({ enabled: z.enum(['true', 'false', 'all']).optional().default('all'), limit: z.coerce.number().min(1).max(100).optional().default(50), offset: z.coerce.number().min(0).optional().default(0), + sortBy: z.enum(['chunkIndex', 'tokenCount', 'enabled']).optional().default('chunkIndex'), + sortOrder: z.enum(['asc', 'desc']).optional().default('asc'), }) const CreateChunkSchema = z.object({ @@ -88,6 +90,8 @@ export async function GET( enabled: searchParams.get('enabled') || undefined, limit: searchParams.get('limit') || undefined, offset: searchParams.get('offset') || undefined, + sortBy: searchParams.get('sortBy') || undefined, + sortOrder: searchParams.get('sortOrder') || undefined, }) const result = await queryChunks(documentId, queryParams, requestId) diff --git a/apps/sim/app/chat/components/auth/email/email-auth.tsx b/apps/sim/app/chat/components/auth/email/email-auth.tsx index 2a9da32b90d..651920a8439 100644 --- a/apps/sim/app/chat/components/auth/email/email-auth.tsx +++ b/apps/sim/app/chat/components/auth/email/email-auth.tsx @@ -293,7 +293,7 @@ export default function EmailAuth({ identifier, onAuthSuccess }: EmailAuthProps) diff --git a/apps/sim/app/form/[identifier]/components/password-auth.tsx b/apps/sim/app/form/[identifier]/components/password-auth.tsx index ecd0ec9d9fb..5f035360a29 100644 --- a/apps/sim/app/form/[identifier]/components/password-auth.tsx +++ b/apps/sim/app/form/[identifier]/components/password-auth.tsx @@ -5,6 +5,7 @@ import { Eye, EyeOff, Loader2 } from 'lucide-react' import { Input, Label } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import AuthBackground from '@/app/(auth)/components/auth-background' +import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes' import { SupportFooter } from '@/app/(auth)/components/support-footer' import Navbar from '@/app/(home)/components/navbar/navbar' @@ -75,7 +76,7 @@ export function PasswordAuth({ onSubmit, error }: PasswordAuthProps) { diff --git a/apps/sim/app/form/[identifier]/form.tsx b/apps/sim/app/form/[identifier]/form.tsx index 966471d5af3..f46fe4fd644 100644 --- a/apps/sim/app/form/[identifier]/form.tsx +++ b/apps/sim/app/form/[identifier]/form.tsx @@ -5,6 +5,7 @@ import { createLogger } from '@sim/logger' import { Loader2 } from 'lucide-react' import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono' import AuthBackground from '@/app/(auth)/components/auth-background' +import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes' import { SupportFooter } from '@/app/(auth)/components/support-footer' import Navbar from '@/app/(home)/components/navbar/navbar' import { @@ -322,11 +323,7 @@ export default function Form({ identifier }: { identifier: string }) { )} {fields.length > 0 && ( - )} @@ -69,9 +67,9 @@ export function InviteStatusCard({ onClick={action.onClick} disabled={action.disabled || action.loading} className={cn( - 'inline-flex h-[32px] w-full items-center justify-center gap-2 rounded-[5px] border border-white bg-white px-2.5 font-[430] font-season text-black text-sm transition-colors hover:border-[var(--border-1)] hover:bg-[var(--border-1)] disabled:cursor-not-allowed disabled:opacity-50', + `${AUTH_PRIMARY_CTA_BASE} w-full`, index !== 0 && - 'border-[var(--landing-border-strong)] bg-transparent text-[var(--landing-text)] hover:border-[var(--landing-border-strong)] hover:bg-[var(--landing-bg-elevated)]' + 'border-[var(--landing-border-strong)] bg-transparent text-[var(--landing-text)] hover:border-[var(--landing-border-strong)] hover:bg-[var(--landing-bg-elevated)] hover:text-[var(--landing-text)]' )} > {action.loading ? ( diff --git a/apps/sim/app/not-found.tsx b/apps/sim/app/not-found.tsx index 1877477fbf1..2d1ab6be6fb 100644 --- a/apps/sim/app/not-found.tsx +++ b/apps/sim/app/not-found.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next' import Link from 'next/link' import { getNavBlogPosts } from '@/lib/blog/registry' import AuthBackground from '@/app/(auth)/components/auth-background' +import { AUTH_PRIMARY_CTA_BASE } from '@/app/(auth)/components/auth-button-classes' import Navbar from '@/app/(home)/components/navbar/navbar' export const metadata: Metadata = { @@ -9,9 +10,6 @@ export const metadata: Metadata = { robots: { index: false, follow: true }, } -const CTA_BASE = - 'inline-flex items-center h-[32px] rounded-[5px] border px-2.5 font-[430] font-season text-sm' - export default async function NotFound() { const blogPosts = await getNavBlogPosts() return ( @@ -29,10 +27,7 @@ export default async function NotFound() { The page you're looking for doesn't exist or has been moved.

- + Return to Home
diff --git a/apps/sim/app/unsubscribe/unsubscribe.tsx b/apps/sim/app/unsubscribe/unsubscribe.tsx index 3efa7698b3c..85802f5c462 100644 --- a/apps/sim/app/unsubscribe/unsubscribe.tsx +++ b/apps/sim/app/unsubscribe/unsubscribe.tsx @@ -3,6 +3,7 @@ import { Suspense, useEffect, useState } from 'react' import { Loader2 } from 'lucide-react' import { useSearchParams } from 'next/navigation' +import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes' import { InviteLayout } from '@/app/invite/components' interface UnsubscribeData { @@ -143,10 +144,7 @@ function UnsubscribeContent() {
-
@@ -168,10 +166,7 @@ function UnsubscribeContent() {
-
@@ -193,10 +188,7 @@ function UnsubscribeContent() {
-
@@ -222,7 +214,7 @@ function UnsubscribeContent() { ))} - {filter && ( + {onFilterToggle ? ( + + ) : filter ? ( @@ -111,15 +134,13 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({ {filter} - )} + ) : null} {sort && } @@ -128,34 +149,6 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({ }) 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} @@ -177,8 +170,8 @@ const SearchSection = memo(function SearchSection({ search }: { search: SearchCo search.onChange(e.target.value)} onKeyDown={search.onKeyDown} onFocus={search.onFocus} onBlur={search.onBlur} @@ -186,11 +179,11 @@ const SearchSection = memo(function SearchSection({ search }: { search: SearchCo 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 || localValue ? ( + {search.tags?.length || search.value ? ( @@ -213,8 +206,19 @@ const SortDropdown = memo(function SortDropdown({ config }: { config: SortConfig return ( - diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx index 06678e6acd3..263498f86e2 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx @@ -1,6 +1,7 @@ 'use client' -import { memo, useMemo, useRef } from 'react' +import { createContext, memo, useContext, useMemo, useRef } from 'react' +import type { Components, ExtraProps } from 'react-markdown' import ReactMarkdown from 'react-markdown' import remarkBreaks from 'remark-breaks' import remarkGfm from 'remark-gfm' @@ -70,34 +71,51 @@ export const PreviewPanel = memo(function PreviewPanel({ const REMARK_PLUGINS = [remarkGfm, remarkBreaks] +/** + * Carries the contentRef and toggle handler from MarkdownPreview down to the + * task-list renderers. Only present when the preview is interactive. + */ +const MarkdownCheckboxCtx = createContext<{ + contentRef: React.MutableRefObject + onToggle: (index: number, checked: boolean) => void +} | null>(null) + +/** Carries the resolved checkbox index from LiRenderer to InputRenderer. */ +const CheckboxIndexCtx = createContext(-1) + const STATIC_MARKDOWN_COMPONENTS = { - p: ({ children }: any) => ( + p: ({ children }: { children?: React.ReactNode }) => (

{children}

), - h1: ({ children }: any) => ( + h1: ({ children }: { children?: React.ReactNode }) => (

{children}

), - h2: ({ children }: any) => ( + h2: ({ children }: { children?: React.ReactNode }) => (

{children}

), - h3: ({ children }: any) => ( + h3: ({ children }: { children?: React.ReactNode }) => (

{children}

), - h4: ({ children }: any) => ( + h4: ({ children }: { children?: React.ReactNode }) => (

{children}

), - code: ({ inline, className, children, ...props }: any) => { - const isInline = inline || !className?.includes('language-') + code: ({ + className, + children, + node: _node, + ...props + }: React.HTMLAttributes & ExtraProps) => { + const isInline = !className?.includes('language-') if (isInline) { return ( @@ -119,8 +137,8 @@ const STATIC_MARKDOWN_COMPONENTS = { ) }, - pre: ({ children }: any) => <>{children}, - a: ({ href, children }: any) => ( + pre: ({ children }: { children?: React.ReactNode }) => <>{children}, + a: ({ href, children }: { href?: string; children?: React.ReactNode }) => ( ), - strong: ({ children }: any) => ( + strong: ({ children }: { children?: React.ReactNode }) => ( {children} ), - em: ({ children }: any) => ( + em: ({ children }: { children?: React.ReactNode }) => ( {children} ), - blockquote: ({ children }: any) => ( + blockquote: ({ children }: { children?: React.ReactNode }) => (
{children}
), hr: () =>
, - img: ({ src, alt }: any) => ( + img: ({ src, alt, node: _node }: React.ComponentPropsWithoutRef<'img'> & ExtraProps) => ( {alt ), - table: ({ children }: any) => ( + table: ({ children }: { children?: React.ReactNode }) => (
{children}
), - thead: ({ children }: any) => {children}, - tbody: ({ children }: any) => {children}, - tr: ({ children }: any) => ( + thead: ({ children }: { children?: React.ReactNode }) => ( + {children} + ), + tbody: ({ children }: { children?: React.ReactNode }) => {children}, + tr: ({ children }: { children?: React.ReactNode }) => ( {children} ), - th: ({ children }: any) => ( + th: ({ children }: { children?: React.ReactNode }) => ( {children} ), - td: ({ children }: any) => {children}, + td: ({ children }: { children?: React.ReactNode }) => ( + {children} + ), } -function buildMarkdownComponents( - checkboxCounterRef: React.MutableRefObject, - onCheckboxToggle?: (checkboxIndex: number, checked: boolean) => void -) { - const isInteractive = Boolean(onCheckboxToggle) +function UlRenderer({ className, children }: React.ComponentPropsWithoutRef<'ul'> & ExtraProps) { + const isTaskList = typeof className === 'string' && className.includes('contains-task-list') + return ( +
    + {children} +
+ ) +} - return { - ...STATIC_MARKDOWN_COMPONENTS, - ul: ({ className, children }: any) => { - const isTaskList = typeof className === 'string' && className.includes('contains-task-list') - return ( -
    - {children} -
- ) - }, - ol: ({ className, children }: any) => { - const isTaskList = typeof className === 'string' && className.includes('contains-task-list') - return ( -
    - {children} -
- ) - }, - li: ({ className, children }: any) => { - const isTaskItem = typeof className === 'string' && className.includes('task-list-item') - if (isTaskItem) { +function OlRenderer({ className, children }: React.ComponentPropsWithoutRef<'ol'> & ExtraProps) { + const isTaskList = typeof className === 'string' && className.includes('contains-task-list') + return ( +
    + {children} +
+ ) +} + +function LiRenderer({ + className, + children, + node, +}: React.ComponentPropsWithoutRef<'li'> & ExtraProps) { + const ctx = useContext(MarkdownCheckboxCtx) + const isTaskItem = typeof className === 'string' && className.includes('task-list-item') + + if (isTaskItem) { + if (ctx) { + const offset = node?.position?.start?.offset + if (offset === undefined) { return
  • {children}
  • } - return
  • {children}
  • - }, - input: ({ type, checked, ...props }: any) => { - if (type !== 'checkbox') return - - const index = checkboxCounterRef.current++ - + const before = ctx.contentRef.current.slice(0, offset) + const prior = before.match(/^(\s*(?:[-*+]|\d+[.)]) +)\[([ xX])\]/gm) return ( - onCheckboxToggle!(index, Boolean(newChecked)) - : undefined - } - disabled={!isInteractive} - size='sm' - className='mt-1 shrink-0' - /> + +
  • {children}
  • +
    ) - }, + } + return
  • {children}
  • } + + return
  • {children}
  • +} + +function InputRenderer({ + type, + checked, + node: _node, + ...props +}: React.ComponentPropsWithoutRef<'input'> & ExtraProps) { + const ctx = useContext(MarkdownCheckboxCtx) + const index = useContext(CheckboxIndexCtx) + + if (type !== 'checkbox') return + + const isInteractive = ctx !== null && index >= 0 + + return ( + ctx.onToggle(index, Boolean(newChecked)) : undefined + } + disabled={!isInteractive} + size='sm' + className='mt-1 shrink-0' + /> + ) } +const MARKDOWN_COMPONENTS = { + ...STATIC_MARKDOWN_COMPONENTS, + ul: UlRenderer, + ol: OlRenderer, + li: LiRenderer, + input: InputRenderer, +} satisfies Components + const MarkdownPreview = memo(function MarkdownPreview({ content, isStreaming = false, @@ -238,32 +287,33 @@ const MarkdownPreview = memo(function MarkdownPreview({ const { ref: scrollRef } = useAutoScroll(isStreaming) const { committed, incoming, generation } = useStreamingReveal(content, isStreaming) - const checkboxCounterRef = useRef(0) + const contentRef = useRef(content) + contentRef.current = content - const components = useMemo( - () => buildMarkdownComponents(checkboxCounterRef, onCheckboxToggle), + const ctxValue = useMemo( + () => (onCheckboxToggle ? { contentRef, onToggle: onCheckboxToggle } : null), [onCheckboxToggle] ) - checkboxCounterRef.current = 0 - const committedMarkdown = useMemo( () => committed ? ( - + {committed} ) : null, - [committed, components] + [committed] ) if (onCheckboxToggle) { return ( -
    - - {content} - -
    + +
    + + {content} + +
    +
    ) } @@ -275,7 +325,7 @@ const MarkdownPreview = memo(function MarkdownPreview({ key={generation} className={cn(isStreaming && 'animate-stream-fade-in', '[&>:first-child]:mt-0')} > - + {incoming} diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 221b658c4df..16be4f3e8cd 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -6,6 +6,8 @@ import { useParams, useRouter } from 'next/navigation' import { Button, Columns2, + Combobox, + type ComboboxOption, Download, DropdownMenu, DropdownMenuContent, @@ -31,17 +33,22 @@ import { formatFileSize, getFileExtension, getMimeTypeFromExtension, + isAudioFileType, + isVideoFileType, } from '@/lib/uploads/utils/file-utils' import { + isSupportedExtension, SUPPORTED_AUDIO_EXTENSIONS, SUPPORTED_DOCUMENT_EXTENSIONS, SUPPORTED_VIDEO_EXTENSIONS, } from '@/lib/uploads/utils/validation' import type { + FilterTag, HeaderAction, ResourceColumn, ResourceRow, SearchConfig, + SortConfig, } from '@/app/workspace/[workspaceId]/components' import { InlineRenameInput, @@ -66,6 +73,7 @@ import { useUploadWorkspaceFile, useWorkspaceFiles, } from '@/hooks/queries/workspace-files' +import { useDebounce } from '@/hooks/use-debounce' import { useInlineRename } from '@/hooks/use-inline-rename' type SaveStatus = 'idle' | 'saving' | 'saved' | 'error' @@ -86,7 +94,6 @@ const COLUMNS: ResourceColumn[] = [ { id: 'type', header: 'Type' }, { id: 'created', header: 'Created' }, { id: 'owner', header: 'Owner' }, - { id: 'updated', header: 'Last Updated' }, ] const MIME_TYPE_LABELS: Record = { @@ -161,16 +168,14 @@ export function Files() { const [uploading, setUploading] = useState(false) const [uploadProgress, setUploadProgress] = useState({ completed: 0, total: 0 }) const [inputValue, setInputValue] = useState('') - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('') - const searchTimerRef = useRef>(null) - - const handleSearchChange = useCallback((value: string) => { - setInputValue(value) - if (searchTimerRef.current) clearTimeout(searchTimerRef.current) - searchTimerRef.current = setTimeout(() => { - setDebouncedSearchTerm(value) - }, 200) - }, []) + const debouncedSearchTerm = useDebounce(inputValue, 200) + const [activeSort, setActiveSort] = useState<{ + column: string + direction: 'asc' | 'desc' + } | null>(null) + const [typeFilter, setTypeFilter] = useState([]) + const [sizeFilter, setSizeFilter] = useState([]) + const [uploadedByFilter, setUploadedByFilter] = useState([]) const [creatingFile, setCreatingFile] = useState(false) const [isDirty, setIsDirty] = useState(false) @@ -206,10 +211,60 @@ export function Files() { selectedFileRef.current = selectedFile const filteredFiles = useMemo(() => { - if (!debouncedSearchTerm) return files - const q = debouncedSearchTerm.toLowerCase() - return files.filter((f) => f.name.toLowerCase().includes(q)) - }, [files, debouncedSearchTerm]) + let result = debouncedSearchTerm + ? files.filter((f) => f.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) + : files + + if (typeFilter.length > 0) { + result = result.filter((f) => { + const ext = getFileExtension(f.name) + if (typeFilter.includes('document') && isSupportedExtension(ext)) return true + if (typeFilter.includes('audio') && isAudioFileType(f.type)) return true + if (typeFilter.includes('video') && isVideoFileType(f.type)) return true + return false + }) + } + + if (sizeFilter.length > 0) { + result = result.filter((f) => { + if (sizeFilter.includes('small') && f.size < 1_048_576) return true + if (sizeFilter.includes('medium') && f.size >= 1_048_576 && f.size <= 10_485_760) + return true + if (sizeFilter.includes('large') && f.size > 10_485_760) return true + return false + }) + } + + if (uploadedByFilter.length > 0) { + result = result.filter((f) => uploadedByFilter.includes(f.uploadedBy)) + } + + const col = activeSort?.column ?? 'created' + const dir = activeSort?.direction ?? 'desc' + return [...result].sort((a, b) => { + let cmp = 0 + switch (col) { + case 'name': + cmp = a.name.localeCompare(b.name) + break + case 'size': + cmp = a.size - b.size + break + case 'type': + cmp = formatFileType(a.type, a.name).localeCompare(formatFileType(b.type, b.name)) + break + case 'created': + cmp = new Date(a.uploadedAt).getTime() - new Date(b.uploadedAt).getTime() + break + case 'owner': + cmp = (members?.find((m) => m.userId === a.uploadedBy)?.name ?? '').localeCompare( + members?.find((m) => m.userId === b.uploadedBy)?.name ?? '' + ) + break + } + return dir === 'asc' ? cmp : -cmp + }) + }, [files, debouncedSearchTerm, typeFilter, sizeFilter, uploadedByFilter, activeSort, members]) const rowCacheRef = useRef( new Map() @@ -245,12 +300,6 @@ export function Files() { }, created: timeCell(file.uploadedAt), owner: ownerCell(file.uploadedBy, members), - updated: timeCell(file.uploadedAt), - }, - sortValues: { - size: file.size, - created: -new Date(file.uploadedAt).getTime(), - updated: -new Date(file.uploadedAt).getTime(), }, } nextCache.set(file.id, { row, file, members }) @@ -342,7 +391,7 @@ export function Files() { } } }, - [workspaceId] + [workspaceId, uploadFile] ) const handleDownload = useCallback(async (file: WorkspaceFileRecord) => { @@ -690,7 +739,6 @@ export function Files() { handleDeleteSelected, ]) - /** Stable refs for values used in callbacks to avoid dependency churn */ const listRenameRef = useRef(listRename) listRenameRef.current = listRename const headerRenameRef = useRef(headerRename) @@ -711,18 +759,14 @@ export function Files() { const canEdit = userPermissions.canEdit === true - const handleSearchClearAll = useCallback(() => { - handleSearchChange('') - }, [handleSearchChange]) - const searchConfig: SearchConfig = useMemo( () => ({ value: inputValue, - onChange: handleSearchChange, - onClearAll: handleSearchClearAll, + onChange: setInputValue, + onClearAll: () => setInputValue(''), placeholder: 'Search files...', }), - [inputValue, handleSearchChange, handleSearchClearAll] + [inputValue] ) const createConfig = useMemo( @@ -764,6 +808,205 @@ export function Files() { [handleNavigateToFiles] ) + const typeDisplayLabel = useMemo(() => { + if (typeFilter.length === 0) return 'All' + if (typeFilter.length === 1) { + const labels: Record = { + document: 'Documents', + audio: 'Audio', + video: 'Video', + } + return labels[typeFilter[0]] ?? typeFilter[0] + } + return `${typeFilter.length} selected` + }, [typeFilter]) + + const sizeDisplayLabel = useMemo(() => { + if (sizeFilter.length === 0) return 'All' + if (sizeFilter.length === 1) { + const labels: Record = { small: 'Small', medium: 'Medium', large: 'Large' } + return labels[sizeFilter[0]] ?? sizeFilter[0] + } + return `${sizeFilter.length} selected` + }, [sizeFilter]) + + const uploadedByDisplayLabel = useMemo(() => { + if (uploadedByFilter.length === 0) return 'All' + if (uploadedByFilter.length === 1) + return members?.find((m) => m.userId === uploadedByFilter[0])?.name ?? '1 member' + return `${uploadedByFilter.length} members` + }, [uploadedByFilter, members]) + + const memberOptions: ComboboxOption[] = useMemo( + () => + (members ?? []).map((m) => ({ + value: m.userId, + label: m.name, + iconElement: m.image ? ( + {m.name} + ) : ( + + {m.name.charAt(0).toUpperCase()} + + ), + })), + [members] + ) + + const sortConfig: SortConfig = useMemo( + () => ({ + options: [ + { id: 'name', label: 'Name' }, + { id: 'size', label: 'Size' }, + { id: 'type', label: 'Type' }, + { id: 'created', label: 'Created' }, + { id: 'owner', label: 'Owner' }, + ], + active: activeSort, + onSort: (column, direction) => setActiveSort({ column, direction }), + onClear: () => setActiveSort(null), + }), + [activeSort] + ) + + const hasActiveFilters = + typeFilter.length > 0 || sizeFilter.length > 0 || uploadedByFilter.length > 0 + + const filterContent = useMemo( + () => ( +
    +
    + File Type + {typeDisplayLabel} + } + showAllOption + allOptionLabel='All' + size='sm' + className='h-[32px] w-full rounded-md' + /> +
    +
    + Size + 10 MB)' }, + ]} + multiSelect + multiSelectValues={sizeFilter} + onMultiSelectChange={setSizeFilter} + overlayContent={ + {sizeDisplayLabel} + } + showAllOption + allOptionLabel='All' + size='sm' + className='h-[32px] w-full rounded-md' + /> +
    + {memberOptions.length > 0 && ( +
    + + Uploaded By + + + {uploadedByDisplayLabel} + + } + searchable + searchPlaceholder='Search members...' + showAllOption + allOptionLabel='All' + size='sm' + className='h-[32px] w-full rounded-md' + /> +
    + )} + {hasActiveFilters && ( + + )} +
    + ), + [ + typeFilter, + sizeFilter, + uploadedByFilter, + memberOptions, + typeDisplayLabel, + sizeDisplayLabel, + uploadedByDisplayLabel, + hasActiveFilters, + ] + ) + + const filterTags: FilterTag[] = useMemo(() => { + const tags: FilterTag[] = [] + if (typeFilter.length > 0) { + const typeLabels: Record = { + document: 'Documents', + audio: 'Audio', + video: 'Video', + } + const label = + typeFilter.length === 1 + ? `Type: ${typeLabels[typeFilter[0]]}` + : `Type: ${typeFilter.length} selected` + tags.push({ label, onRemove: () => setTypeFilter([]) }) + } + if (sizeFilter.length > 0) { + const sizeLabels: Record = { + small: 'Small', + medium: 'Medium', + large: 'Large', + } + const label = + sizeFilter.length === 1 + ? `Size: ${sizeLabels[sizeFilter[0]]}` + : `Size: ${sizeFilter.length} selected` + tags.push({ label, onRemove: () => setSizeFilter([]) }) + } + if (uploadedByFilter.length > 0) { + const label = + uploadedByFilter.length === 1 + ? `Uploaded by: ${members?.find((m) => m.userId === uploadedByFilter[0])?.name ?? '1 member'}` + : `Uploaded by: ${uploadedByFilter.length} members` + tags.push({ label, onRemove: () => setUploadedByFilter([]) }) + } + return tags + }, [typeFilter, sizeFilter, uploadedByFilter, members]) + if (fileIdFromRoute && !selectedFile) { return (
    @@ -834,7 +1077,9 @@ export function Files() { title='Files' create={createConfig} search={searchConfig} - defaultSort='created' + sort={sortConfig} + filter={filterContent} + filterTags={filterTags} headerActions={headerActionsConfig} columns={COLUMNS} rows={rows} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx index e6c03863f0b..384666e3e07 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx @@ -91,7 +91,9 @@ export function MothershipChat({ }: MothershipChatProps) { const styles = LAYOUT_STYLES[layout] const isStreamActive = isSending || isReconnecting - const { ref: scrollContainerRef, scrollToBottom } = useAutoScroll(isStreamActive) + const { ref: scrollContainerRef, scrollToBottom } = useAutoScroll(isStreamActive, { + scrollOnMount: true, + }) const hasMessages = messages.length > 0 const initialScrollDoneRef = useRef(false) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx index b830123d1a0..2735afc993e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx @@ -571,19 +571,19 @@ export function UserInput({ const items = e.clipboardData?.items if (!items) return - const imageFiles: File[] = [] + const pastedFiles: File[] = [] for (const item of Array.from(items)) { - if (item.kind === 'file' && item.type.startsWith('image/')) { + if (item.kind === 'file') { const file = item.getAsFile() - if (file) imageFiles.push(file) + if (file) pastedFiles.push(file) } } - if (imageFiles.length === 0) return + if (pastedFiles.length === 0) return e.preventDefault() const dt = new DataTransfer() - for (const file of imageFiles) { + for (const file of pastedFiles) { dt.items.add(file) } filesRef.current.processFiles(dt.files) 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 2bb41401cfc..687babb9f75 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -7,6 +7,7 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation' import { Badge, Button, + Combobox, Modal, ModalBody, ModalContent, @@ -15,7 +16,6 @@ import { Trash, } from '@/components/emcn' import { SearchHighlight } from '@/components/ui/search-highlight' -import { cn } from '@/lib/core/utils/cn' import type { ChunkData } from '@/lib/knowledge/types' import { formatTokenCount } from '@/lib/tokenization' import type { @@ -27,6 +27,7 @@ import type { ResourceRow, SearchConfig, SelectableConfig, + SortConfig, } from '@/app/workspace/[workspaceId]/components' import { Resource, ResourceHeader } from '@/app/workspace/[workspaceId]/components' import { @@ -152,7 +153,16 @@ export function Document({ const [searchQuery, setSearchQuery] = useState('') const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('') - const [enabledFilter, setEnabledFilter] = useState<'all' | 'enabled' | 'disabled'>('all') + const [enabledFilter, setEnabledFilter] = useState([]) + const [activeSort, setActiveSort] = useState<{ + column: string + direction: 'asc' | 'desc' + } | null>(null) + + const enabledFilterParam = useMemo( + () => (enabledFilter.length === 1 ? (enabledFilter[0] as 'enabled' | 'disabled') : 'all'), + [enabledFilter] + ) const { chunks: initialChunks, @@ -165,7 +175,21 @@ export function Document({ refreshChunks: initialRefreshChunks, updateChunk: initialUpdateChunk, isFetching: isFetchingChunks, - } = useDocumentChunks(knowledgeBaseId, documentId, currentPageFromURL, '', enabledFilter) + } = useDocumentChunks( + knowledgeBaseId, + documentId, + currentPageFromURL, + '', + enabledFilterParam, + activeSort?.column === 'tokens' + ? 'tokenCount' + : activeSort?.column === 'status' + ? 'enabled' + : activeSort + ? 'chunkIndex' + : undefined, + activeSort?.direction + ) const { data: searchResults = [], error: searchQueryError } = useDocumentChunkSearchQuery( { @@ -229,7 +253,10 @@ export function Document({ searchStartIndex + SEARCH_PAGE_SIZE ) - const displayChunks = showingSearch ? paginatedSearchResults : initialChunks + const rawDisplayChunks = showingSearch ? paginatedSearchResults : initialChunks + + const displayChunks = rawDisplayChunks ?? [] + const currentPage = showingSearch ? searchCurrentPage : initialPage const totalPages = showingSearch ? searchTotalPages : initialTotalPages const hasNextPage = showingSearch ? searchCurrentPage < searchTotalPages : initialHasNextPage @@ -562,47 +589,68 @@ export function Document({ } : undefined - const filterContent = ( -
    -
    - Status -
    -
    - {(['all', 'enabled', 'disabled'] as const).map((value) => ( + const enabledDisplayLabel = useMemo(() => { + if (enabledFilter.length === 0) return 'All' + if (enabledFilter.length === 1) return enabledFilter[0] === 'enabled' ? 'Enabled' : 'Disabled' + return `${enabledFilter.length} selected` + }, [enabledFilter]) + + const filterContent = useMemo( + () => ( +
    +
    + Status + { + setEnabledFilter(values) + setSelectedChunks(new Set()) + void goToPage(1) + }} + overlayContent={ + {enabledDisplayLabel} + } + showAllOption + allOptionLabel='All' + size='sm' + className='h-[32px] w-full rounded-md' + /> +
    + {enabledFilter.length > 0 && ( - ))} + )}
    -
    + ), + [enabledFilter, enabledDisplayLabel, goToPage] ) - const filterTags: FilterTag[] = [ - ...(enabledFilter !== 'all' - ? [ - { - label: `Status: ${enabledFilter === 'enabled' ? 'Enabled' : 'Disabled'}`, - onRemove: () => { - setEnabledFilter('all') - setSelectedChunks(new Set()) - void goToPage(1) - }, - }, - ] - : []), - ] + const filterTags: FilterTag[] = useMemo( + () => + enabledFilter.map((value) => ({ + label: `Status: ${value === 'enabled' ? 'Enabled' : 'Disabled'}`, + onRemove: () => { + setEnabledFilter((prev) => prev.filter((v) => v !== value)) + setSelectedChunks(new Set()) + void goToPage(1) + }, + })), + [enabledFilter, goToPage] + ) const handleChunkClick = useCallback((rowId: string) => { setSelectedChunkId(rowId) @@ -814,6 +862,26 @@ export function Document({ } : undefined + const sortConfig: SortConfig = useMemo( + () => ({ + options: [ + { id: 'index', label: 'Index' }, + { id: 'tokens', label: 'Tokens' }, + { id: 'status', label: 'Status' }, + ], + active: activeSort, + onSort: (column, direction) => { + setActiveSort({ column, direction }) + void goToPage(1) + }, + onClear: () => { + setActiveSort(null) + void goToPage(1) + }, + }), + [activeSort, goToPage] + ) + const chunkRows: ResourceRow[] = useMemo(() => { if (!isCompleted) { return [ @@ -1100,6 +1168,7 @@ export function Document({ emptyMessage={emptyMessage} filter={combinedError ? undefined : filterContent} filterTags={combinedError ? undefined : filterTags} + sort={combinedError ? undefined : sortConfig} /> ('all') + const [enabledFilter, setEnabledFilter] = useState([]) const [tagFilterEntries, setTagFilterEntries] = useState< { id: string @@ -235,6 +235,17 @@ export function KnowledgeBase({ [tagFilterEntries] ) + const enabledFilterParam = useMemo<'all' | 'enabled' | 'disabled'>(() => { + if (enabledFilter.length === 1) return enabledFilter[0] as 'enabled' | 'disabled' + return 'all' + }, [enabledFilter]) + + const enabledDisplayLabel = useMemo(() => { + if (enabledFilter.length === 0) return 'All' + if (enabledFilter.length === 1) return enabledFilter[0] === 'enabled' ? 'Enabled' : 'Disabled' + return '2 selected' + }, [enabledFilter]) + const handleSearchChange = useCallback((newQuery: string) => { setSearchQuery(newQuery) setCurrentPage(1) @@ -249,8 +260,10 @@ export function KnowledgeBase({ const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false) const [showConnectorsModal, setShowConnectorsModal] = useState(false) const [currentPage, setCurrentPage] = useState(1) - const [sortBy, setSortBy] = useState('uploadedAt') - const [sortOrder, setSortOrder] = useState('desc') + const [activeSort, setActiveSort] = useState<{ + column: string + direction: 'asc' | 'desc' + } | null>(null) const [contextMenuDocument, setContextMenuDocument] = useState(null) const [showRenameModal, setShowRenameModal] = useState(false) const [documentToRename, setDocumentToRename] = useState(null) @@ -290,8 +303,8 @@ export function KnowledgeBase({ search: searchQuery || undefined, limit: DOCUMENTS_PER_PAGE, offset: (currentPage - 1) * DOCUMENTS_PER_PAGE, - sortBy, - sortOrder, + sortBy: (activeSort?.column ?? 'uploadedAt') as DocumentSortField, + sortOrder: (activeSort?.direction ?? 'desc') as SortOrder, refetchInterval: (data) => { if (isDeleting) return false const hasPending = data?.documents?.some( @@ -301,7 +314,7 @@ export function KnowledgeBase({ if (hasSyncingConnectorsRef.current) return 5000 return false }, - enabledFilter, + enabledFilter: enabledFilterParam, tagFilters: activeTagFilters.length > 0 ? activeTagFilters : undefined, }) @@ -571,7 +584,7 @@ export function KnowledgeBase({ knowledgeBaseId: id, operation: 'enable', selectAll: true, - enabledFilter, + enabledFilter: enabledFilterParam, }, { onSuccess: (result) => { @@ -618,7 +631,7 @@ export function KnowledgeBase({ knowledgeBaseId: id, operation: 'disable', selectAll: true, - enabledFilter, + enabledFilter: enabledFilterParam, }, { onSuccess: (result) => { @@ -667,7 +680,7 @@ export function KnowledgeBase({ knowledgeBaseId: id, operation: 'delete', selectAll: true, - enabledFilter, + enabledFilter: enabledFilterParam, }, { onSuccess: (result) => { @@ -707,12 +720,12 @@ export function KnowledgeBase({ const selectedDocumentsList = documents.filter((doc) => selectedDocuments.has(doc.id)) const enabledCount = isSelectAllMode - ? enabledFilter === 'disabled' + ? enabledFilterParam === 'disabled' ? 0 : pagination.total : selectedDocumentsList.filter((doc) => doc.enabled).length const disabledCount = isSelectAllMode - ? enabledFilter === 'enabled' + ? enabledFilterParam === 'enabled' ? 0 : pagination.total : selectedDocumentsList.filter((doc) => !doc.enabled).length @@ -795,59 +808,83 @@ export function KnowledgeBase({ : []), ] - const sortConfig: SortConfig = { - options: [ - { id: 'filename', label: 'Name' }, - { id: 'fileSize', label: 'Size' }, - { id: 'tokenCount', label: 'Tokens' }, - { id: 'chunkCount', label: 'Chunks' }, - { id: 'uploadedAt', label: 'Uploaded' }, - { id: 'enabled', label: 'Status' }, - ], - active: { column: sortBy, direction: sortOrder }, - onSort: (column, direction) => { - setSortBy(column as DocumentSortField) - setSortOrder(direction) - setCurrentPage(1) - }, - } + const sortConfig: SortConfig = useMemo( + () => ({ + options: [ + { id: 'filename', label: 'Name' }, + { id: 'fileSize', label: 'Size' }, + { id: 'tokenCount', label: 'Tokens' }, + { id: 'chunkCount', label: 'Chunks' }, + { id: 'uploadedAt', label: 'Uploaded' }, + { id: 'enabled', label: 'Status' }, + ], + active: activeSort, + onSort: (column, direction) => { + setActiveSort({ column, direction }) + setCurrentPage(1) + }, + onClear: () => { + setActiveSort(null) + setCurrentPage(1) + }, + }), + [activeSort] + ) - const filterContent = ( -
    -
    - Status -
    -
    - {(['all', 'enabled', 'disabled'] as const).map((value) => ( + const filterContent = useMemo( + () => ( +
    +
    + Status + { + setEnabledFilter(values) + setCurrentPage(1) + setSelectedDocuments(new Set()) + setIsSelectAllMode(false) + }} + overlayContent={ + {enabledDisplayLabel} + } + showAllOption + allOptionLabel='All' + size='sm' + className='h-[32px] w-full rounded-md' + /> +
    + {enabledFilter.length > 0 && ( - ))} + )} + { + setTagFilterEntries(entries) + setCurrentPage(1) + setSelectedDocuments(new Set()) + setIsSelectAllMode(false) + }} + />
    - { - setTagFilterEntries(entries) - setCurrentPage(1) - setSelectedDocuments(new Set()) - setIsSelectAllMode(false) - }} - /> -
    + ), + [enabledFilter, enabledDisplayLabel, tagDefinitions, tagFilterEntries] ) const connectorBadges = @@ -871,33 +908,39 @@ export function KnowledgeBase({ ) : null - const filterTags: FilterTag[] = [ - ...(enabledFilter !== 'all' - ? [ - { - label: `Status: ${enabledFilter === 'enabled' ? 'Enabled' : 'Disabled'}`, - onRemove: () => { - setEnabledFilter('all') - setCurrentPage(1) - setSelectedDocuments(new Set()) - setIsSelectAllMode(false) + const filterTags: FilterTag[] = useMemo( + () => [ + ...(enabledFilter.length > 0 + ? [ + { + label: + enabledFilter.length === 1 + ? `Status: ${enabledFilter[0] === 'enabled' ? 'Enabled' : 'Disabled'}` + : 'Status: 2 selected', + onRemove: () => { + setEnabledFilter([]) + setCurrentPage(1) + setSelectedDocuments(new Set()) + setIsSelectAllMode(false) + }, }, + ] + : []), + ...tagFilterEntries + .filter((f) => f.tagSlot && f.value.trim()) + .map((f) => ({ + label: `${f.tagName}: ${f.value}`, + onRemove: () => { + const updated = tagFilterEntries.filter((e) => e.id !== f.id) + setTagFilterEntries(updated) + setCurrentPage(1) + setSelectedDocuments(new Set()) + setIsSelectAllMode(false) }, - ] - : []), - ...tagFilterEntries - .filter((f) => f.tagSlot && f.value.trim()) - .map((f) => ({ - label: `${f.tagName}: ${f.value}`, - onRemove: () => { - const updated = tagFilterEntries.filter((_, idx) => idx !== tagFilterEntries.indexOf(f)) - setTagFilterEntries(updated) - setCurrentPage(1) - setSelectedDocuments(new Set()) - setIsSelectAllMode(false) - }, - })), - ] + })), + ], + [enabledFilter, tagFilterEntries] + ) const selectableConfig: SelectableConfig = { selectedIds: selectedDocuments, @@ -922,7 +965,7 @@ export function KnowledgeBase({ content: ( -
    {getStatusBadge(doc)}
    +
    {getStatusBadge(doc)}
    {doc.processingError} @@ -1019,7 +1062,7 @@ export function KnowledgeBase({ const emptyMessage = searchQuery ? 'No documents found' - : enabledFilter !== 'all' || activeTagFilters.length > 0 + : enabledFilter.length > 0 || activeTagFilters.length > 0 ? 'Nothing matches your filter' : undefined diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx index 46ee5efdbe4..58bd4ceddae 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx @@ -3,15 +3,18 @@ import { useCallback, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { useParams, useRouter } from 'next/navigation' -import { Tooltip } from '@/components/emcn' +import type { ComboboxOption } from '@/components/emcn' +import { Combobox, Tooltip } from '@/components/emcn' import { Database } from '@/components/emcn/icons' import type { KnowledgeBaseData } from '@/lib/knowledge/types' import type { CreateAction, + FilterTag, ResourceCell, ResourceColumn, ResourceRow, SearchConfig, + SortConfig, } from '@/app/workspace/[workspaceId]/components' import { ownerCell, Resource, timeCell } from '@/app/workspace/[workspaceId]/components' import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components' @@ -29,6 +32,7 @@ import { CONNECTOR_REGISTRY } from '@/connectors/registry' 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') @@ -98,21 +102,16 @@ export function Knowledge() { const { mutateAsync: updateKnowledgeBaseMutation } = useUpdateKnowledgeBase(workspaceId) const { mutateAsync: deleteKnowledgeBaseMutation } = useDeleteKnowledgeBase(workspaceId) - const [searchInputValue, setSearchInputValue] = useState('') - const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('') - const searchTimerRef = useRef>(null) - - const handleSearchChange = useCallback((value: string) => { - setSearchInputValue(value) - if (searchTimerRef.current) clearTimeout(searchTimerRef.current) - searchTimerRef.current = setTimeout(() => { - setDebouncedSearchQuery(value) - }, 300) - }, []) + const [activeSort, setActiveSort] = useState<{ + column: string + direction: 'asc' | 'desc' + } | null>(null) + const [connectorFilter, setConnectorFilter] = useState([]) + const [contentFilter, setContentFilter] = useState([]) + const [ownerFilter, setOwnerFilter] = useState([]) - const handleSearchClearAll = useCallback(() => { - handleSearchChange('') - }, [handleSearchChange]) + const [searchInputValue, setSearchInputValue] = useState('') + const debouncedSearchQuery = useDebounce(searchInputValue, 300) const [isCreateModalOpen, setIsCreateModalOpen] = useState(false) @@ -184,14 +183,77 @@ export function Knowledge() { [deleteKnowledgeBaseMutation] ) - const filteredKnowledgeBases = useMemo( - () => filterKnowledgeBases(knowledgeBases, debouncedSearchQuery), - [knowledgeBases, debouncedSearchQuery] - ) + const processedKBs = useMemo(() => { + let result = filterKnowledgeBases(knowledgeBases, debouncedSearchQuery) + + if (connectorFilter.length > 0) { + result = result.filter((kb) => { + const hasConnectors = (kb.connectorTypes?.length ?? 0) > 0 + if (connectorFilter.includes('connected') && hasConnectors) return true + if (connectorFilter.includes('unconnected') && !hasConnectors) return true + return false + }) + } + + if (contentFilter.length > 0) { + const docCount = (kb: KnowledgeBaseData) => (kb as KnowledgeBaseWithDocCount).docCount ?? 0 + result = result.filter((kb) => { + if (contentFilter.includes('has-docs') && docCount(kb) > 0) return true + if (contentFilter.includes('empty') && docCount(kb) === 0) return true + return false + }) + } + + if (ownerFilter.length > 0) { + result = result.filter((kb) => ownerFilter.includes(kb.userId)) + } + + const col = activeSort?.column ?? 'created' + const dir = activeSort?.direction ?? 'desc' + return [...result].sort((a, b) => { + let cmp = 0 + switch (col) { + case 'name': + cmp = a.name.localeCompare(b.name) + break + case 'documents': + cmp = + ((a as KnowledgeBaseWithDocCount).docCount || 0) - + ((b as KnowledgeBaseWithDocCount).docCount || 0) + break + case 'tokens': + cmp = (a.tokenCount || 0) - (b.tokenCount || 0) + break + case 'created': + cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + break + case 'updated': + cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime() + break + case 'connectors': + cmp = (a.connectorTypes?.length ?? 0) - (b.connectorTypes?.length ?? 0) + break + case 'owner': + cmp = (members?.find((m) => m.userId === a.userId)?.name ?? '').localeCompare( + members?.find((m) => m.userId === b.userId)?.name ?? '' + ) + break + } + return dir === 'asc' ? cmp : -cmp + }) + }, [ + knowledgeBases, + debouncedSearchQuery, + connectorFilter, + contentFilter, + ownerFilter, + activeSort, + members, + ]) const rows: ResourceRow[] = useMemo( () => - filteredKnowledgeBases.map((kb) => { + processedKBs.map((kb) => { const kbWithCount = kb as KnowledgeBaseWithDocCount return { id: kb.id, @@ -211,16 +273,9 @@ export function Knowledge() { owner: ownerCell(kb.userId, members), updated: timeCell(kb.updatedAt), }, - sortValues: { - documents: kbWithCount.docCount || 0, - tokens: kb.tokenCount || 0, - connectors: kb.connectorTypes?.length || 0, - created: -new Date(kb.createdAt).getTime(), - updated: -new Date(kb.updatedAt).getTime(), - }, } }), - [filteredKnowledgeBases, members] + [processedKBs, members] ) const handleRowClick = useCallback( @@ -303,13 +358,190 @@ export function Knowledge() { const searchConfig: SearchConfig = useMemo( () => ({ value: searchInputValue, - onChange: handleSearchChange, - onClearAll: handleSearchClearAll, + onChange: setSearchInputValue, + onClearAll: () => setSearchInputValue(''), placeholder: 'Search knowledge bases...', }), - [searchInputValue, handleSearchChange, handleSearchClearAll] + [searchInputValue] ) + const sortConfig: SortConfig = useMemo( + () => ({ + options: [ + { id: 'name', label: 'Name' }, + { id: 'documents', label: 'Documents' }, + { id: 'tokens', label: 'Tokens' }, + { id: 'connectors', label: 'Connectors' }, + { id: 'created', label: 'Created' }, + { id: 'updated', label: 'Last Updated' }, + { id: 'owner', label: 'Owner' }, + ], + active: activeSort, + onSort: (column, direction) => setActiveSort({ column, direction }), + onClear: () => setActiveSort(null), + }), + [activeSort] + ) + + const connectorDisplayLabel = useMemo(() => { + if (connectorFilter.length === 0) return 'All' + if (connectorFilter.length === 1) + return connectorFilter[0] === 'connected' ? 'With connectors' : 'Without connectors' + return `${connectorFilter.length} selected` + }, [connectorFilter]) + + const contentDisplayLabel = useMemo(() => { + if (contentFilter.length === 0) return 'All' + if (contentFilter.length === 1) + return contentFilter[0] === 'has-docs' ? 'Has documents' : 'Empty' + return `${contentFilter.length} selected` + }, [contentFilter]) + + const ownerDisplayLabel = useMemo(() => { + if (ownerFilter.length === 0) return 'All' + if (ownerFilter.length === 1) + return members?.find((m) => m.userId === ownerFilter[0])?.name ?? '1 member' + return `${ownerFilter.length} members` + }, [ownerFilter, members]) + + const memberOptions: ComboboxOption[] = useMemo( + () => + (members ?? []).map((m) => ({ + value: m.userId, + label: m.name, + iconElement: m.image ? ( + {m.name} + ) : ( + + {m.name.charAt(0).toUpperCase()} + + ), + })), + [members] + ) + + const hasActiveFilters = + connectorFilter.length > 0 || contentFilter.length > 0 || ownerFilter.length > 0 + + const filterContent = useMemo( + () => ( +
    +
    + Connectors + {connectorDisplayLabel} + } + showAllOption + allOptionLabel='All' + size='sm' + className='h-[32px] w-full rounded-md' + /> +
    +
    + Content + {contentDisplayLabel} + } + showAllOption + allOptionLabel='All' + size='sm' + className='h-[32px] w-full rounded-md' + /> +
    + {memberOptions.length > 0 && ( +
    + Owner + {ownerDisplayLabel} + } + searchable + searchPlaceholder='Search members...' + showAllOption + allOptionLabel='All' + size='sm' + className='h-[32px] w-full rounded-md' + /> +
    + )} + {hasActiveFilters && ( + + )} +
    + ), + [ + connectorFilter, + contentFilter, + ownerFilter, + memberOptions, + connectorDisplayLabel, + contentDisplayLabel, + ownerDisplayLabel, + hasActiveFilters, + ] + ) + + const filterTags: FilterTag[] = useMemo(() => { + const tags: FilterTag[] = [] + if (connectorFilter.length > 0) { + const label = + connectorFilter.length === 1 + ? `Connectors: ${connectorFilter[0] === 'connected' ? 'With connectors' : 'Without connectors'}` + : `Connectors: ${connectorFilter.length} types` + tags.push({ label, onRemove: () => setConnectorFilter([]) }) + } + if (contentFilter.length > 0) { + const label = + contentFilter.length === 1 + ? `Content: ${contentFilter[0] === 'has-docs' ? 'Has documents' : 'Empty'}` + : `Content: ${contentFilter.length} types` + tags.push({ label, onRemove: () => setContentFilter([]) }) + } + if (ownerFilter.length > 0) { + const label = + ownerFilter.length === 1 + ? `Owner: ${members?.find((m) => m.userId === ownerFilter[0])?.name ?? '1 member'}` + : `Owner: ${ownerFilter.length} members` + tags.push({ label, onRemove: () => setOwnerFilter([]) }) + } + return tags + }, [connectorFilter, contentFilter, ownerFilter, members]) + return ( <> void>(() => {}) const logsQueryRef = useRef({ isFetching: false, hasNextPage: false, fetchNextPage: () => {} }) const [isNotificationSettingsOpen, setIsNotificationSettingsOpen] = useState(false) + const [activeSort, setActiveSort] = useState<{ + column: string + direction: 'asc' | 'desc' + } | null>(null) const userPermissions = useUserPermissionsContext() const [contextMenuOpen, setContextMenuOpen] = useState(false) @@ -358,11 +363,43 @@ export default function Logs() { return logsQuery.data.pages.flatMap((page) => page.logs) }, [logsQuery.data?.pages]) + const sortedLogs = useMemo(() => { + if (!activeSort) return logs + + const { column, direction } = activeSort + return [...logs].sort((a, b) => { + let cmp = 0 + switch (column) { + case 'date': + cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + break + case 'duration': { + const aDuration = parseDuration({ duration: a.duration ?? undefined }) ?? -1 + const bDuration = parseDuration({ duration: b.duration ?? undefined }) ?? -1 + cmp = aDuration - bDuration + break + } + case 'cost': { + const aCost = typeof a.cost?.total === 'number' ? a.cost.total : -1 + const bCost = typeof b.cost?.total === 'number' ? b.cost.total : -1 + cmp = aCost - bCost + break + } + case 'status': + cmp = (a.status ?? '').localeCompare(b.status ?? '') + break + default: + break + } + return direction === 'asc' ? cmp : -cmp + }) + }, [logs, activeSort]) + const selectedLogIndex = useMemo( - () => (selectedLogId ? logs.findIndex((l) => l.id === selectedLogId) : -1), - [logs, selectedLogId] + () => (selectedLogId ? sortedLogs.findIndex((l) => l.id === selectedLogId) : -1), + [sortedLogs, selectedLogId] ) - const selectedLogFromList = selectedLogIndex >= 0 ? logs[selectedLogIndex] : null + const selectedLogFromList = selectedLogIndex >= 0 ? sortedLogs[selectedLogIndex] : null const selectedLog = useMemo(() => { if (!selectedLogFromList) return null @@ -381,8 +418,8 @@ export default function Logs() { useFolders(workspaceId) useEffect(() => { - logsRef.current = logs - }, [logs]) + logsRef.current = sortedLogs + }, [sortedLogs]) useEffect(() => { selectedLogIndexRef.current = selectedLogIndex }, [selectedLogIndex]) @@ -443,12 +480,12 @@ export default function Logs() { const handleLogContextMenu = useCallback( (e: React.MouseEvent, rowId: string) => { e.preventDefault() - const log = logs.find((l) => l.id === rowId) ?? null + const log = sortedLogs.find((l) => l.id === rowId) ?? null setContextMenuPosition({ x: e.clientX, y: e.clientY }) setContextMenuLog(log) setContextMenuOpen(true) }, - [logs] + [sortedLogs] ) const handleCopyExecutionId = useCallback(() => { @@ -659,7 +696,7 @@ export default function Logs() { const rows: ResourceRow[] = useMemo( () => - logs.map((log) => { + sortedLogs.map((log) => { const formattedDate = formatDate(log.createdAt) const displayStatus = getDisplayStatus(log.status) const isMothershipJob = log.trigger === 'mothership' @@ -710,7 +747,7 @@ export default function Logs() { }, } }), - [logs] + [sortedLogs] ) const sidebarOverlay = useMemo( @@ -721,7 +758,7 @@ export default function Logs() { onClose={handleCloseSidebar} onNavigateNext={handleNavigateNext} onNavigatePrev={handleNavigatePrev} - hasNext={selectedLogIndex < logs.length - 1} + hasNext={selectedLogIndex < sortedLogs.length - 1} hasPrev={selectedLogIndex > 0} /> ), @@ -732,7 +769,7 @@ export default function Logs() { handleNavigateNext, handleNavigatePrev, selectedLogIndex, - logs.length, + sortedLogs.length, ] ) @@ -978,6 +1015,21 @@ export default function Logs() { [appliedFilters, textSearch, removeBadge, handleFiltersChange] ) + const sortConfig = useMemo( + () => ({ + options: [ + { id: 'date', label: 'Date' }, + { id: 'duration', label: 'Duration' }, + { id: 'cost', label: 'Cost' }, + { id: 'status', label: 'Status' }, + ], + active: activeSort, + onSort: (column, direction) => setActiveSort({ column, direction }), + onClear: () => setActiveSort(null), + }), + [activeSort] + ) + const searchConfig = useMemo( () => ({ value: currentInput, @@ -1021,7 +1073,7 @@ export default function Logs() { label: 'Export', icon: Download, onClick: handleExport, - disabled: !userPermissions.canEdit || isExporting || logs.length === 0, + disabled: !userPermissions.canEdit || isExporting || sortedLogs.length === 0, }, { label: 'Notifications', @@ -1054,7 +1106,7 @@ export default function Logs() { handleExport, userPermissions.canEdit, isExporting, - logs.length, + sortedLogs.length, handleOpenNotificationSettings, ] ) @@ -1065,6 +1117,7 @@ export default function Logs() { } @@ -1335,7 +1388,7 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr }, [resetFilters, onSearchQueryChange]) return ( -
    +
    Status (null) const [searchQuery, setSearchQuery] = useState('') const debouncedSearchQuery = useDebounce(searchQuery, 300) + const [activeSort, setActiveSort] = useState<{ + column: string + direction: 'asc' | 'desc' + } | null>(null) + const [scheduleTypeFilter, setScheduleTypeFilter] = useState([]) + const [statusFilter, setStatusFilter] = useState([]) + const [healthFilter, setHealthFilter] = useState([]) const visibleItems = useMemo( () => allItems.filter((item) => item.sourceType === 'job' && item.status !== 'completed'), @@ -81,15 +101,68 @@ export function ScheduledTasks() { ) const filteredItems = useMemo(() => { - if (!debouncedSearchQuery) return visibleItems - const q = debouncedSearchQuery.toLowerCase() - return visibleItems.filter((item) => { - const task = item.prompt || '' - return ( - task.toLowerCase().includes(q) || getScheduleDescription(item).toLowerCase().includes(q) - ) + let result = debouncedSearchQuery + ? visibleItems.filter((item) => { + const task = item.prompt || '' + return ( + task.toLowerCase().includes(debouncedSearchQuery.toLowerCase()) || + getScheduleDescription(item).toLowerCase().includes(debouncedSearchQuery.toLowerCase()) + ) + }) + : visibleItems + + if (scheduleTypeFilter.length > 0) { + result = result.filter((item) => { + if (scheduleTypeFilter.includes('recurring') && Boolean(item.cronExpression)) return true + if (scheduleTypeFilter.includes('once') && !item.cronExpression) return true + return false + }) + } + + if (statusFilter.length > 0) { + result = result.filter((item) => { + if (statusFilter.includes('active') && item.status === 'active') return true + if (statusFilter.includes('paused') && item.status === 'disabled') return true + return false + }) + } + + if (healthFilter.includes('has-failures')) { + result = result.filter((item) => (item.failedCount ?? 0) > 0) + } + + const col = activeSort?.column ?? 'nextRun' + const dir = activeSort?.direction ?? 'desc' + return [...result].sort((a, b) => { + let cmp = 0 + switch (col) { + case 'task': + cmp = (a.prompt || '').localeCompare(b.prompt || '') + break + case 'nextRun': + cmp = + (a.nextRunAt ? new Date(a.nextRunAt).getTime() : 0) - + (b.nextRunAt ? new Date(b.nextRunAt).getTime() : 0) + break + case 'lastRun': + cmp = + (a.lastRanAt ? new Date(a.lastRanAt).getTime() : 0) - + (b.lastRanAt ? new Date(b.lastRanAt).getTime() : 0) + break + case 'schedule': + cmp = getScheduleDescription(a).localeCompare(getScheduleDescription(b)) + break + } + return dir === 'asc' ? cmp : -cmp }) - }, [visibleItems, debouncedSearchQuery]) + }, [ + visibleItems, + debouncedSearchQuery, + scheduleTypeFilter, + statusFilter, + healthFilter, + activeSort, + ]) const rows: ResourceRow[] = useMemo( () => @@ -104,10 +177,6 @@ export function ScheduledTasks() { nextRun: timeCell(item.nextRunAt), lastRun: timeCell(item.lastRanAt), }, - sortValues: { - nextRun: item.nextRunAt ? -new Date(item.nextRunAt).getTime() : 0, - lastRun: item.lastRanAt ? -new Date(item.lastRanAt).getTime() : 0, - }, })), [filteredItems] ) @@ -170,6 +239,151 @@ export function ScheduledTasks() { } } + const sortConfig: SortConfig = useMemo( + () => ({ + options: [ + { id: 'task', label: 'Task' }, + { id: 'schedule', label: 'Schedule' }, + { id: 'nextRun', label: 'Next Run' }, + { id: 'lastRun', label: 'Last Run' }, + ], + active: activeSort, + onSort: (column, direction) => setActiveSort({ column, direction }), + onClear: () => setActiveSort(null), + }), + [activeSort] + ) + + const scheduleTypeDisplayLabel = useMemo(() => { + if (scheduleTypeFilter.length === 0) return 'All' + if (scheduleTypeFilter.length === 1) + return scheduleTypeFilter[0] === 'recurring' ? 'Recurring' : 'One-time' + return `${scheduleTypeFilter.length} selected` + }, [scheduleTypeFilter]) + + const statusDisplayLabel = useMemo(() => { + if (statusFilter.length === 0) return 'All' + if (statusFilter.length === 1) return statusFilter[0] === 'active' ? 'Active' : 'Paused' + return `${statusFilter.length} selected` + }, [statusFilter]) + + const healthDisplayLabel = useMemo(() => { + if (healthFilter.length === 0) return 'All' + return 'Has failures' + }, [healthFilter]) + + const hasActiveFilters = + scheduleTypeFilter.length > 0 || statusFilter.length > 0 || healthFilter.length > 0 + + const filterContent = useMemo( + () => ( +
    +
    + + Schedule Type + + + {scheduleTypeDisplayLabel} + + } + showAllOption + allOptionLabel='All' + size='sm' + className='h-[32px] w-full rounded-md' + /> +
    +
    + Status + {statusDisplayLabel} + } + showAllOption + allOptionLabel='All' + size='sm' + className='h-[32px] w-full rounded-md' + /> +
    +
    + Health + {healthDisplayLabel} + } + showAllOption + allOptionLabel='All' + size='sm' + className='h-[32px] w-full rounded-md' + /> +
    + {hasActiveFilters && ( + + )} +
    + ), + [ + scheduleTypeFilter, + statusFilter, + healthFilter, + scheduleTypeDisplayLabel, + statusDisplayLabel, + healthDisplayLabel, + hasActiveFilters, + ] + ) + + const filterTags: FilterTag[] = useMemo(() => { + const tags: FilterTag[] = [] + if (scheduleTypeFilter.length > 0) { + const label = + scheduleTypeFilter.length === 1 + ? `Type: ${scheduleTypeFilter[0] === 'recurring' ? 'Recurring' : 'One-time'}` + : `Type: ${scheduleTypeFilter.length} selected` + tags.push({ label, onRemove: () => setScheduleTypeFilter([]) }) + } + if (statusFilter.length > 0) { + const label = + statusFilter.length === 1 + ? `Status: ${statusFilter[0] === 'active' ? 'Active' : 'Paused'}` + : `Status: ${statusFilter.length} selected` + tags.push({ label, onRemove: () => setStatusFilter([]) }) + } + if (healthFilter.length > 0) { + tags.push({ label: 'Health: Has failures', onRemove: () => setHealthFilter([]) }) + } + return tags + }, [scheduleTypeFilter, statusFilter, healthFilter]) + return ( <> , MothershipResourceType> = { @@ -100,6 +115,7 @@ export function RecentlyDeleted() { const workspaceId = params?.workspaceId as string const [activeTab, setActiveTab] = useState('all') const [searchTerm, setSearchTerm] = useState('') + const [activeSort, setActiveSort] = useState(null) const [restoringIds, setRestoringIds] = useState>(new Set()) const [restoredItems, setRestoredItems] = useState>(new Map()) @@ -174,7 +190,6 @@ export function RecentlyDeleted() { } } - items.sort((a, b) => b.deletedAt.getTime() - a.deletedAt.getTime()) return items }, [ workflowsQuery.data, @@ -191,10 +206,27 @@ export function RecentlyDeleted() { const normalized = searchTerm.toLowerCase() items = items.filter((r) => r.name.toLowerCase().includes(normalized)) } - return items - }, [resources, activeTab, searchTerm]) + const col = (activeSort ?? DEFAULT_SORT).column + const dir = (activeSort ?? DEFAULT_SORT).direction + return [...items].sort((a, b) => { + let cmp = 0 + switch (col) { + case 'name': + cmp = a.name.localeCompare(b.name) + break + case 'type': + cmp = a.type.localeCompare(b.type) + break + case 'deleted': + cmp = a.deletedAt.getTime() - b.deletedAt.getTime() + break + } + return dir === 'asc' ? cmp : -cmp + }) + }, [resources, activeTab, searchTerm, activeSort]) const showNoResults = searchTerm.trim() && filtered.length === 0 && resources.length > 0 + const selectedSort = activeSort ?? DEFAULT_SORT function handleRestore(resource: DeletedResource) { setRestoringIds((prev) => new Set(prev).add(resource.id)) @@ -232,18 +264,41 @@ export function RecentlyDeleted() { return (
    -
    - - setSearchTerm(e.target.value)} - disabled={isLoading} - className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0' - /> +
    +
    + + setSearchTerm(e.target.value)} + disabled={isLoading} + className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0' + /> +
    +
    + { + const option = SORT_OPTIONS.find( + (sortOption) => `${sortOption.column}:${sortOption.direction}` === value + ) + if (option) { + setActiveSort({ column: option.column, direction: option.direction }) + } + }} + options={SORT_OPTIONS.map((option) => ({ + label: option.label, + value: `${option.column}:${option.direction}`, + }))} + className='h-[30px] rounded-lg border-[var(--border)] bg-transparent px-2.5 text-small dark:bg-[var(--surface-4)]' + /> +
    setActiveTab(v as ResourceType)}> diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx index 0e748546771..07d5046283b 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useMemo, useState } from 'react' +import { memo, useCallback, useMemo, useRef, useState } from 'react' import { X } from 'lucide-react' import { nanoid } from 'nanoid' import { @@ -11,29 +11,29 @@ import { DropdownMenuTrigger, } from '@/components/emcn' import { ChevronDown, Plus } from '@/components/emcn/icons' -import { cn } from '@/lib/core/utils/cn' import type { Filter, FilterRule } from '@/lib/table' import { COMPARISON_OPERATORS } from '@/lib/table/query-builder/constants' -import { filterRulesToFilter } from '@/lib/table/query-builder/converters' - -const OPERATOR_LABELS: Record = { - eq: '=', - ne: '≠', - gt: '>', - gte: '≥', - lt: '<', - lte: '≤', - contains: '∋', - in: '∈', -} as const +import { filterRulesToFilter, filterToRules } from '@/lib/table/query-builder/converters' + +const OPERATOR_LABELS = Object.fromEntries( + COMPARISON_OPERATORS.map((op) => [op.value, op.label]) +) as Record interface TableFilterProps { columns: Array<{ name: string; type: string }> + filter: Filter | null onApply: (filter: Filter | null) => void + onClose: () => void } -export function TableFilter({ columns, onApply }: TableFilterProps) { - const [rules, setRules] = useState(() => [createRule(columns)]) +export function TableFilter({ columns, filter, onApply, onClose }: TableFilterProps) { + const [rules, setRules] = useState(() => { + const fromFilter = filterToRules(filter) + return fromFilter.length > 0 ? fromFilter : [createRule(columns)] + }) + + const rulesRef = useRef(rules) + rulesRef.current = rules const columnOptions = useMemo( () => columns.map((col) => ({ value: col.name, label: col.name })), @@ -46,52 +46,82 @@ export function TableFilter({ columns, onApply }: TableFilterProps) { const handleRemove = useCallback( (id: string) => { - setRules((prev) => { - const next = prev.filter((r) => r.id !== id) - return next.length === 0 ? [createRule(columns)] : next - }) + const next = rulesRef.current.filter((r) => r.id !== id) + if (next.length === 0) { + onApply(null) + onClose() + setRules([createRule(columns)]) + } else { + setRules(next) + } }, - [columns] + [columns, onApply, onClose] ) const handleUpdate = useCallback((id: string, field: keyof FilterRule, value: string) => { setRules((prev) => prev.map((r) => (r.id === id ? { ...r, [field]: value } : r))) }, []) + const handleToggleLogical = useCallback((id: string) => { + setRules((prev) => + prev.map((r) => + r.id === id ? { ...r, logicalOperator: r.logicalOperator === 'and' ? 'or' : 'and' } : r + ) + ) + }, []) + const handleApply = useCallback(() => { - const validRules = rules.filter((r) => r.column && r.value) + const validRules = rulesRef.current.filter((r) => r.column && r.value) onApply(filterRulesToFilter(validRules)) - }, [rules, onApply]) + }, [onApply]) - return ( -
    - {rules.map((rule) => ( - - ))} - -
    - + const handleClear = useCallback(() => { + setRules([createRule(columns)]) + onApply(null) + }, [columns, onApply]) - + return ( +
    +
    + {rules.map((rule, index) => ( + + ))} + +
    + +
    + {filter !== null && ( + + )} + +
    +
    ) @@ -99,18 +129,39 @@ export function TableFilter({ columns, onApply }: TableFilterProps) { interface FilterRuleRowProps { rule: FilterRule + isFirst: boolean columns: Array<{ value: string; label: string }> onUpdate: (id: string, field: keyof FilterRule, value: string) => void onRemove: (id: string) => void onApply: () => void + onToggleLogical: (id: string) => void } -function FilterRuleRow({ rule, columns, onUpdate, onRemove, onApply }: FilterRuleRowProps) { +const FilterRuleRow = memo(function FilterRuleRow({ + rule, + isFirst, + columns, + onUpdate, + onRemove, + onApply, + onToggleLogical, +}: FilterRuleRowProps) { return ( -
    +
    + {isFirst ? ( + Where + ) : ( + + )} + - @@ -129,8 +180,8 @@ function FilterRuleRow({ rule, columns, onUpdate, onRemove, onApply }: FilterRul - @@ -151,25 +202,21 @@ function FilterRuleRow({ rule, columns, onUpdate, onRemove, onApply }: FilterRul value={rule.value} onChange={(e) => onUpdate(rule.id, 'value', e.target.value)} onKeyDown={(e) => { - if (e.key === 'Enter') handleApply() + if (e.key === 'Enter') onApply() }} placeholder='Enter a value' - className='h-[30px] min-w-[160px] flex-1 rounded-[5px] border border-[var(--border)] bg-transparent px-2 text-[var(--text-secondary)] text-xs outline-none placeholder:text-[var(--text-subtle)] hover-hover:border-[var(--border-1)] focus:border-[var(--border-1)]' + className='h-[28px] flex-1 rounded-[5px] border border-[var(--border)] bg-transparent px-2 text-[var(--text-secondary)] text-xs outline-none placeholder:text-[var(--text-subtle)] hover-hover:border-[var(--border-1)] focus:border-[var(--border-1)]' />
    ) - - function handleApply() { - onApply() - } -} +}) function createRule(columns: Array<{ name: string }>): FilterRule { return { diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx index 74f116454d2..28086ac8869 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx @@ -1,6 +1,7 @@ 'use client' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { GripVertical } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' import { Button, @@ -305,6 +306,17 @@ export function Table({ return 0 }, [resizingColumn, displayColumns, columnWidths]) + const dropIndicatorLeft = useMemo(() => { + if (!dropTargetColumnName) return null + let left = CHECKBOX_COL_WIDTH + for (const col of displayColumns) { + if (dropSide === 'left' && col.name === dropTargetColumnName) return left + left += columnWidths[col.name] ?? COL_WIDTH + if (dropSide === 'right' && col.name === dropTargetColumnName) return left + } + return null + }, [dropTargetColumnName, dropSide, displayColumns, columnWidths]) + const isAllRowsSelected = useMemo(() => { if (checkedRows.size > 0 && rows.length > 0 && checkedRows.size >= rows.length) { for (const row of rows) { @@ -367,13 +379,11 @@ export function Table({ setColumnWidths(updatedWidths) } const updatedOrder = columnOrderRef.current?.map((n) => (n === columnName ? newName : n)) - if (updatedOrder) { - setColumnOrder(updatedOrder) - updateMetadataRef.current({ - columnWidths: updatedWidths, - columnOrder: updatedOrder, - }) - } + if (updatedOrder) setColumnOrder(updatedOrder) + updateMetadataRef.current({ + columnWidths: updatedWidths, + columnOrder: updatedOrder, + }) updateColumnMutation.mutate({ columnName, updates: { name: newName } }) }, }) @@ -682,6 +692,7 @@ export function Table({ } setDragColumnName(null) setDropTargetColumnName(null) + setDropSide('left') }, []) const handleColumnDragLeave = useCallback(() => { @@ -1340,8 +1351,7 @@ export function Table({ const insertColumnInOrder = useCallback( (anchorColumn: string, newColumn: string, side: 'left' | 'right') => { - const order = columnOrderRef.current - if (!order) return + const order = columnOrderRef.current ?? schemaColumnsRef.current.map((c) => c.name) const newOrder = [...order] let anchorIdx = newOrder.indexOf(anchorColumn) if (anchorIdx === -1) { @@ -1422,12 +1432,12 @@ export function Table({ const handleDeleteColumnConfirm = useCallback(() => { if (!deletingColumn) return const columnToDelete = deletingColumn + const orderAtDelete = columnOrderRef.current setDeletingColumn(null) deleteColumnMutation.mutate(columnToDelete, { onSuccess: () => { - const order = columnOrderRef.current - if (!order) return - const newOrder = order.filter((n) => n !== columnToDelete) + if (!orderAtDelete) return + const newOrder = orderAtDelete.filter((n) => n !== columnToDelete) setColumnOrder(newOrder) updateMetadataRef.current({ columnWidths: columnWidthsRef.current, @@ -1448,6 +1458,17 @@ export function Table({ const handleFilterApply = useCallback((filter: Filter | null) => { setQueryOptions((prev) => ({ ...prev, filter })) }, []) + + const [filterOpen, setFilterOpen] = useState(false) + + const handleFilterToggle = useCallback(() => { + setFilterOpen((prev) => !prev) + }, []) + + const handleFilterClose = useCallback(() => { + setFilterOpen(false) + }, []) + const columnOptions = useMemo( () => displayColumns.map((col) => ({ @@ -1526,11 +1547,6 @@ export function Table({ [handleAddColumn, addColumnMutation.isPending] ) - const filterElement = useMemo( - () => , - [displayColumns, handleFilterApply] - ) - const activeSortState = useMemo(() => { if (!queryOptions.sort) return null const entries = Object.entries(queryOptions.sort) @@ -1597,7 +1613,19 @@ export function Table({ <> - + + {filterOpen && ( + + )} )} @@ -1677,8 +1705,6 @@ export function Table({ onResize={handleColumnResize} onResizeEnd={handleColumnResizeEnd} isDragging={dragColumnName === column.name} - isDropTarget={dropTargetColumnName === column.name} - dropSide={dropTargetColumnName === column.name ? dropSide : undefined} onDragStart={handleColumnDragStart} onDragOver={handleColumnDragOver} onDragEnd={handleColumnDragEnd} @@ -1701,7 +1727,7 @@ export function Table({ <> {rows.map((row, index) => { const prevPosition = index > 0 ? rows[index - 1].position : -1 - const gapCount = row.position - prevPosition - 1 + const gapCount = queryOptions.filter ? 0 : row.position - prevPosition - 1 return ( {gapCount > 0 && ( @@ -1755,6 +1781,12 @@ export function Table({ style={{ left: resizeIndicatorLeft }} /> )} + {dropIndicatorLeft !== null && ( +
    + )}
    {!isLoadingTable && !isLoadingRows && userPermissions.canEdit && ( @@ -2581,8 +2613,6 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ onResize, onResizeEnd, isDragging, - isDropTarget, - dropSide, onDragStart, onDragOver, onDragEnd, @@ -2605,8 +2635,6 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ onResize: (columnName: string, width: number) => void onResizeEnd: () => void isDragging?: boolean - isDropTarget?: boolean - dropSide?: 'left' | 'right' onDragStart?: (columnName: string) => void onDragOver?: (columnName: string, side: 'left' | 'right') => void onDragEnd?: () => void @@ -2698,22 +2726,13 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ return ( - {isDropTarget && dropSide === 'left' && ( -
    - )} - {isDropTarget && dropSide === 'right' && ( -
    - )} {isRenaming ? (
    @@ -2738,63 +2757,73 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
    ) : ( - - - - - - onRenameColumn(column.name)}> - - Rename column - - - - {React.createElement(COLUMN_TYPE_ICONS[column.type] ?? TypeText)} - Change type - - - {COLUMN_TYPE_OPTIONS.map((option) => ( - onChangeType(column.name, option.type)} - > - - {option.label} - - ))} - - - - onInsertLeft(column.name)}> - - Insert column left - - onInsertRight(column.name)}> - - Insert column right - - - onToggleUnique(column.name)}> - - {column.unique ? 'Remove unique' : 'Set unique'} - - - onDeleteColumn(column.name)}> - - Delete column - - - +
    + + + + + + onRenameColumn(column.name)}> + + Rename column + + + + {React.createElement(COLUMN_TYPE_ICONS[column.type] ?? TypeText)} + Change type + + + {COLUMN_TYPE_OPTIONS.map((option) => ( + onChangeType(column.name, option.type)} + > + + {option.label} + + ))} + + + + onInsertLeft(column.name)}> + + Insert column left + + onInsertRight(column.name)}> + + Insert column right + + + onToggleUnique(column.name)}> + + {column.unique ? 'Remove unique' : 'Set unique'} + + + onDeleteColumn(column.name)}> + + Delete column + + + +
    + +
    +
    )}
    (null) const [searchTerm, setSearchTerm] = useState('') + const debouncedSearchTerm = useDebounce(searchTerm, 300) + const [activeSort, setActiveSort] = useState<{ + column: string + direction: 'asc' | 'desc' + } | null>(null) + const [rowCountFilter, setRowCountFilter] = useState([]) + const [ownerFilter, setOwnerFilter] = useState([]) const [uploading, setUploading] = useState(false) const [uploadProgress, setUploadProgress] = useState({ completed: 0, total: 0 }) const csvInputRef = useRef(null) @@ -78,15 +94,56 @@ export function Tables() { closeMenu: closeRowContextMenu, } = useContextMenu() - const filteredTables = useMemo(() => { - if (!searchTerm) return tables - const term = searchTerm.toLowerCase() - return tables.filter((table) => table.name.toLowerCase().includes(term)) - }, [tables, searchTerm]) + const processedTables = useMemo(() => { + let result = debouncedSearchTerm + ? tables.filter((t) => t.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) + : tables + + if (rowCountFilter.length > 0) { + result = result.filter((t) => { + if (rowCountFilter.includes('empty') && t.rowCount === 0) return true + if (rowCountFilter.includes('small') && t.rowCount >= 1 && t.rowCount <= 100) return true + if (rowCountFilter.includes('large') && t.rowCount > 100) return true + return false + }) + } + if (ownerFilter.length > 0) { + result = result.filter((t) => ownerFilter.includes(t.createdBy)) + } + const col = activeSort?.column ?? 'created' + const dir = activeSort?.direction ?? 'desc' + return [...result].sort((a, b) => { + let cmp = 0 + switch (col) { + case 'name': + cmp = a.name.localeCompare(b.name) + break + case 'columns': + cmp = a.schema.columns.length - b.schema.columns.length + break + case 'rows': + cmp = a.rowCount - b.rowCount + break + case 'created': + cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + break + case 'updated': + cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime() + break + case 'owner': { + const aName = members?.find((m) => m.userId === a.createdBy)?.name ?? '' + const bName = members?.find((m) => m.userId === b.createdBy)?.name ?? '' + cmp = aName.localeCompare(bName) + break + } + } + return dir === 'asc' ? cmp : -cmp + }) + }, [tables, debouncedSearchTerm, rowCountFilter, ownerFilter, activeSort, members]) const rows: ResourceRow[] = useMemo( () => - filteredTables.map((table) => ({ + processedTables.map((table) => ({ id: table.id, cells: { name: { @@ -105,16 +162,167 @@ export function Tables() { owner: ownerCell(table.createdBy, members), updated: timeCell(table.updatedAt), }, - sortValues: { - columns: table.schema.columns.length, - rows: table.rowCount, - created: -new Date(table.createdAt).getTime(), - updated: -new Date(table.updatedAt).getTime(), - }, })), - [filteredTables, members] + [processedTables, members] + ) + + const searchConfig: SearchConfig = useMemo( + () => ({ + value: searchTerm, + onChange: setSearchTerm, + onClearAll: () => setSearchTerm(''), + placeholder: 'Search tables...', + }), + [searchTerm] + ) + + const sortConfig: SortConfig = useMemo( + () => ({ + options: [ + { id: 'name', label: 'Name' }, + { id: 'columns', label: 'Columns' }, + { id: 'rows', label: 'Rows' }, + { id: 'created', label: 'Created' }, + { id: 'owner', label: 'Owner' }, + { id: 'updated', label: 'Last Updated' }, + ], + active: activeSort, + onSort: (column, direction) => setActiveSort({ column, direction }), + onClear: () => setActiveSort(null), + }), + [activeSort] + ) + + const rowCountDisplayLabel = useMemo(() => { + if (rowCountFilter.length === 0) return 'All' + if (rowCountFilter.length === 1) { + const labels: Record = { + empty: 'Empty', + small: 'Small (1–100)', + large: 'Large (101+)', + } + return labels[rowCountFilter[0]] ?? rowCountFilter[0] + } + return `${rowCountFilter.length} selected` + }, [rowCountFilter]) + + const ownerDisplayLabel = useMemo(() => { + if (ownerFilter.length === 0) return 'All' + if (ownerFilter.length === 1) + return members?.find((m) => m.userId === ownerFilter[0])?.name ?? '1 member' + return `${ownerFilter.length} members` + }, [ownerFilter, members]) + + const memberOptions: ComboboxOption[] = useMemo( + () => + (members ?? []).map((m) => ({ + value: m.userId, + label: m.name, + iconElement: m.image ? ( + {m.name} + ) : ( + + {m.name.charAt(0).toUpperCase()} + + ), + })), + [members] + ) + + const hasActiveFilters = rowCountFilter.length > 0 || ownerFilter.length > 0 + + const filterContent = useMemo( + () => ( +
    +
    + Row Count + {rowCountDisplayLabel} + } + showAllOption + allOptionLabel='All' + size='sm' + className='h-[32px] w-full rounded-md' + /> +
    + {memberOptions.length > 0 && ( +
    + Owner + {ownerDisplayLabel} + } + searchable + searchPlaceholder='Search members...' + showAllOption + allOptionLabel='All' + size='sm' + className='h-[32px] w-full rounded-md' + /> +
    + )} + {hasActiveFilters && ( + + )} +
    + ), + [ + rowCountFilter, + ownerFilter, + memberOptions, + rowCountDisplayLabel, + ownerDisplayLabel, + hasActiveFilters, + ] ) + const filterTags: FilterTag[] = useMemo(() => { + const tags: FilterTag[] = [] + if (rowCountFilter.length > 0) { + const rowLabels: Record = { empty: 'Empty', small: 'Small', large: 'Large' } + const label = + rowCountFilter.length === 1 + ? `Rows: ${rowLabels[rowCountFilter[0]]}` + : `Rows: ${rowCountFilter.length} selected` + tags.push({ label, onRemove: () => setRowCountFilter([]) }) + } + if (ownerFilter.length > 0) { + const label = + ownerFilter.length === 1 + ? `Owner: ${members?.find((m) => m.userId === ownerFilter[0])?.name ?? '1 member'}` + : `Owner: ${ownerFilter.length} members` + tags.push({ label, onRemove: () => setOwnerFilter([]) }) + } + return tags + }, [rowCountFilter, ownerFilter, members]) + const handleContentContextMenu = useCallback( (e: React.MouseEvent) => { const target = e.target as HTMLElement @@ -215,7 +423,7 @@ export function Tables() { } } }, - [workspaceId, router] + [workspaceId, router, uploadCsv] ) const handleListUploadCsv = useCallback(() => { @@ -260,12 +468,10 @@ export function Tables() { onClick: handleCreateTable, disabled: uploading || userPermissions.canEdit !== true || createTable.isPending, }} - search={{ - value: searchTerm, - onChange: setSearchTerm, - placeholder: 'Search tables...', - }} - defaultSort='created' + search={searchConfig} + sort={sortConfig} + filter={filterContent} + filterTags={filterTags} headerActions={[ { label: uploadButtonLabel, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx index 79aeab6f1ca..7a64c8e0392 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx @@ -245,9 +245,6 @@ export function CollapsedTaskFlyoutItem({ title={task.name} isActive={!!task.isActive} isUnread={!!task.isUnread} - statusIndicatorClassName={ - !(isCurrentRoute || isMenuOpen) ? 'group-hover:hidden' : undefined - } /> {showActions && ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx index a21344fe3f1..07062fc1081 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx @@ -3,7 +3,7 @@ import { useCallback, useMemo } from 'react' import { useQueryClient } from '@tanstack/react-query' import { useParams, usePathname, useRouter } from 'next/navigation' -import { ChevronDown, Skeleton, Tooltip } from '@/components/emcn' +import { ChevronDown, Skeleton } from '@/components/emcn' import { useSession } from '@/lib/auth/auth-client' import { getSubscriptionAccessState } from '@/lib/billing/client' import { isHosted } from '@/lib/core/config/feature-flags' @@ -15,6 +15,7 @@ import { isBillingEnabled, sectionConfig, } from '@/app/workspace/[workspaceId]/settings/navigation' +import { SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar' import { useSSOProviders } from '@/ee/sso/hooks/sso' import { prefetchWorkspaceCredentials } from '@/hooks/queries/credentials' import { prefetchGeneralSettings, useGeneralSettings } from '@/hooks/queries/general-settings' @@ -186,25 +187,18 @@ export function SettingsSidebar({ <> {/* Back button */}
    - - - - - {showCollapsedTooltips && ( - -

    Back

    -
    - )} -
    + + +
    {/* Settings sections */} @@ -303,14 +297,13 @@ export function SettingsSidebar({ ) return ( - - {element} - {showCollapsedTooltips && ( - -

    {item.label}

    -
    - )} -
    + + {element} + ) })}
    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 df8f68cff4d..b21d2a90e85 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -100,6 +100,28 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('Sidebar') +export function SidebarTooltip({ + children, + label, + enabled, + side = 'right', +}: { + children: React.ReactElement + label: string + enabled: boolean + side?: 'right' | 'bottom' +}) { + if (!enabled) return children + return ( + + {children} + +

    {label}

    +
    +
    + ) +} + function SidebarItemSkeleton() { return (
    @@ -135,71 +157,61 @@ const SidebarTaskItem = memo(function SidebarTaskItem({ onMoreClick: (e: React.MouseEvent, taskId: string) => void }) { return ( - - - { - if (task.id === 'new') return - if (e.shiftKey || e.metaKey || e.ctrlKey) { - e.preventDefault() - onMultiSelectClick(task.id, e.shiftKey, e.metaKey || e.ctrlKey) - } else { - useFolderStore.setState({ - selectedTasks: new Set(), - lastSelectedTaskId: task.id, - }) - } - }} - onContextMenu={task.id !== 'new' ? (e) => onContextMenu(e, task.id) : undefined} - > - -
    - {task.name} -
    - {task.id !== 'new' && ( -
    - {isActive && !isCurrentRoute && ( - - )} - {isActive && !isCurrentRoute && ( - - )} - {!isActive && isUnread && !isCurrentRoute && ( - + + { + if (task.id === 'new') return + if (e.shiftKey || e.metaKey || e.ctrlKey) { + e.preventDefault() + onMultiSelectClick(task.id, e.shiftKey, e.metaKey || e.ctrlKey) + } else { + useFolderStore.setState({ + selectedTasks: new Set(), + lastSelectedTaskId: task.id, + }) + } + }} + onContextMenu={task.id !== 'new' ? (e) => onContextMenu(e, task.id) : undefined} + > + +
    {task.name}
    + {task.id !== 'new' && ( +
    + {isActive && !isCurrentRoute && ( + + )} + {isActive && !isCurrentRoute && ( + + )} + {!isActive && isUnread && !isCurrentRoute && ( + + )} + -
    - )} - - - {showCollapsedTooltips && ( - -

    {task.name}

    -
    - )} - + > + + +
    + )} + + ) }) @@ -265,15 +277,12 @@ const SidebarNavItem = memo(function SidebarNavItem({ ) : null + if (!element) return null + return ( - - {element} - {showCollapsedTooltips && ( - -

    {item.label}

    -
    - )} -
    + + {element} + ) }) @@ -317,6 +326,7 @@ export const Sidebar = memo(function Sidebar() { const setSidebarWidth = useSidebarStore((state) => state.setSidebarWidth) const isCollapsed = useSidebarStore((state) => state.isCollapsed) const toggleCollapsed = useSidebarStore((state) => state.toggleCollapsed) + const _hasHydrated = useSidebarStore((state) => state._hasHydrated) const isOnWorkflowPage = !!workflowId const isCollapsedRef = useRef(isCollapsed) @@ -326,14 +336,12 @@ export const Sidebar = memo(function Sidebar() { const isMac = useMemo(() => isMacPlatform(), []) - // Delay collapsed tooltips until the width transition finishes. const [showCollapsedTooltips, setShowCollapsedTooltips] = useState(isCollapsed) useLayoutEffect(() => { - if (!isCollapsed) { - document.documentElement.removeAttribute('data-sidebar-collapsed') - } - }, [isCollapsed]) + if (!_hasHydrated) return + document.documentElement.removeAttribute('data-sidebar-collapsed') + }, [_hasHydrated]) useEffect(() => { if (isCollapsed) { @@ -1010,10 +1018,6 @@ 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( () => , [] @@ -1054,7 +1058,6 @@ export const Sidebar = memo(function Sidebar() { [handleCreateWorkflow] ) - // Stable no-op for collapsed workflow context menu delete (never changes) const noop = useCallback(() => {}, []) const handleExpandSidebar = useCallback( @@ -1065,16 +1068,13 @@ export const Sidebar = memo(function Sidebar() { [toggleCollapsed] ) - // 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), []) const handleEdgeKeyDown = useCallback( @@ -1087,16 +1087,13 @@ export const Sidebar = memo(function Sidebar() { [isCollapsed, toggleCollapsed] ) - // 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] @@ -1107,7 +1104,6 @@ export const Sidebar = memo(function Sidebar() { [workflowFlyoutRename.saveRename] ) - // Stable style for hidden file inputs const hiddenStyle = useMemo(() => ({ display: 'none' }) as const, []) const resolveWorkspaceIdFromPath = useCallback((): string | undefined => { @@ -1205,69 +1201,68 @@ export const Sidebar = memo(function Sidebar() { onClick={handleSidebarClick} >
    - {/* Top bar: Logo + Collapse toggle */}
    - - +
    + + {brand.logoUrl ? ( + {brand.name} + ) : ( + + )} + + {brand.logoUrl ? ( {brand.name} - ) : isCollapsed ? ( - ) : ( - - )} - {isCollapsed && ( - + )} + - - {showCollapsedTooltips && ( - -

    Expand sidebar

    -
    - )} - +
    +
    - - - - - {!isCollapsed && ( - -

    Collapse sidebar

    -
    - )} -
    + + +
    - {/* Workspace Header */}
    ) : ( <> - {/* Top Navigation: Home, Search */}
    {topNavItems.map((item) => ( - {/* Workspace */}
    Workspace
    @@ -1330,7 +1323,6 @@ export const Sidebar = memo(function Sidebar() {
    - {/* Scrollable Tasks + Workflows */}
    - {/* Tasks */}
    All tasks
    @@ -1460,7 +1451,6 @@ export const Sidebar = memo(function Sidebar() { )}
    - {/* Workflows */}
    - {/* Footer */}
    - {/* Help dropdown */} - + - - - + - {showCollapsedTooltips && ( - -

    Help

    -
    - )} -
    + @@ -1669,7 +1650,6 @@ export const Sidebar = memo(function Sidebar() { ))}
    - {/* Nav Item Context Menu */} - {/* Task Context Menu */} - {/* Task Delete Confirmation Modal */} - {/* Universal Search Modal */} - {/* Footer Navigation Modals */} - {/* Hidden file input for workspace import */} ) => { @@ -295,6 +301,8 @@ export function useDocumentChunks( offset, search: search || undefined, enabledFilter, + sortBy, + sortOrder, }) queryClient.setQueryData( knowledgeKeys.chunks(knowledgeBaseId, documentId, paramsKey), @@ -309,7 +317,7 @@ export function useDocumentChunks( } ) }, - [knowledgeBaseId, documentId, offset, search, enabledFilter, queryClient] + [knowledgeBaseId, documentId, offset, search, enabledFilter, sortBy, sortOrder, queryClient] ) return { diff --git a/apps/sim/hooks/queries/kb/knowledge.ts b/apps/sim/hooks/queries/kb/knowledge.ts index 53b938690d5..455d762ecab 100644 --- a/apps/sim/hooks/queries/kb/knowledge.ts +++ b/apps/sim/hooks/queries/kb/knowledge.ts @@ -181,6 +181,8 @@ export interface KnowledgeChunksParams { enabledFilter?: 'all' | 'enabled' | 'disabled' limit?: number offset?: number + sortBy?: 'chunkIndex' | 'tokenCount' | 'enabled' + sortOrder?: 'asc' | 'desc' } export interface KnowledgeChunksResponse { @@ -196,6 +198,8 @@ export async function fetchKnowledgeChunks( enabledFilter, limit = 50, offset = 0, + sortBy, + sortOrder, }: KnowledgeChunksParams, signal?: AbortSignal ): Promise { @@ -206,6 +210,8 @@ export async function fetchKnowledgeChunks( } if (limit) params.set('limit', limit.toString()) if (offset) params.set('offset', offset.toString()) + if (sortBy && sortBy !== 'chunkIndex') params.set('sortBy', sortBy) + if (sortOrder && sortOrder !== 'asc') params.set('sortOrder', sortOrder) const response = await fetch( `/api/knowledge/${knowledgeBaseId}/documents/${documentId}/chunks${params.toString() ? `?${params.toString()}` : ''}`, @@ -306,6 +312,8 @@ export const serializeChunkParams = (params: KnowledgeChunksParams) => enabledFilter: params.enabledFilter ?? 'all', limit: params.limit ?? 50, offset: params.offset ?? 0, + sortBy: params.sortBy ?? 'chunkIndex', + sortOrder: params.sortOrder ?? 'asc', }) export function useKnowledgeChunksQuery( diff --git a/apps/sim/hooks/use-auto-scroll.ts b/apps/sim/hooks/use-auto-scroll.ts index dd23e79e992..e8829af5f5c 100644 --- a/apps/sim/hooks/use-auto-scroll.ts +++ b/apps/sim/hooks/use-auto-scroll.ts @@ -5,6 +5,10 @@ const STICK_THRESHOLD = 30 /** User must scroll back to within this distance to re-engage auto-scroll. */ const REATTACH_THRESHOLD = 5 +interface UseAutoScrollOptions { + scrollOnMount?: boolean +} + /** * Manages sticky auto-scroll for a streaming chat container. * @@ -16,7 +20,10 @@ const REATTACH_THRESHOLD = 5 * Returns `ref` (callback ref for the scroll container) and `scrollToBottom` * for imperative use after layout-changing events like panel expansion. */ -export function useAutoScroll(isStreaming: boolean) { +export function useAutoScroll( + isStreaming: boolean, + { scrollOnMount = false }: UseAutoScrollOptions = {} +) { const containerRef = useRef(null) const stickyRef = useRef(true) const userDetachedRef = useRef(false) @@ -24,6 +31,7 @@ export function useAutoScroll(isStreaming: boolean) { const prevScrollHeightRef = useRef(0) const touchStartYRef = useRef(0) const rafIdRef = useRef(0) + const scrollOnMountRef = useRef(scrollOnMount) const scrollToBottom = useCallback(() => { const el = containerRef.current @@ -33,7 +41,7 @@ export function useAutoScroll(isStreaming: boolean) { const callbackRef = useCallback((el: HTMLDivElement | null) => { containerRef.current = el - if (el) el.scrollTop = el.scrollHeight + if (el && scrollOnMountRef.current) el.scrollTop = el.scrollHeight }, []) useEffect(() => { diff --git a/apps/sim/lib/knowledge/chunks/service.ts b/apps/sim/lib/knowledge/chunks/service.ts index c4aef86d270..7ec51d0c617 100644 --- a/apps/sim/lib/knowledge/chunks/service.ts +++ b/apps/sim/lib/knowledge/chunks/service.ts @@ -2,7 +2,7 @@ import { createHash, randomUUID } from 'crypto' import { db } from '@sim/db' import { document, embedding, knowledgeBase } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, asc, eq, ilike, inArray, isNull, sql } from 'drizzle-orm' +import { and, asc, desc, eq, ilike, inArray, isNull, sql } from 'drizzle-orm' import type { BatchOperationResult, ChunkData, @@ -23,24 +23,27 @@ export async function queryChunks( filters: ChunkFilters, requestId: string ): Promise { - const { search, enabled = 'all', limit = 50, offset = 0 } = filters + const { + search, + enabled = 'all', + limit = 50, + offset = 0, + sortBy = 'chunkIndex', + sortOrder = 'asc', + } = filters - // Build query conditions const conditions = [eq(embedding.documentId, documentId)] - // Add enabled filter if (enabled === 'true') { conditions.push(eq(embedding.enabled, true)) } else if (enabled === 'false') { conditions.push(eq(embedding.enabled, false)) } - // Add search filter if (search) { conditions.push(ilike(embedding.content, `%${search}%`)) } - // Fetch chunks const chunks = await db .select({ id: embedding.id, @@ -63,11 +66,20 @@ export async function queryChunks( }) .from(embedding) .where(and(...conditions)) - .orderBy(asc(embedding.chunkIndex)) + .orderBy( + (() => { + const col = + sortBy === 'tokenCount' + ? embedding.tokenCount + : sortBy === 'enabled' + ? embedding.enabled + : embedding.chunkIndex + return sortOrder === 'desc' ? desc(col) : asc(col) + })() + ) .limit(limit) .offset(offset) - // Get total count for pagination const totalCount = await db .select({ count: sql`count(*)` }) .from(embedding) diff --git a/apps/sim/lib/knowledge/chunks/types.ts b/apps/sim/lib/knowledge/chunks/types.ts index 5c48c450ab6..2532828b2e9 100644 --- a/apps/sim/lib/knowledge/chunks/types.ts +++ b/apps/sim/lib/knowledge/chunks/types.ts @@ -3,6 +3,8 @@ export interface ChunkFilters { enabled?: 'true' | 'false' | 'all' limit?: number offset?: number + sortBy?: 'chunkIndex' | 'tokenCount' | 'enabled' + sortOrder?: 'asc' | 'desc' } export interface ChunkData {