From 356b473dc3e80847fcc86554d2123d6d50eb479d Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com> Date: Sat, 3 Jan 2026 14:37:59 -0800 Subject: [PATCH 1/7] fix(import): fix missing blocks in import if undefined keys exist (#2674) --- .../credentials/credential-extractor.ts | 46 +++++++++++++++++++ apps/sim/stores/workflows/json/importer.ts | 35 +++++++++++++- 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/workflows/credentials/credential-extractor.ts b/apps/sim/lib/workflows/credentials/credential-extractor.ts index df18128723..014febabc5 100644 --- a/apps/sim/lib/workflows/credentials/credential-extractor.ts +++ b/apps/sim/lib/workflows/credentials/credential-extractor.ts @@ -161,6 +161,49 @@ function formatFieldName(fieldName: string): string { .join(' ') } +/** + * Remove malformed subBlocks from a block that may have been created by bugs. + * This includes subBlocks with: + * - Key "undefined" (caused by assigning to undefined key) + * - Missing required `id` field + * - Type "unknown" (indicates malformed data) + */ +function removeMalformedSubBlocks(block: any): void { + if (!block.subBlocks) return + + const keysToRemove: string[] = [] + + Object.entries(block.subBlocks).forEach(([key, subBlock]: [string, any]) => { + // Flag subBlocks with invalid keys (literal "undefined" string) + if (key === 'undefined') { + keysToRemove.push(key) + return + } + + // Flag subBlocks that are null or not objects + if (!subBlock || typeof subBlock !== 'object') { + keysToRemove.push(key) + return + } + + // Flag subBlocks with type "unknown" (malformed data) + if (subBlock.type === 'unknown') { + keysToRemove.push(key) + return + } + + // Flag subBlocks missing required id field + if (!subBlock.id) { + keysToRemove.push(key) + } + }) + + // Remove the flagged keys + keysToRemove.forEach((key) => { + delete block.subBlocks[key] + }) +} + /** * Sanitize workflow state by removing all credentials and workspace-specific data * This is used for both template creation and workflow export to ensure consistency @@ -183,6 +226,9 @@ export function sanitizeWorkflowForSharing( Object.values(sanitized.blocks).forEach((block: any) => { if (!block?.type) return + // First, remove any malformed subBlocks that may have been created by bugs + removeMalformedSubBlocks(block) + const blockConfig = getBlock(block.type) // Process subBlocks with config diff --git a/apps/sim/stores/workflows/json/importer.ts b/apps/sim/stores/workflows/json/importer.ts index bb119e9896..f2243c76c8 100644 --- a/apps/sim/stores/workflows/json/importer.ts +++ b/apps/sim/stores/workflows/json/importer.ts @@ -5,9 +5,14 @@ import type { WorkflowState } from '../workflow/types' const logger = createLogger('WorkflowJsonImporter') /** - * Normalize subblock values by converting empty strings to null. + * Normalize subblock values by converting empty strings to null and filtering out invalid subblocks. * This provides backwards compatibility for workflows exported before the null sanitization fix, * preventing Zod validation errors like "Expected array, received string". + * + * Also filters out malformed subBlocks that may have been created by bugs in previous exports: + * - SubBlocks with key "undefined" (caused by assigning to undefined key) + * - SubBlocks missing required fields like `id` + * - SubBlocks with `type: "unknown"` (indicates malformed data) */ function normalizeSubblockValues(blocks: Record): Record { const normalizedBlocks: Record = {} @@ -19,6 +24,34 @@ function normalizeSubblockValues(blocks: Record): Record = {} Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]: [string, any]) => { + // Skip subBlocks with invalid keys (literal "undefined" string) + if (subBlockId === 'undefined') { + logger.warn(`Skipping malformed subBlock with key "undefined" in block ${blockId}`) + return + } + + // Skip subBlocks that are null or not objects + if (!subBlock || typeof subBlock !== 'object') { + logger.warn(`Skipping invalid subBlock ${subBlockId} in block ${blockId}: not an object`) + return + } + + // Skip subBlocks with type "unknown" (malformed data) + if (subBlock.type === 'unknown') { + logger.warn( + `Skipping malformed subBlock ${subBlockId} in block ${blockId}: type is "unknown"` + ) + return + } + + // Skip subBlocks missing required id field + if (!subBlock.id) { + logger.warn( + `Skipping malformed subBlock ${subBlockId} in block ${blockId}: missing id field` + ) + return + } + const normalizedSubBlock = { ...subBlock } // Convert empty strings to null for consistency From 1673ef98ac7f0239c2c695a596038a08cdfdc843 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com> Date: Sat, 3 Jan 2026 14:42:39 -0800 Subject: [PATCH 2/7] fix(variables): fix variables block parsing error for json (#2675) --- .../variables-input/variables-input.tsx | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/variables-input/variables-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/variables-input/variables-input.tsx index c887f289b1..cd81e5c990 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/variables-input/variables-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/variables-input/variables-input.tsx @@ -38,6 +38,27 @@ const DEFAULT_ASSIGNMENT: Omit = { isExisting: false, } +/** + * Parses a value that might be a JSON string or already an array of VariableAssignment. + * This handles the case where workflows are imported with stringified values. + */ +function parseVariableAssignments(value: unknown): VariableAssignment[] { + if (!value) return [] + if (Array.isArray(value)) return value as VariableAssignment[] + if (typeof value === 'string') { + const trimmed = value.trim() + if (trimmed.startsWith('[') && trimmed.endsWith(']')) { + try { + const parsed = JSON.parse(trimmed) + if (Array.isArray(parsed)) return parsed as VariableAssignment[] + } catch { + // Not valid JSON, return empty array + } + } + } + return [] +} + export function VariablesInput({ blockId, subBlockId, @@ -64,8 +85,8 @@ export function VariablesInput({ (v: Variable) => v.workflowId === workflowId ) - const value = isPreview ? previewValue : storeValue - const assignments: VariableAssignment[] = value || [] + const rawValue = isPreview ? previewValue : storeValue + const assignments: VariableAssignment[] = parseVariableAssignments(rawValue) const isReadOnly = isPreview || disabled const getAvailableVariablesFor = (currentAssignmentId: string) => { From 195e0e8e3f7c0ae09e3ad4b08f0915646e7446b8 Mon Sep 17 00:00:00 2001 From: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com> Date: Sat, 3 Jan 2026 16:51:24 -0800 Subject: [PATCH 3/7] feat(popover): sections; improvement: tooltip, popover; fix(notifications): loading content (#2676) --- apps/sim/app/_styles/globals.css | 22 +- .../message/components/markdown-renderer.tsx | 2 +- .../notifications/notifications.tsx | 14 +- .../output-select/output-select.tsx | 19 +- .../context-menu/block-context-menu.tsx | 61 +- .../context-menu/pane-context-menu.tsx | 37 +- .../components/markdown-renderer.tsx | 2 +- .../components/tag-dropdown/tag-dropdown.tsx | 27 +- .../workflow-block/workflow-block.tsx | 2 +- .../[workspaceId]/w/[workflowId]/workflow.tsx | 4 +- .../components/general/general.tsx | 13 +- .../components/context-menu/context-menu.tsx | 26 +- .../components/permissions-table.tsx | 5 +- .../workspace-header/workspace-header.tsx | 4 +- .../w/components/sidebar/sidebar.tsx | 6 +- apps/sim/components/emcn/components/index.ts | 2 + .../emcn/components/popover/popover.tsx | 543 ++++++++---------- .../emcn/components/tooltip/tooltip.tsx | 4 +- 18 files changed, 368 insertions(+), 425 deletions(-) diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css index be0629e5e3..b94f9a2e58 100644 --- a/apps/sim/app/_styles/globals.css +++ b/apps/sim/app/_styles/globals.css @@ -50,8 +50,8 @@ @layer base { :root, .light { - --bg: #fdfdfd; /* main canvas - neutral near-white */ - --surface-1: #fcfcfc; /* sidebar, panels */ + --bg: #fefefe; /* main canvas - neutral near-white */ + --surface-1: #fefefe; /* sidebar, panels */ --surface-2: #ffffff; /* blocks, cards, modals - pure white */ --surface-3: #f7f7f7; /* popovers, headers */ --surface-4: #f5f5f5; /* buttons base */ @@ -70,6 +70,7 @@ --text-muted: #737373; --text-subtle: #8c8c8c; --text-inverse: #ffffff; + --text-muted-inverse: #a0a0a0; --text-error: #ef4444; /* Borders / dividers */ @@ -186,6 +187,7 @@ --text-muted: #787878; --text-subtle: #7d7d7d; --text-inverse: #1b1b1b; + --text-muted-inverse: #b3b3b3; --text-error: #ef4444; /* --border-strong: #303030; */ @@ -331,38 +333,38 @@ } ::-webkit-scrollbar-track { - background: var(--surface-1); + background: transparent; } ::-webkit-scrollbar-thumb { - background-color: var(--surface-7); + background-color: #c0c0c0; border-radius: var(--radius); } ::-webkit-scrollbar-thumb:hover { - background-color: var(--surface-7); + background-color: #a8a8a8; } /* Dark Mode Global Scrollbar */ .dark ::-webkit-scrollbar-track { - background: var(--surface-4); + background: transparent; } .dark ::-webkit-scrollbar-thumb { - background-color: var(--surface-7); + background-color: #5a5a5a; } .dark ::-webkit-scrollbar-thumb:hover { - background-color: var(--surface-7); + background-color: #6a6a6a; } * { scrollbar-width: thin; - scrollbar-color: var(--surface-7) var(--surface-1); + scrollbar-color: #c0c0c0 transparent; } .dark * { - scrollbar-color: var(--surface-7) var(--surface-4); + scrollbar-color: #5a5a5a transparent; } .copilot-scrollable { diff --git a/apps/sim/app/chat/components/message/components/markdown-renderer.tsx b/apps/sim/app/chat/components/message/components/markdown-renderer.tsx index 5ac058b440..ba69814cfc 100644 --- a/apps/sim/app/chat/components/message/components/markdown-renderer.tsx +++ b/apps/sim/app/chat/components/message/components/markdown-renderer.tsx @@ -16,7 +16,7 @@ export function LinkWithPreview({ href, children }: { href: string; children: Re {children} - + {href} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/notifications.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/notifications.tsx index ceda33549a..e727a34ffd 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/notifications.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/notifications.tsx @@ -185,6 +185,10 @@ export function NotificationSettings({ const hasSubscriptions = filteredSubscriptions.length > 0 + // Compute form visibility synchronously to avoid empty state flash + // Show form if user explicitly opened it OR if loading is complete with no subscriptions + const displayForm = showForm || (!isLoading && !hasSubscriptions && !editingId) + const getSubscriptionsForTab = useCallback( (tab: NotificationType) => { return subscriptions.filter((s) => s.notificationType === tab) @@ -192,12 +196,6 @@ export function NotificationSettings({ [subscriptions] ) - useEffect(() => { - if (!isLoading && !hasSubscriptions && !editingId) { - setShowForm(true) - } - }, [isLoading, hasSubscriptions, editingId, activeTab]) - const resetForm = useCallback(() => { setFormData({ workflowIds: [], @@ -1210,7 +1208,7 @@ export function NotificationSettings({ ) const renderTabContent = () => { - if (showForm) { + if (displayForm) { return renderForm() } @@ -1279,7 +1277,7 @@ export function NotificationSettings({ - {showForm ? ( + {displayForm ? ( <> {hasSubscriptions && ( - +

{isImportingWorkspace ? 'Importing workspace...' : 'Import workspace'}

@@ -364,7 +364,7 @@ export function WorkspaceHeader({ - +

{isCreatingWorkspace ? 'Creating workspace...' : 'Create workspace'}

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 1b24b4caaa..13fd4aa42f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -529,7 +529,7 @@ export function Sidebar() { - +

{isImporting ? 'Importing workflow...' : 'Import workflow'}

@@ -544,7 +544,7 @@ export function Sidebar() { - +

{isCreatingFolder ? 'Creating folder...' : 'Create folder'}

@@ -559,7 +559,7 @@ export function Sidebar() { - +

{isCreatingWorkflow ? 'Creating workflow...' : 'Create workflow'}

diff --git a/apps/sim/components/emcn/components/index.ts b/apps/sim/components/emcn/components/index.ts index 9e9ca0414e..7e821b3561 100644 --- a/apps/sim/components/emcn/components/index.ts +++ b/apps/sim/components/emcn/components/index.ts @@ -57,6 +57,8 @@ export { type PopoverBackButtonProps, PopoverContent, type PopoverContentProps, + PopoverDivider, + type PopoverDividerProps, PopoverFolder, type PopoverFolderProps, PopoverItem, diff --git a/apps/sim/components/emcn/components/popover/popover.tsx b/apps/sim/components/emcn/components/popover/popover.tsx index 7de80bd384..c82e651b8c 100644 --- a/apps/sim/components/emcn/components/popover/popover.tsx +++ b/apps/sim/components/emcn/components/popover/popover.tsx @@ -55,53 +55,102 @@ import { Check, ChevronLeft, ChevronRight, Search } from 'lucide-react' import { cn } from '@/lib/core/utils/cn' type PopoverSize = 'sm' | 'md' +type PopoverColorScheme = 'default' | 'inverted' +type PopoverVariant = 'default' | 'secondary' /** - * Shared base styles for all popover interactive items. - * Ensures consistent styling across items, folders, and back button. - */ -const POPOVER_ITEM_BASE_CLASSES = - 'flex min-w-0 cursor-pointer items-center gap-[8px] rounded-[6px] px-[6px] font-base text-[var(--text-primary)] disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed' - -/** - * Size-specific styles for popover items. - * SM: 11px text, 22px height - * MD: 13px text, 26px height - */ -const POPOVER_ITEM_SIZE_CLASSES: Record = { - sm: 'h-[22px] text-[11px]', - md: 'h-[26px] text-[13px]', -} - -/** - * Size-specific icon classes for popover items. + * Style constants for popover components. + * Organized by component type and property. */ -const POPOVER_ICON_SIZE_CLASSES: Record = { - sm: 'h-3 w-3', - md: 'h-3.5 w-3.5', -} +const STYLES = { + /** Base classes shared by all interactive items */ + itemBase: + 'flex min-w-0 cursor-pointer items-center gap-[8px] rounded-[6px] px-[6px] font-base disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed', + + /** Content container */ + content: 'px-[6px] py-[6px] rounded-[6px]', + + /** Size variants */ + size: { + sm: { item: 'h-[22px] text-[11px]', icon: 'h-3 w-3', section: 'px-[6px] py-[4px] text-[11px]' }, + md: { + item: 'h-[26px] text-[13px]', + icon: 'h-3.5 w-3.5', + section: 'px-[6px] py-[4px] text-[13px]', + }, + } satisfies Record, + + /** Color scheme variants */ + colorScheme: { + default: { + text: 'text-[var(--text-primary)]', + section: 'text-[var(--text-tertiary)]', + search: 'text-[var(--text-muted)]', + searchInput: 'text-[var(--text-primary)] placeholder:text-[var(--text-muted)]', + content: 'bg-[var(--surface-5)] text-foreground dark:bg-[var(--surface-3)]', + divider: 'border-[var(--border-1)]', + }, + inverted: { + text: 'text-white dark:text-[var(--text-primary)]', + section: 'text-[var(--text-muted-inverse)]', + search: 'text-[var(--text-muted-inverse)] dark:text-[var(--text-muted)]', + searchInput: + 'text-white placeholder:text-[var(--text-muted-inverse)] dark:text-[var(--text-primary)] dark:placeholder:text-[var(--text-muted)]', + content: 'bg-[#1b1b1b] text-white dark:bg-[var(--surface-3)] dark:text-foreground', + divider: 'border-[#363636] dark:border-[var(--border-1)]', + }, + } satisfies Record< + PopoverColorScheme, + { + text: string + section: string + search: string + searchInput: string + content: string + divider: string + } + >, + + /** Interactive state styles: default, secondary (brand), inverted (dark bg in light mode) */ + states: { + default: { + active: 'bg-[var(--border-1)] text-[var(--text-primary)] [&_svg]:text-[var(--text-primary)]', + hover: + 'hover:bg-[var(--border-1)] hover:text-[var(--text-primary)] hover:[&_svg]:text-[var(--text-primary)]', + }, + secondary: { + active: + 'bg-[var(--brand-secondary)] text-[var(--text-inverse)] [&_svg]:text-[var(--text-inverse)]', + hover: + 'hover:bg-[var(--brand-secondary)] hover:text-[var(--text-inverse)] dark:hover:text-[var(--text-inverse)] hover:[&_svg]:text-[var(--text-inverse)] dark:hover:[&_svg]:text-[var(--text-inverse)]', + }, + inverted: { + active: + 'bg-[#363636] text-white [&_svg]:text-white dark:bg-[var(--surface-5)] dark:text-[var(--text-primary)] dark:[&_svg]:text-[var(--text-primary)]', + hover: + 'hover:bg-[#363636] hover:text-white hover:[&_svg]:text-white dark:hover:bg-[var(--surface-5)] dark:hover:text-[var(--text-primary)] dark:hover:[&_svg]:text-[var(--text-primary)]', + }, + }, +} as const /** - * Variant-specific active state styles for popover items. + * Gets the active/hover classes for a popover item. + * Uses variant for secondary, otherwise colorScheme determines default vs inverted. */ -const POPOVER_ITEM_ACTIVE_CLASSES = { - secondary: 'bg-[var(--brand-secondary)] text-[var(--bg)] [&_svg]:text-[var(--bg)]', - default: - 'bg-[var(--surface-7)] dark:bg-[var(--surface-5)] text-[var(--text-primary)] [&_svg]:text-[var(--text-primary)]', -} +function getItemStateClasses( + variant: PopoverVariant, + colorScheme: PopoverColorScheme, + isActive: boolean +): string { + const state = isActive ? 'active' : 'hover' + + if (variant === 'secondary') { + return STYLES.states.secondary[state] + } -/** - * Variant-specific hover state styles for popover items. - */ -const POPOVER_ITEM_HOVER_CLASSES = { - secondary: - 'hover:bg-[var(--brand-secondary)] hover:text-[var(--bg)] hover:[&_svg]:text-[var(--bg)]', - default: - 'hover:bg-[var(--surface-7)] dark:hover:bg-[var(--surface-5)] hover:text-[var(--text-primary)] hover:[&_svg]:text-[var(--text-primary)]', + return colorScheme === 'inverted' ? STYLES.states.inverted[state] : STYLES.states.default[state] } -type PopoverVariant = 'default' | 'secondary' - interface PopoverContextValue { openFolder: ( id: string, @@ -116,6 +165,7 @@ interface PopoverContextValue { onFolderSelect: (() => void) | null variant: PopoverVariant size: PopoverSize + colorScheme: PopoverColorScheme searchQuery: string setSearchQuery: (query: string) => void } @@ -143,23 +193,23 @@ export interface PopoverProps extends PopoverPrimitive.PopoverProps { * @default 'md' */ size?: PopoverSize + /** + * Color scheme for the popover + * - default: light background in light mode, dark in dark mode + * - inverted: dark background (#1b1b1b) in light mode, matches tooltip styling + * @default 'default' + */ + colorScheme?: PopoverColorScheme } /** * Root popover component. Manages open state and folder navigation context. - * - * @example - * ```tsx - * - * ... - * ... - * - * ``` */ const Popover: React.FC = ({ children, variant = 'default', size = 'md', + colorScheme = 'default', ...props }) => { const [currentFolder, setCurrentFolder] = React.useState(null) @@ -185,7 +235,7 @@ const Popover: React.FC = ({ setOnFolderSelect(null) }, []) - const contextValue: PopoverContextValue = React.useMemo( + const contextValue = React.useMemo( () => ({ openFolder, closeFolder, @@ -195,6 +245,7 @@ const Popover: React.FC = ({ onFolderSelect, variant, size, + colorScheme, searchQuery, setSearchQuery, }), @@ -206,6 +257,7 @@ const Popover: React.FC = ({ onFolderSelect, variant, size, + colorScheme, searchQuery, ] ) @@ -222,13 +274,6 @@ Popover.displayName = 'Popover' /** * Trigger element that opens/closes the popover when clicked. * Use asChild to render as a custom component. - * - * @example - * ```tsx - * - * - * - * ``` */ const PopoverTrigger = PopoverPrimitive.Trigger @@ -244,74 +289,48 @@ export interface PopoverContentProps 'side' | 'align' | 'sideOffset' | 'alignOffset' | 'collisionPadding' > { /** - * When true, renders the popover content inline instead of in a portal. - * Useful when used inside other portalled components (e.g. dialogs) - * where additional portals can interfere with scroll locking behavior. + * Renders content inline instead of in a portal. + * Useful inside dialogs where portals interfere with scroll locking. * @default false */ disablePortal?: boolean - /** - * Maximum height for the popover content in pixels - */ + /** Maximum height in pixels */ maxHeight?: number - /** - * Maximum width for the popover content in pixels. - * When provided, Popover will also enable default truncation for inner text and section headers. - */ + /** Maximum width in pixels. Enables text truncation when set. */ maxWidth?: number - /** - * Minimum width for the popover content in pixels - */ + /** Minimum width in pixels */ minWidth?: number /** - * Preferred side to display the popover + * Preferred side to display * @default 'bottom' */ side?: 'top' | 'right' | 'bottom' | 'left' /** - * Alignment of the popover relative to anchor + * Alignment relative to anchor * @default 'start' */ align?: 'start' | 'center' | 'end' - /** - * Offset from the anchor in pixels. - * Defaults to 22px for top side (to avoid covering cursor) and 10px for other sides. - */ + /** Offset from anchor. Defaults to 20px for top, 14px for other sides. */ sideOffset?: number /** - * Padding from viewport edges in pixels + * Padding from viewport edges * @default 8 */ collisionPadding?: number /** - * When true, adds a border to the popover content + * Adds border to content * @default false */ border?: boolean /** - * When true, the popover will flip to avoid collisions with viewport edges + * Flip to avoid viewport collisions * @default true */ avoidCollisions?: boolean } /** - * Shared styles for popover content container. - * Both sizes use same padding and 6px border radius. - */ -const POPOVER_CONTENT_CLASSES = 'px-[6px] py-[6px] rounded-[6px]' - -/** - * Popover content component with automatic positioning and collision detection. - * Wraps children in a styled container with scrollable area. - * - * @example - * ```tsx - * - * Item 1 - * Item 2 - * - * ``` + * Popover content with automatic positioning and collision detection. */ const PopoverContent = React.forwardRef< React.ElementRef, @@ -340,13 +359,10 @@ const PopoverContent = React.forwardRef< ) => { const context = React.useContext(PopoverContext) const size = context?.size || 'md' + const colorScheme = context?.colorScheme || 'default' - // Smart default offset: larger offset when rendering above to avoid covering cursor const effectiveSideOffset = sideOffset ?? (side === 'top' ? 20 : 14) - // Detect explicit width constraints provided by the consumer. - // When present, we enable default text truncation behavior for inner flexible items, - // so callers don't need to manually pass 'truncate' to every label. const hasUserWidthConstraint = maxWidth !== undefined || minWidth !== undefined || @@ -359,29 +375,21 @@ const PopoverContent = React.forwardRef< if (!container) return const { scrollHeight, clientHeight, scrollTop } = container - if (scrollHeight <= clientHeight) { - return - } + if (scrollHeight <= clientHeight) return const deltaY = event.deltaY const isScrollingDown = deltaY > 0 const isAtTop = scrollTop === 0 const isAtBottom = scrollTop + clientHeight >= scrollHeight - // If we're at the boundary and user keeps scrolling in that direction, - // let the event bubble so parent scroll containers can handle it. - if ((isScrollingDown && isAtBottom) || (!isScrollingDown && isAtTop)) { - return - } + if ((isScrollingDown && isAtBottom) || (!isScrollingDown && isAtTop)) return - // Otherwise, consume the wheel event and manually scroll the popover content. event.preventDefault() container.scrollTop += deltaY } const handleOpenAutoFocus = React.useCallback( (e: Event) => { - // Always prevent auto-focus to avoid flickering from focus-triggered repositioning e.preventDefault() onOpenAutoFocus?.(e) }, @@ -390,7 +398,6 @@ const PopoverContent = React.forwardRef< const handleCloseAutoFocus = React.useCallback( (e: Event) => { - // Always prevent auto-focus to avoid flickering from focus-triggered repositioning e.preventDefault() onCloseAutoFocus?.(e) }, @@ -412,11 +419,9 @@ const PopoverContent = React.forwardRef< onCloseAutoFocus={handleCloseAutoFocus} {...restProps} className={cn( - // will-change-transform creates a new GPU compositing layer to prevent paint flickering - 'z-[10000200] flex flex-col overflow-auto bg-[var(--surface-5)] text-foreground outline-none will-change-transform dark:bg-[var(--surface-3)]', - POPOVER_CONTENT_CLASSES, - // If width is constrained by the caller (prop or style), ensure inner flexible text truncates by default, - // and also truncate section headers. + 'z-[10000200] flex flex-col overflow-auto outline-none will-change-transform', + STYLES.colorScheme[colorScheme].content, + STYLES.content, hasUserWidthConstraint && '[&_.flex-1]:truncate [&_[data-popover-section]]:truncate', border && 'border border-[var(--border-1)]', className @@ -424,7 +429,6 @@ const PopoverContent = React.forwardRef< style={{ maxHeight: `${maxHeight || 400}px`, maxWidth: maxWidth !== undefined ? `${maxWidth}px` : 'calc(100vw - 16px)', - // Only enforce default min width when the user hasn't set width constraints minWidth: minWidth !== undefined ? `${minWidth}px` @@ -440,9 +444,7 @@ const PopoverContent = React.forwardRef< ) - if (disablePortal) { - return content - } + if (disablePortal) return content return {content} } @@ -453,83 +455,52 @@ PopoverContent.displayName = 'PopoverContent' export interface PopoverScrollAreaProps extends React.HTMLAttributes {} /** - * Scrollable area container for popover items. - * Use this to wrap items that should scroll within the popover. - * - * @example - * ```tsx - * - * - * Item 1 - * Item 2 - * - * - * ``` + * Scrollable container for popover items. */ const PopoverScrollArea = React.forwardRef( - ({ className, ...props }, ref) => { - return ( -
div:has([data-popover-section]):not(:first-child)]:mt-[6px]', - className - )} - ref={ref} - {...props} - /> - ) - } + ({ className, ...props }, ref) => ( +
div:has([data-popover-section]):not(:first-child)]:mt-[6px]', + className + )} + ref={ref} + {...props} + /> + ) ) PopoverScrollArea.displayName = 'PopoverScrollArea' export interface PopoverItemProps extends React.HTMLAttributes { - /** - * Whether this item is currently active/selected - */ + /** Whether this item is currently active/selected */ active?: boolean - /** - * If true, this item will only show when not inside any folder - */ + /** Only show when not inside any folder */ rootOnly?: boolean - /** - * Whether this item is disabled - */ + /** Whether this item is disabled */ disabled?: boolean /** - * Whether to show a checkmark when active + * Show checkmark when active * @default false */ showCheck?: boolean } /** - * Popover item component for individual items within a popover. - * - * @example - * ```tsx - * handleClick()}> - * - * Item label - * - * ``` + * Individual popover item with hover and active states. */ const PopoverItem = React.forwardRef( ( { className, active, rootOnly, disabled, showCheck = false, children, onClick, ...props }, ref ) => { - // Try to get context - if not available, we're outside Popover (shouldn't happen) const context = React.useContext(PopoverContext) const variant = context?.variant || 'default' const size = context?.size || 'md' + const colorScheme = context?.colorScheme || 'default' - // If rootOnly is true and we're in a folder, don't render - if (rootOnly && context?.isInFolder) { - return null - } + if (rootOnly && context?.isInFolder) return null const handleClick = (e: React.MouseEvent) => { if (disabled) { @@ -542,9 +513,10 @@ const PopoverItem = React.forwardRef( return (
( {...props} > {children} - {showCheck && active && ( - - )} + {showCheck && active && }
) } @@ -567,46 +537,27 @@ const PopoverItem = React.forwardRef( PopoverItem.displayName = 'PopoverItem' export interface PopoverSectionProps extends React.HTMLAttributes { - /** - * If true, this section will only show when not inside any folder - */ + /** Only show when not inside any folder */ rootOnly?: boolean } /** - * Size-specific styles for popover section headers. - * Shared: 6px padding, 4px vertical padding - */ -const POPOVER_SECTION_SIZE_CLASSES: Record = { - sm: 'px-[6px] py-[4px] text-[11px]', - md: 'px-[6px] py-[4px] text-[13px]', -} - -/** - * Popover section header component for grouping items with a title. - * - * @example - * ```tsx - * - * Section Title - * - * ``` + * Section header for grouping popover items. */ const PopoverSection = React.forwardRef( ({ className, rootOnly, ...props }, ref) => { const context = React.useContext(PopoverContext) const size = context?.size || 'md' + const colorScheme = context?.colorScheme || 'default' - // If rootOnly is true and we're in a folder, don't render - if (rootOnly && context?.isInFolder) { - return null - } + if (rootOnly && context?.isInFolder) return null return (
( PopoverSection.displayName = 'PopoverSection' export interface PopoverFolderProps extends Omit, 'children'> { - /** - * Unique identifier for the folder - */ + /** Unique folder identifier */ id: string - /** - * Display title for the folder - */ + /** Display title */ title: string - /** - * Icon to display before the title - */ + /** Icon before title */ icon?: React.ReactNode - /** - * Function to call when folder is opened (for lazy loading) - */ + /** Callback when folder opens (for lazy loading) */ onOpen?: () => void | Promise - /** - * Function to call when the folder title is selected (from within the folder view) - */ + /** Callback when folder title is selected from within folder view */ onSelect?: () => void - /** - * Children to render when folder is open - */ + /** Folder contents */ children?: React.ReactNode - /** - * Whether this item is currently active/selected - */ + /** Whether currently active/selected */ active?: boolean } /** - * Popover folder component that expands to show nested content. - * Automatically handles navigation and back button rendering. - * - * @example - * ```tsx - * }> - * Workflow 1 - * Workflow 2 - * - * ``` + * Expandable folder that shows nested content. */ const PopoverFolder = React.forwardRef( ({ className, id, title, icon, onOpen, onSelect, children, active, ...props }, ref) => { - const { openFolder, currentFolder, isInFolder, variant, size } = usePopoverContext() + const { openFolder, currentFolder, isInFolder, variant, size, colorScheme } = + usePopoverContext() - // Don't render if we're in a different folder - if (isInFolder && currentFolder !== id) { - return null - } + if (isInFolder && currentFolder !== id) return null + if (currentFolder === id) return <>{children} - // If we're in this folder, render its children - if (currentFolder === id) { - return <>{children} - } - - // Handle click anywhere on folder item const handleClick = (e: React.MouseEvent) => { e.stopPropagation() openFolder(id, title, onOpen, onSelect) } - // Otherwise, render as a clickable folder item return (
( > {icon} {title} - +
) } @@ -709,42 +630,23 @@ const PopoverFolder = React.forwardRef( PopoverFolder.displayName = 'PopoverFolder' export interface PopoverBackButtonProps extends React.HTMLAttributes { - /** - * Ref callback for the folder title element (when selectable) - */ + /** Ref callback for folder title element */ folderTitleRef?: (el: HTMLElement | null) => void - /** - * Whether the folder title is currently active/selected - */ + /** Whether folder title is active/selected */ folderTitleActive?: boolean - /** - * Callback when mouse enters the folder title - */ + /** Callback on folder title mouse enter */ onFolderTitleMouseEnter?: () => void } /** - * Back button component that appears when inside a folder. - * Automatically hidden when at root level. - * - * @example - * ```tsx - * - * - * - * // content - * - * - * ``` + * Back button shown inside folders. Hidden at root level. */ const PopoverBackButton = React.forwardRef( ({ className, folderTitleRef, folderTitleActive, onFolderTitleMouseEnter, ...props }, ref) => { - const { isInFolder, closeFolder, folderTitle, onFolderSelect, variant, size } = + const { isInFolder, closeFolder, folderTitle, onFolderSelect, variant, size, colorScheme } = usePopoverContext() - if (!isInFolder) { - return null - } + if (!isInFolder) return null return (
@@ -752,28 +654,27 @@ const PopoverBackButton = React.forwardRef - + Back
{folderTitle && onFolderSelect && (
{folderTitle} @@ -805,43 +707,20 @@ PopoverBackButton.displayName = 'PopoverBackButton' export interface PopoverSearchProps extends React.HTMLAttributes { /** - * Placeholder text for the search input + * Placeholder text * @default 'Search...' */ placeholder?: string - /** - * Callback when search query changes - */ + /** Callback when query changes */ onValueChange?: (value: string) => void } /** - * Size-specific styles for popover search container. - * Shared: padding - */ -const POPOVER_SEARCH_SIZE_CLASSES: Record = { - sm: 'px-[8px] py-[6px] text-[11px]', - md: 'px-[8px] py-[6px] text-[13px]', -} - -/** - * Search input component for filtering popover items. - * - * @example - * ```tsx - * - * - * - * - * // items - * - * - * - * ``` + * Search input for filtering popover items. */ const PopoverSearch = React.forwardRef( ({ className, placeholder = 'Search...', onValueChange, ...props }, ref) => { - const { searchQuery, setSearchQuery, size } = usePopoverContext() + const { searchQuery, setSearchQuery, size, colorScheme } = usePopoverContext() const inputRef = React.useRef(null) const handleChange = (e: React.ChangeEvent) => { @@ -857,18 +736,19 @@ const PopoverSearch = React.forwardRef( }, [setSearchQuery, onValueChange]) return ( -
+
( PopoverSearch.displayName = 'PopoverSearch' +export interface PopoverDividerProps extends React.HTMLAttributes { + /** Only show when not inside any folder */ + rootOnly?: boolean +} + +/** + * Horizontal divider for separating popover sections. + */ +const PopoverDivider = React.forwardRef( + ({ className, rootOnly, ...props }, ref) => { + const context = React.useContext(PopoverContext) + const colorScheme = context?.colorScheme || 'default' + + if (rootOnly && context?.isInFolder) return null + + return ( +
+ ) + } +) + +PopoverDivider.displayName = 'PopoverDivider' + export { Popover, PopoverTrigger, @@ -893,7 +801,8 @@ export { PopoverFolder, PopoverBackButton, PopoverSearch, + PopoverDivider, usePopoverContext, } -export type { PopoverSize } +export type { PopoverSize, PopoverColorScheme } diff --git a/apps/sim/components/emcn/components/tooltip/tooltip.tsx b/apps/sim/components/emcn/components/tooltip/tooltip.tsx index 705d7a3769..f3c42c0c99 100644 --- a/apps/sim/components/emcn/components/tooltip/tooltip.tsx +++ b/apps/sim/components/emcn/components/tooltip/tooltip.tsx @@ -45,13 +45,13 @@ const Content = React.forwardRef< collisionPadding={8} avoidCollisions={true} className={cn( - 'z-[10000300] rounded-[3px] bg-black px-[7.5px] py-[6px] font-base text-white text-xs shadow-md dark:bg-white dark:text-black', + 'z-[10000300] rounded-[4px] bg-[#1b1b1b] px-[8px] py-[3.5px] font-base text-white text-xs shadow-sm dark:bg-[#fdfdfd] dark:text-black', className )} {...props} > {props.children} - + )) From ac942416de9ad5b382019c867c30efaf0261dd4c Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sat, 3 Jan 2026 17:40:55 -0800 Subject: [PATCH 4/7] fix(kalshi): remove synthetically constructed outputs (#2677) * fix(kalshi): remove synthetically constructed outputs * fix api interface --- apps/sim/tools/kalshi/get_balance.ts | 12 +++--------- apps/sim/tools/kalshi/types.ts | 2 +- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/apps/sim/tools/kalshi/get_balance.ts b/apps/sim/tools/kalshi/get_balance.ts index bc273fea95..1ebcd94373 100644 --- a/apps/sim/tools/kalshi/get_balance.ts +++ b/apps/sim/tools/kalshi/get_balance.ts @@ -8,9 +8,7 @@ export interface KalshiGetBalanceResponse { success: boolean output: { balance: number // In cents - portfolioValue?: number // In cents - balanceDollars: number // Converted to dollars - portfolioValueDollars?: number // Converted to dollars + portfolioValue: number // In cents } } @@ -51,16 +49,14 @@ export const kalshiGetBalanceTool: ToolConfig Date: Sun, 4 Jan 2026 12:41:33 -0800 Subject: [PATCH 5/7] fix(grain): save before deploying workflow (#2678) * save before deployment fix * moved to helper * removed comment --- .../app/api/webhooks/trigger/[path]/route.ts | 6 ++++ apps/sim/lib/webhooks/processor.ts | 28 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.ts index 549ce6a78d..9cdff87dc5 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.ts @@ -5,6 +5,7 @@ import { checkWebhookPreprocessing, findWebhookAndWorkflow, handleProviderChallenges, + handleProviderReachabilityTest, parseWebhookBody, queueWebhookExecution, verifyProviderAuth, @@ -123,6 +124,11 @@ export async function POST( return authError } + const reachabilityResponse = handleProviderReachabilityTest(foundWebhook, body, requestId) + if (reachabilityResponse) { + return reachabilityResponse + } + let preprocessError: NextResponse | null = null try { preprocessError = await checkWebhookPreprocessing(foundWorkflow, foundWebhook, requestId) diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts index b197a8ef18..96cf01b8f3 100644 --- a/apps/sim/lib/webhooks/processor.ts +++ b/apps/sim/lib/webhooks/processor.ts @@ -121,6 +121,34 @@ export async function handleProviderChallenges( return null } +/** + * Handle provider-specific reachability tests that occur AFTER webhook lookup. + * + * @param webhook - The webhook record from the database + * @param body - The parsed request body + * @param requestId - Request ID for logging + * @returns NextResponse if this is a verification request, null to continue normal flow + */ +export function handleProviderReachabilityTest( + webhook: any, + body: any, + requestId: string +): NextResponse | null { + const provider = webhook?.provider + + if (provider === 'grain') { + const isVerificationRequest = !body || Object.keys(body).length === 0 || !body.type + if (isVerificationRequest) { + logger.info( + `[${requestId}] Grain reachability test detected - returning 200 for webhook verification` + ) + return NextResponse.json({ status: 'ok', message: 'Webhook endpoint verified' }) + } + } + + return null +} + export async function findWebhookAndWorkflow( options: WebhookProcessorOptions ): Promise<{ webhook: any; workflow: any } | null> { From ed6b9c0c4a98843f9cacbcf186b67109625849af Mon Sep 17 00:00:00 2001 From: Waleed Date: Sun, 4 Jan 2026 23:47:54 -0800 Subject: [PATCH 6/7] fix(kb): fix styling inconsistencies, add rename capability for documents, added search preview (#2680) --- .../chunk-context-menu/chunk-context-menu.tsx | 34 ++- .../knowledge/[id]/[documentId]/document.tsx | 269 ++++++++++-------- .../[workspaceId]/knowledge/[id]/base.tsx | 111 +++++++- .../document-context-menu.tsx | 44 ++- .../knowledge/[id]/components/index.ts | 1 + .../components/rename-document-modal/index.ts | 1 + .../rename-document-modal.tsx | 136 +++++++++ apps/sim/components/ui/search-highlight.tsx | 3 +- 8 files changed, 473 insertions(+), 126 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/rename-document-modal/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/rename-document-modal/rename-document-modal.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-context-menu/chunk-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-context-menu/chunk-context-menu.tsx index eea79e3f86..ebdf27f537 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-context-menu/chunk-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-context-menu/chunk-context-menu.tsx @@ -39,11 +39,24 @@ interface ChunkContextMenuProps { * Whether add chunk is disabled */ disableAddChunk?: boolean + /** + * Number of selected chunks (for batch operations) + */ + selectedCount?: number + /** + * Number of enabled chunks in selection + */ + enabledCount?: number + /** + * Number of disabled chunks in selection + */ + disabledCount?: number } /** * Context menu for chunks table. * Shows chunk actions when right-clicking a row, or "Create chunk" when right-clicking empty space. + * Supports batch operations when multiple chunks are selected. */ export function ChunkContextMenu({ isOpen, @@ -61,7 +74,20 @@ export function ChunkContextMenu({ disableToggleEnabled = false, disableDelete = false, disableAddChunk = false, + selectedCount = 1, + enabledCount = 0, + disabledCount = 0, }: ChunkContextMenuProps) { + const isMultiSelect = selectedCount > 1 + + const getToggleLabel = () => { + if (isMultiSelect) { + if (disabledCount > 0) return 'Enable' + return 'Disable' + } + return isChunkEnabled ? 'Disable' : 'Enable' + } + return ( {hasChunk ? ( <> - {onOpenInNewTab && ( + {!isMultiSelect && onOpenInNewTab && ( { onOpenInNewTab() @@ -86,7 +112,7 @@ export function ChunkContextMenu({ Open in new tab )} - {onEdit && ( + {!isMultiSelect && onEdit && ( { onEdit() @@ -96,7 +122,7 @@ export function ChunkContextMenu({ Edit )} - {onCopyContent && ( + {!isMultiSelect && onCopyContent && ( { onCopyContent() @@ -114,7 +140,7 @@ export function ChunkContextMenu({ onClose() }} > - {isChunkEnabled ? 'Disable' : 'Enable'} + {getToggleLabel()} )} {onDelete && ( diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx index 87117ebbd2..d6675928e3 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -15,6 +15,7 @@ import { } from 'lucide-react' import { useParams, useRouter, useSearchParams } from 'next/navigation' import { + Badge, Breadcrumb, Button, Checkbox, @@ -107,14 +108,31 @@ interface DocumentProps { documentName?: string } -function getStatusBadgeStyles(enabled: boolean) { - return enabled - ? 'inline-flex items-center rounded-md bg-green-100 px-2 py-1 text-xs font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400' - : 'inline-flex items-center rounded-md bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300' -} - -function truncateContent(content: string, maxLength = 150): string { +function truncateContent(content: string, maxLength = 150, searchQuery = ''): string { if (content.length <= maxLength) return content + + if (searchQuery.trim()) { + const searchTerms = searchQuery + .trim() + .split(/\s+/) + .filter((term) => term.length > 0) + .map((term) => term.toLowerCase()) + + for (const term of searchTerms) { + const matchIndex = content.toLowerCase().indexOf(term) + if (matchIndex !== -1) { + const contextBefore = 30 + const start = Math.max(0, matchIndex - contextBefore) + const end = Math.min(content.length, start + maxLength) + + let result = content.substring(start, end) + if (start > 0) result = `...${result}` + if (end < content.length) result = `${result}...` + return result + } + } + } + return `${content.substring(0, maxLength)}...` } @@ -655,13 +673,21 @@ export function Document({ /** * Handle right-click on a chunk row + * If right-clicking on an unselected chunk, select only that chunk + * If right-clicking on a selected chunk with multiple selections, keep all selections */ const handleChunkContextMenu = useCallback( (e: React.MouseEvent, chunk: ChunkData) => { + const isCurrentlySelected = selectedChunks.has(chunk.id) + + if (!isCurrentlySelected) { + setSelectedChunks(new Set([chunk.id])) + } + setContextMenuChunk(chunk) baseHandleContextMenu(e) }, - [baseHandleContextMenu] + [selectedChunks, baseHandleContextMenu] ) /** @@ -946,106 +972,114 @@ export function Document({ ) : ( - displayChunks.map((chunk: ChunkData) => ( - handleChunkClick(chunk)} - onContextMenu={(e) => handleChunkContextMenu(e, chunk)} - > - { + const isSelected = selectedChunks.has(chunk.id) + + return ( + handleChunkClick(chunk)} + onContextMenu={(e) => handleChunkContextMenu(e, chunk)} > -
- - handleSelectChunk(chunk.id, checked as boolean) - } - disabled={!userPermissions.canEdit} - aria-label={`Select chunk ${chunk.chunkIndex}`} - onClick={(e) => e.stopPropagation()} - /> -
-
- - {chunk.chunkIndex} - - - - - - - - {chunk.tokenCount > 1000 - ? `${(chunk.tokenCount / 1000).toFixed(1)}k` - : chunk.tokenCount} - - -
- {chunk.enabled ? 'Enabled' : 'Disabled'} -
-
- -
- - - - - - {!userPermissions.canEdit - ? 'Write permission required to modify chunks' - : chunk.enabled - ? 'Disable Chunk' - : 'Enable Chunk'} - - - - - - - - {!userPermissions.canEdit - ? 'Write permission required to delete chunks' - : 'Delete Chunk'} - - -
-
-
- )) +
+ + handleSelectChunk(chunk.id, checked as boolean) + } + disabled={!userPermissions.canEdit} + aria-label={`Select chunk ${chunk.chunkIndex}`} + onClick={(e) => e.stopPropagation()} + /> +
+ + + {chunk.chunkIndex} + + + + + + + + {chunk.tokenCount > 1000 + ? `${(chunk.tokenCount / 1000).toFixed(1)}k` + : chunk.tokenCount.toLocaleString()} + + + + {chunk.enabled ? 'Enabled' : 'Disabled'} + + + +
+ + + + + + {!userPermissions.canEdit + ? 'Write permission required to modify chunks' + : chunk.enabled + ? 'Disable Chunk' + : 'Enable Chunk'} + + + + + + + + {!userPermissions.canEdit + ? 'Write permission required to delete chunks' + : 'Delete Chunk'} + + +
+
+ + ) + }) )} @@ -1206,8 +1240,11 @@ export function Document({ onClose={handleContextMenuClose} hasChunk={contextMenuChunk !== null} isChunkEnabled={contextMenuChunk?.enabled ?? true} + selectedCount={selectedChunks.size} + enabledCount={enabledCount} + disabledCount={disabledCount} onOpenInNewTab={ - contextMenuChunk + contextMenuChunk && selectedChunks.size === 1 ? () => { const url = `/workspace/${workspaceId}/knowledge/${knowledgeBaseId}/${documentId}?chunk=${contextMenuChunk.id}` window.open(url, '_blank') @@ -1215,7 +1252,7 @@ export function Document({ : undefined } onEdit={ - contextMenuChunk + contextMenuChunk && selectedChunks.size === 1 ? () => { setSelectedChunk(contextMenuChunk) setIsModalOpen(true) @@ -1223,7 +1260,7 @@ export function Document({ : undefined } onCopyContent={ - contextMenuChunk + contextMenuChunk && selectedChunks.size === 1 ? () => { navigator.clipboard.writeText(contextMenuChunk.content) } @@ -1231,12 +1268,22 @@ export function Document({ } onToggleEnabled={ contextMenuChunk && userPermissions.canEdit - ? () => handleToggleEnabled(contextMenuChunk.id) + ? selectedChunks.size > 1 + ? () => { + if (disabledCount > 0) { + handleBulkEnable() + } else { + handleBulkDisable() + } + } + : () => handleToggleEnabled(contextMenuChunk.id) : undefined } onDelete={ contextMenuChunk && userPermissions.canEdit - ? () => handleDeleteChunk(contextMenuChunk.id) + ? selectedChunks.size > 1 + ? handleBulkDelete + : () => handleDeleteChunk(contextMenuChunk.id) : undefined } onAddChunk={ diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index 67a8534cde..21c0f4d093 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' +import { useQueryClient } from '@tanstack/react-query' import { format } from 'date-fns' import { AlertCircle, @@ -47,10 +48,12 @@ import { AddDocumentsModal, BaseTagsModal, DocumentContextMenu, + RenameDocumentModal, } from '@/app/workspace/[workspaceId]/knowledge/[id]/components' import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' +import { knowledgeKeys } from '@/hooks/queries/knowledge' import { useKnowledgeBase, useKnowledgeBaseDocuments, @@ -404,6 +407,7 @@ export function KnowledgeBase({ id, knowledgeBaseName: passedKnowledgeBaseName, }: KnowledgeBaseProps) { + const queryClient = useQueryClient() const params = useParams() const workspaceId = params.workspaceId as string const { removeKnowledgeBase } = useKnowledgeBasesList(workspaceId, { enabled: false }) @@ -432,6 +436,8 @@ export function KnowledgeBase({ const [sortBy, setSortBy] = useState('uploadedAt') const [sortOrder, setSortOrder] = useState('desc') const [contextMenuDocument, setContextMenuDocument] = useState(null) + const [showRenameModal, setShowRenameModal] = useState(false) + const [documentToRename, setDocumentToRename] = useState(null) const { isOpen: isContextMenuOpen, @@ -699,6 +705,60 @@ export function KnowledgeBase({ } } + /** + * Opens the rename document modal + */ + const handleRenameDocument = (doc: DocumentData) => { + setDocumentToRename(doc) + setShowRenameModal(true) + } + + /** + * Saves the renamed document + */ + const handleSaveRename = async (documentId: string, newName: string) => { + const currentDoc = documents.find((doc) => doc.id === documentId) + const previousName = currentDoc?.filename + + updateDocument(documentId, { filename: newName }) + queryClient.setQueryData(knowledgeKeys.document(id, documentId), (previous) => + previous ? { ...previous, filename: newName } : previous + ) + + try { + const response = await fetch(`/api/knowledge/${id}/documents/${documentId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ filename: newName }), + }) + + if (!response.ok) { + const result = await response.json() + throw new Error(result.error || 'Failed to rename document') + } + + const result = await response.json() + + if (!result.success) { + throw new Error(result.error || 'Failed to rename document') + } + + logger.info(`Document renamed: ${documentId}`) + } catch (err) { + if (previousName !== undefined) { + updateDocument(documentId, { filename: previousName }) + queryClient.setQueryData( + knowledgeKeys.document(id, documentId), + (previous) => (previous ? { ...previous, filename: previousName } : previous) + ) + } + logger.error('Error renaming document:', err) + throw err + } + } + /** * Opens the delete document confirmation modal */ @@ -968,13 +1028,21 @@ export function KnowledgeBase({ /** * Handle right-click on a document row + * If right-clicking on an unselected document, select only that document + * If right-clicking on a selected document with multiple selections, keep all selections */ const handleDocumentContextMenu = useCallback( (e: React.MouseEvent, doc: DocumentData) => { + const isCurrentlySelected = selectedDocuments.has(doc.id) + + if (!isCurrentlySelected) { + setSelectedDocuments(new Set([doc.id])) + } + setContextMenuDocument(doc) baseHandleContextMenu(e) }, - [baseHandleContextMenu] + [selectedDocuments, baseHandleContextMenu] ) /** @@ -1211,7 +1279,9 @@ export function KnowledgeBase({ { if (doc.processingStatus === 'completed') { @@ -1558,6 +1628,17 @@ export function KnowledgeBase({ chunkingConfig={knowledgeBase?.chunkingConfig} /> + {/* Rename Document Modal */} + {documentToRename && ( + + )} + 0 ? handleBulkEnable : undefined} @@ -1580,8 +1661,11 @@ export function KnowledgeBase({ ? getDocumentTags(contextMenuDocument, tagDefinitions).length > 0 : false } + selectedCount={selectedDocuments.size} + enabledCount={enabledCount} + disabledCount={disabledCount} onOpenInNewTab={ - contextMenuDocument + contextMenuDocument && selectedDocuments.size === 1 ? () => { const urlParams = new URLSearchParams({ kbName: knowledgeBaseName, @@ -1594,13 +1678,26 @@ export function KnowledgeBase({ } : undefined } + onRename={ + contextMenuDocument && selectedDocuments.size === 1 && userPermissions.canEdit + ? () => handleRenameDocument(contextMenuDocument) + : undefined + } onToggleEnabled={ contextMenuDocument && userPermissions.canEdit - ? () => handleToggleEnabled(contextMenuDocument.id) + ? selectedDocuments.size > 1 + ? () => { + if (disabledCount > 0) { + handleBulkEnable() + } else { + handleBulkDisable() + } + } + : () => handleToggleEnabled(contextMenuDocument.id) : undefined } onViewTags={ - contextMenuDocument + contextMenuDocument && selectedDocuments.size === 1 ? () => { const urlParams = new URLSearchParams({ kbName: knowledgeBaseName, @@ -1614,7 +1711,9 @@ export function KnowledgeBase({ } onDelete={ contextMenuDocument && userPermissions.canEdit - ? () => handleDeleteDocument(contextMenuDocument.id) + ? selectedDocuments.size > 1 + ? handleBulkDelete + : () => handleDeleteDocument(contextMenuDocument.id) : undefined } onAddDocument={userPermissions.canEdit ? handleAddDocuments : undefined} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/document-context-menu/document-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/document-context-menu/document-context-menu.tsx index 9916442a6a..ca9df6b722 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/document-context-menu/document-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/document-context-menu/document-context-menu.tsx @@ -11,6 +11,7 @@ interface DocumentContextMenuProps { * Document-specific actions (shown when right-clicking on a document) */ onOpenInNewTab?: () => void + onRename?: () => void onToggleEnabled?: () => void onViewTags?: () => void onDelete?: () => void @@ -42,11 +43,24 @@ interface DocumentContextMenuProps { * Whether add document is disabled */ disableAddDocument?: boolean + /** + * Number of selected documents (for batch operations) + */ + selectedCount?: number + /** + * Number of enabled documents in selection + */ + enabledCount?: number + /** + * Number of disabled documents in selection + */ + disabledCount?: number } /** * Context menu for documents table. * Shows document actions when right-clicking a row, or "Add Document" when right-clicking empty space. + * Supports batch operations when multiple documents are selected. */ export function DocumentContextMenu({ isOpen, @@ -54,6 +68,7 @@ export function DocumentContextMenu({ menuRef, onClose, onOpenInNewTab, + onRename, onToggleEnabled, onViewTags, onDelete, @@ -64,7 +79,20 @@ export function DocumentContextMenu({ disableToggleEnabled = false, disableDelete = false, disableAddDocument = false, + selectedCount = 1, + enabledCount = 0, + disabledCount = 0, }: DocumentContextMenuProps) { + const isMultiSelect = selectedCount > 1 + + const getToggleLabel = () => { + if (isMultiSelect) { + if (disabledCount > 0) return 'Enable' + return 'Disable' + } + return isDocumentEnabled ? 'Disable' : 'Enable' + } + return ( {hasDocument ? ( <> - {onOpenInNewTab && ( + {!isMultiSelect && onOpenInNewTab && ( { onOpenInNewTab() @@ -89,7 +117,17 @@ export function DocumentContextMenu({ Open in new tab )} - {hasTags && onViewTags && ( + {!isMultiSelect && onRename && ( + { + onRename() + onClose() + }} + > + Rename + + )} + {!isMultiSelect && hasTags && onViewTags && ( { onViewTags() @@ -107,7 +145,7 @@ export function DocumentContextMenu({ onClose() }} > - {isDocumentEnabled ? 'Disable' : 'Enable'} + {getToggleLabel()} )} {onDelete && ( diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts index 478e7911c2..b0e8ca1434 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts @@ -2,3 +2,4 @@ export { ActionBar } from './action-bar/action-bar' export { AddDocumentsModal } from './add-documents-modal/add-documents-modal' export { BaseTagsModal } from './base-tags-modal/base-tags-modal' export { DocumentContextMenu } from './document-context-menu' +export { RenameDocumentModal } from './rename-document-modal' diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/rename-document-modal/index.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/rename-document-modal/index.ts new file mode 100644 index 0000000000..d1505e6f52 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/rename-document-modal/index.ts @@ -0,0 +1 @@ +export { RenameDocumentModal } from './rename-document-modal' diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/rename-document-modal/rename-document-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/rename-document-modal/rename-document-modal.tsx new file mode 100644 index 0000000000..8196bfb43d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/rename-document-modal/rename-document-modal.tsx @@ -0,0 +1,136 @@ +'use client' + +import { useEffect, useState } from 'react' +import { createLogger } from '@sim/logger' +import { + Button, + Input, + Label, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, +} from '@/components/emcn' +import { cn } from '@/lib/core/utils/cn' + +const logger = createLogger('RenameDocumentModal') + +interface RenameDocumentModalProps { + open: boolean + onOpenChange: (open: boolean) => void + documentId: string + initialName: string + onSave: (documentId: string, newName: string) => Promise +} + +/** + * Modal for renaming a document. + * Only changes the display name, not the underlying storage key. + */ +export function RenameDocumentModal({ + open, + onOpenChange, + documentId, + initialName, + onSave, +}: RenameDocumentModalProps) { + const [name, setName] = useState(initialName) + const [isSubmitting, setIsSubmitting] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + if (open) { + setName(initialName) + setError(null) + } + }, [open, initialName]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + const trimmedName = name.trim() + + if (!trimmedName) { + setError('Name is required') + return + } + + if (trimmedName === initialName) { + onOpenChange(false) + return + } + + setIsSubmitting(true) + setError(null) + + try { + await onSave(documentId, trimmedName) + onOpenChange(false) + } catch (err) { + logger.error('Error renaming document:', err) + setError(err instanceof Error ? err.message : 'Failed to rename document') + } finally { + setIsSubmitting(false) + } + } + + return ( + + + Rename Document +
+ +
+
+ + { + setName(e.target.value) + setError(null) + }} + placeholder='Enter document name' + className={cn(error && 'border-[var(--text-error)]')} + disabled={isSubmitting} + autoFocus + maxLength={255} + autoComplete='off' + autoCorrect='off' + autoCapitalize='off' + data-lpignore='true' + data-form-type='other' + /> +
+
+
+ +
+ {error ? ( +

+ {error} +

+ ) : ( +
+ )} +
+ + +
+
+ + + + + ) +} diff --git a/apps/sim/components/ui/search-highlight.tsx b/apps/sim/components/ui/search-highlight.tsx index 49fa427521..9a1322f8e8 100644 --- a/apps/sim/components/ui/search-highlight.tsx +++ b/apps/sim/components/ui/search-highlight.tsx @@ -11,7 +11,6 @@ export function SearchHighlight({ text, searchQuery, className = '' }: SearchHig return {text} } - // Create regex pattern for all search terms const searchTerms = searchQuery .trim() .split(/\s+/) @@ -35,7 +34,7 @@ export function SearchHighlight({ text, searchQuery, className = '' }: SearchHig return isMatch ? ( {part} From 0977ed228f94cc6e77894d1d9d262bfd8d8ec755 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 5 Jan 2026 00:29:31 -0800 Subject: [PATCH 7/7] improvement(kb): add configurable concurrency to chunks processing, sped up 22x for large docs (#2681) --- apps/sim/lib/core/config/env.ts | 6 +- apps/sim/lib/knowledge/documents/service.ts | 8 +-- apps/sim/lib/knowledge/embeddings.ts | 68 ++++++++++++++------- 3 files changed, 52 insertions(+), 30 deletions(-) diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index b42ae407a2..5cca8759be 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -174,9 +174,9 @@ export const env = createEnv({ KB_CONFIG_RETRY_FACTOR: z.number().optional().default(2), // Retry backoff factor KB_CONFIG_MIN_TIMEOUT: z.number().optional().default(1000), // Min timeout in ms KB_CONFIG_MAX_TIMEOUT: z.number().optional().default(10000), // Max timeout in ms - KB_CONFIG_CONCURRENCY_LIMIT: z.number().optional().default(20), // Queue concurrency limit - KB_CONFIG_BATCH_SIZE: z.number().optional().default(20), // Processing batch size - KB_CONFIG_DELAY_BETWEEN_BATCHES: z.number().optional().default(100), // Delay between batches in ms + KB_CONFIG_CONCURRENCY_LIMIT: z.number().optional().default(50), // Concurrent embedding API calls + KB_CONFIG_BATCH_SIZE: z.number().optional().default(2000), // Chunks to process per embedding batch + KB_CONFIG_DELAY_BETWEEN_BATCHES: z.number().optional().default(0), // Delay between batches in ms (0 for max speed) KB_CONFIG_DELAY_BETWEEN_DOCUMENTS: z.number().optional().default(50), // Delay between documents in ms // Real-time Communication diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index 19419fccf4..313ea8d395 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -29,10 +29,10 @@ const TIMEOUTS = { // Configuration for handling large documents const LARGE_DOC_CONFIG = { - MAX_CHUNKS_PER_BATCH: 500, // Insert embeddings in batches of 500 - MAX_EMBEDDING_BATCH: 500, // Generate embeddings in batches of 500 - MAX_FILE_SIZE: 100 * 1024 * 1024, // 100MB max file size - MAX_CHUNKS_PER_DOCUMENT: 100000, // Maximum chunks allowed per document + MAX_CHUNKS_PER_BATCH: 500, + MAX_EMBEDDING_BATCH: env.KB_CONFIG_BATCH_SIZE || 2000, + MAX_FILE_SIZE: 100 * 1024 * 1024, + MAX_CHUNKS_PER_DOCUMENT: 100000, } /** diff --git a/apps/sim/lib/knowledge/embeddings.ts b/apps/sim/lib/knowledge/embeddings.ts index 785d8347d6..7a736688be 100644 --- a/apps/sim/lib/knowledge/embeddings.ts +++ b/apps/sim/lib/knowledge/embeddings.ts @@ -7,6 +7,7 @@ import { batchByTokenLimit, getTotalTokenCount } from '@/lib/tokenization' const logger = createLogger('EmbeddingUtils') const MAX_TOKENS_PER_REQUEST = 8000 +const MAX_CONCURRENT_BATCHES = env.KB_CONFIG_CONCURRENCY_LIMIT || 50 export class EmbeddingAPIError extends Error { public status: number @@ -121,8 +122,29 @@ async function callEmbeddingAPI(inputs: string[], config: EmbeddingConfig): Prom } /** - * Generate embeddings for multiple texts with token-aware batching - * Uses tiktoken for token counting + * Process batches with controlled concurrency + */ +async function processWithConcurrency( + items: T[], + concurrency: number, + processor: (item: T, index: number) => Promise +): Promise { + const results: R[] = new Array(items.length) + let currentIndex = 0 + + const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => { + while (currentIndex < items.length) { + const index = currentIndex++ + results[index] = await processor(items[index], index) + } + }) + + await Promise.all(workers) + return results +} + +/** + * Generate embeddings for multiple texts with token-aware batching and parallel processing */ export async function generateEmbeddings( texts: string[], @@ -138,35 +160,35 @@ export async function generateEmbeddings( const batches = batchByTokenLimit(texts, MAX_TOKENS_PER_REQUEST, embeddingModel) logger.info( - `Split ${texts.length} texts into ${batches.length} batches (max ${MAX_TOKENS_PER_REQUEST} tokens per batch)` + `Split ${texts.length} texts into ${batches.length} batches (max ${MAX_TOKENS_PER_REQUEST} tokens per batch, ${MAX_CONCURRENT_BATCHES} concurrent)` ) - const allEmbeddings: number[][] = [] + const batchResults = await processWithConcurrency( + batches, + MAX_CONCURRENT_BATCHES, + async (batch, i) => { + const batchTokenCount = getTotalTokenCount(batch, embeddingModel) - for (let i = 0; i < batches.length; i++) { - const batch = batches[i] - const batchTokenCount = getTotalTokenCount(batch, embeddingModel) + logger.info( + `Processing batch ${i + 1}/${batches.length}: ${batch.length} texts, ${batchTokenCount} tokens` + ) - logger.info( - `Processing batch ${i + 1}/${batches.length}: ${batch.length} texts, ${batchTokenCount} tokens` - ) + try { + const batchEmbeddings = await callEmbeddingAPI(batch, config) - try { - const batchEmbeddings = await callEmbeddingAPI(batch, config) - allEmbeddings.push(...batchEmbeddings) + logger.info( + `Generated ${batchEmbeddings.length} embeddings for batch ${i + 1}/${batches.length}` + ) - logger.info( - `Generated ${batchEmbeddings.length} embeddings for batch ${i + 1}/${batches.length}` - ) - } catch (error) { - logger.error(`Failed to generate embeddings for batch ${i + 1}:`, error) - throw error + return batchEmbeddings + } catch (error) { + logger.error(`Failed to generate embeddings for batch ${i + 1}:`, error) + throw error + } } + ) - if (i + 1 < batches.length) { - await new Promise((resolve) => setTimeout(resolve, 100)) - } - } + const allEmbeddings = batchResults.flat() logger.info(`Successfully generated ${allEmbeddings.length} embeddings total`)