v0.5.50: import improvements, ui upgrades, kb styling and performance improvements

This commit is contained in:
Waleed
2026-01-05 00:35:55 -08:00
committed by GitHub
36 changed files with 1042 additions and 602 deletions

View File

@@ -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 {

View File

@@ -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)

View File

@@ -16,7 +16,7 @@ export function LinkWithPreview({ href, children }: { href: string; children: Re
{children}
</a>
</Tooltip.Trigger>
<Tooltip.Content side='top' align='center' sideOffset={5} className='max-w-sm p-3'>
<Tooltip.Content side='top' align='center' sideOffset={5} className='max-w-sm'>
<span className='truncate font-medium text-xs'>{href}</span>
</Tooltip.Content>
</Tooltip.Root>

View File

@@ -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 (
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
<PopoverAnchor
@@ -76,7 +102,7 @@ export function ChunkContextMenu({
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
{hasChunk ? (
<>
{onOpenInNewTab && (
{!isMultiSelect && onOpenInNewTab && (
<PopoverItem
onClick={() => {
onOpenInNewTab()
@@ -86,7 +112,7 @@ export function ChunkContextMenu({
Open in new tab
</PopoverItem>
)}
{onEdit && (
{!isMultiSelect && onEdit && (
<PopoverItem
onClick={() => {
onEdit()
@@ -96,7 +122,7 @@ export function ChunkContextMenu({
Edit
</PopoverItem>
)}
{onCopyContent && (
{!isMultiSelect && onCopyContent && (
<PopoverItem
onClick={() => {
onCopyContent()
@@ -114,7 +140,7 @@ export function ChunkContextMenu({
onClose()
}}
>
{isChunkEnabled ? 'Disable' : 'Enable'}
{getToggleLabel()}
</PopoverItem>
)}
{onDelete && (

View File

@@ -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({
</TableCell>
</TableRow>
) : (
displayChunks.map((chunk: ChunkData) => (
<TableRow
key={chunk.id}
className='cursor-pointer hover:bg-[var(--surface-2)]'
onClick={() => handleChunkClick(chunk)}
onContextMenu={(e) => handleChunkContextMenu(e, chunk)}
>
<TableCell
className='w-[52px] py-[8px]'
style={{ paddingLeft: '20.5px', paddingRight: 0 }}
displayChunks.map((chunk: ChunkData) => {
const isSelected = selectedChunks.has(chunk.id)
return (
<TableRow
key={chunk.id}
className={`${
isSelected
? 'bg-[var(--surface-3)] dark:bg-[var(--surface-4)]'
: 'hover:bg-[var(--surface-3)] dark:hover:bg-[var(--surface-4)]'
} cursor-pointer`}
onClick={() => handleChunkClick(chunk)}
onContextMenu={(e) => handleChunkContextMenu(e, chunk)}
>
<div className='flex items-center'>
<Checkbox
size='sm'
checked={selectedChunks.has(chunk.id)}
onCheckedChange={(checked) =>
handleSelectChunk(chunk.id, checked as boolean)
}
disabled={!userPermissions.canEdit}
aria-label={`Select chunk ${chunk.chunkIndex}`}
onClick={(e) => e.stopPropagation()}
/>
</div>
</TableCell>
<TableCell className='w-[60px] py-[8px] pr-[12px] pl-[15px] font-mono text-[14px] text-[var(--text-primary)]'>
{chunk.chunkIndex}
</TableCell>
<TableCell className='px-[12px] py-[8px]'>
<span
className='block min-w-0 truncate text-[14px] text-[var(--text-primary)]'
title={chunk.content}
<TableCell
className='w-[52px] py-[8px]'
style={{ paddingLeft: '20.5px', paddingRight: 0 }}
>
<SearchHighlight
text={truncateContent(chunk.content)}
searchQuery={searchQuery}
/>
</span>
</TableCell>
<TableCell className='w-[8%] px-[12px] py-[8px] text-[12px] text-[var(--text-muted)]'>
{chunk.tokenCount > 1000
? `${(chunk.tokenCount / 1000).toFixed(1)}k`
: chunk.tokenCount}
</TableCell>
<TableCell className='w-[12%] px-[12px] py-[8px]'>
<div className={getStatusBadgeStyles(chunk.enabled)}>
{chunk.enabled ? 'Enabled' : 'Disabled'}
</div>
</TableCell>
<TableCell className='w-[14%] py-[8px] pr-[4px] pl-[12px]'>
<div className='flex items-center gap-[4px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
handleToggleEnabled(chunk.id)
}}
disabled={!userPermissions.canEdit}
className='h-[28px] w-[28px] p-0 text-[var(--text-muted)] hover:text-[var(--text-primary)] disabled:opacity-50'
>
{chunk.enabled ? (
<Circle className='h-[14px] w-[14px]' />
) : (
<CircleOff className='h-[14px] w-[14px]' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{!userPermissions.canEdit
? 'Write permission required to modify chunks'
: chunk.enabled
? 'Disable Chunk'
: 'Enable Chunk'}
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
handleDeleteChunk(chunk.id)
}}
disabled={!userPermissions.canEdit}
className='h-[28px] w-[28px] p-0 text-[var(--text-muted)] hover:text-[var(--text-error)] disabled:opacity-50'
>
<Trash className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{!userPermissions.canEdit
? 'Write permission required to delete chunks'
: 'Delete Chunk'}
</Tooltip.Content>
</Tooltip.Root>
</div>
</TableCell>
</TableRow>
))
<div className='flex items-center'>
<Checkbox
size='sm'
checked={selectedChunks.has(chunk.id)}
onCheckedChange={(checked) =>
handleSelectChunk(chunk.id, checked as boolean)
}
disabled={!userPermissions.canEdit}
aria-label={`Select chunk ${chunk.chunkIndex}`}
onClick={(e) => e.stopPropagation()}
/>
</div>
</TableCell>
<TableCell className='w-[60px] py-[8px] pr-[12px] pl-[15px] font-mono text-[14px] text-[var(--text-primary)]'>
{chunk.chunkIndex}
</TableCell>
<TableCell className='px-[12px] py-[8px]'>
<span
className='block min-w-0 truncate text-[14px] text-[var(--text-primary)]'
title={chunk.content}
>
<SearchHighlight
text={truncateContent(chunk.content, 150, searchQuery)}
searchQuery={searchQuery}
/>
</span>
</TableCell>
<TableCell className='w-[8%] px-[12px] py-[8px] text-[12px] text-[var(--text-muted)]'>
{chunk.tokenCount > 1000
? `${(chunk.tokenCount / 1000).toFixed(1)}k`
: chunk.tokenCount.toLocaleString()}
</TableCell>
<TableCell className='w-[12%] px-[12px] py-[8px]'>
<Badge variant={chunk.enabled ? 'green' : 'gray'} size='sm'>
{chunk.enabled ? 'Enabled' : 'Disabled'}
</Badge>
</TableCell>
<TableCell className='w-[14%] py-[8px] pr-[4px] pl-[12px]'>
<div className='flex items-center gap-[4px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
handleToggleEnabled(chunk.id)
}}
disabled={!userPermissions.canEdit}
className='h-[28px] w-[28px] p-0 text-[var(--text-muted)] hover:text-[var(--text-primary)] disabled:opacity-50'
>
{chunk.enabled ? (
<Circle className='h-[14px] w-[14px]' />
) : (
<CircleOff className='h-[14px] w-[14px]' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{!userPermissions.canEdit
? 'Write permission required to modify chunks'
: chunk.enabled
? 'Disable Chunk'
: 'Enable Chunk'}
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
handleDeleteChunk(chunk.id)
}}
disabled={!userPermissions.canEdit}
className='h-[28px] w-[28px] p-0 text-[var(--text-muted)] hover:text-[var(--text-error)] disabled:opacity-50'
>
<Trash className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{!userPermissions.canEdit
? 'Write permission required to delete chunks'
: 'Delete Chunk'}
</Tooltip.Content>
</Tooltip.Root>
</div>
</TableCell>
</TableRow>
)
})
)}
</TableBody>
</Table>
@@ -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={

View File

@@ -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<DocumentSortField>('uploadedAt')
const [sortOrder, setSortOrder] = useState<SortOrder>('desc')
const [contextMenuDocument, setContextMenuDocument] = useState<DocumentData | null>(null)
const [showRenameModal, setShowRenameModal] = useState(false)
const [documentToRename, setDocumentToRename] = useState<DocumentData | null>(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<DocumentData>(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<DocumentData>(
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({
<TableRow
key={doc.id}
className={`${
isSelected ? 'bg-[var(--surface-2)]' : 'hover:bg-[var(--surface-2)]'
isSelected
? 'bg-[var(--surface-3)] dark:bg-[var(--surface-4)]'
: 'hover:bg-[var(--surface-3)] dark:hover:bg-[var(--surface-4)]'
} ${doc.processingStatus === 'completed' ? 'cursor-pointer' : 'cursor-default'}`}
onClick={() => {
if (doc.processingStatus === 'completed') {
@@ -1558,6 +1628,17 @@ export function KnowledgeBase({
chunkingConfig={knowledgeBase?.chunkingConfig}
/>
{/* Rename Document Modal */}
{documentToRename && (
<RenameDocumentModal
open={showRenameModal}
onOpenChange={setShowRenameModal}
documentId={documentToRename.id}
initialName={documentToRename.filename}
onSave={handleSaveRename}
/>
)}
<ActionBar
selectedCount={selectedDocuments.size}
onEnable={disabledCount > 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}

View File

@@ -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 (
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
<PopoverAnchor
@@ -79,7 +107,7 @@ export function DocumentContextMenu({
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
{hasDocument ? (
<>
{onOpenInNewTab && (
{!isMultiSelect && onOpenInNewTab && (
<PopoverItem
onClick={() => {
onOpenInNewTab()
@@ -89,7 +117,17 @@ export function DocumentContextMenu({
Open in new tab
</PopoverItem>
)}
{hasTags && onViewTags && (
{!isMultiSelect && onRename && (
<PopoverItem
onClick={() => {
onRename()
onClose()
}}
>
Rename
</PopoverItem>
)}
{!isMultiSelect && hasTags && onViewTags && (
<PopoverItem
onClick={() => {
onViewTags()
@@ -107,7 +145,7 @@ export function DocumentContextMenu({
onClose()
}}
>
{isDocumentEnabled ? 'Disable' : 'Enable'}
{getToggleLabel()}
</PopoverItem>
)}
{onDelete && (

View File

@@ -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'

View File

@@ -0,0 +1 @@
export { RenameDocumentModal } from './rename-document-modal'

View File

@@ -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<void>
}
/**
* 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<string | null>(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 (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent>
<ModalHeader>Rename Document</ModalHeader>
<form onSubmit={handleSubmit} className='flex min-h-0 flex-1 flex-col'>
<ModalBody className='!pb-[16px]'>
<div className='space-y-[12px]'>
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='document-name'>Name</Label>
<Input
id='document-name'
value={name}
onChange={(e) => {
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'
/>
</div>
</div>
</ModalBody>
<ModalFooter>
<div className='flex w-full items-center justify-between gap-[12px]'>
{error ? (
<p className='min-w-0 flex-1 truncate text-[11px] text-[var(--text-error)] leading-tight'>
{error}
</p>
) : (
<div />
)}
<div className='flex flex-shrink-0 gap-[8px]'>
<Button
variant='default'
onClick={() => onOpenChange(false)}
type='button'
disabled={isSubmitting}
>
Cancel
</Button>
<Button variant='tertiary' type='submit' disabled={isSubmitting || !name?.trim()}>
{isSubmitting ? 'Renaming...' : 'Rename'}
</Button>
</div>
</div>
</ModalFooter>
</form>
</ModalContent>
</Modal>
)
}

View File

@@ -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({
</ModalTabs>
<ModalFooter>
{showForm ? (
{displayForm ? (
<>
{hasSubscriptions && (
<Button

View File

@@ -7,8 +7,8 @@ import {
Badge,
Popover,
PopoverContent,
PopoverDivider,
PopoverItem,
PopoverSection,
PopoverTrigger,
} from '@/components/emcn'
import {
@@ -468,7 +468,7 @@ export function OutputSelect({
disablePortal={disablePopoverPortal}
>
<div className='space-y-[2px]'>
{Object.entries(groupedOutputs).map(([blockName, outputs]) => {
{Object.entries(groupedOutputs).map(([blockName, outputs], groupIndex, groupArray) => {
const startIndex = flattenedOutputs.findIndex((o) => o.blockName === blockName)
const firstOutput = outputs[0]
@@ -489,12 +489,10 @@ export function OutputSelect({
return (
<div key={blockName}>
<PopoverSection>
<div className='flex items-center gap-1.5'>
<TagIcon icon={blockIcon} color={blockColor} />
<span>{blockName}</span>
</div>
</PopoverSection>
<div className='flex items-center gap-1.5 px-[6px] py-[4px]'>
<TagIcon icon={blockIcon} color={blockColor} />
<span className='font-medium text-[13px]'>{blockName}</span>
</div>
<div className='flex flex-col gap-[2px]'>
{outputs.map((output, localIndex) => {
@@ -509,14 +507,13 @@ export function OutputSelect({
onClick={() => handleOutputSelection(output.label)}
onMouseEnter={() => setHighlightedIndex(globalIndex)}
>
<span className='min-w-0 flex-1 truncate text-[var(--text-primary)]'>
{output.path}
</span>
<span className='min-w-0 flex-1 truncate'>{output.path}</span>
{isSelectedValue(output) && <Check className='h-3 w-3 flex-shrink-0' />}
</PopoverItem>
)
})}
</div>
{groupIndex < groupArray.length - 1 && <PopoverDivider />}
</div>
)
})}

View File

@@ -1,6 +1,12 @@
'use client'
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDivider,
PopoverItem,
} from '@/components/emcn'
import type { BlockContextMenuProps } from './types'
/**
@@ -48,7 +54,13 @@ export function BlockContextMenu({
}
return (
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
<Popover
open={isOpen}
onOpenChange={onClose}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<PopoverAnchor
style={{
position: 'fixed',
@@ -59,7 +71,7 @@ export function BlockContextMenu({
}}
/>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
{/* Copy */}
{/* Clipboard actions */}
<PopoverItem
className='group'
onClick={() => {
@@ -70,8 +82,6 @@ export function BlockContextMenu({
<span>Copy</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>C</span>
</PopoverItem>
{/* Paste */}
<PopoverItem
className='group'
disabled={disableEdit || !hasClipboard}
@@ -83,8 +93,6 @@ export function BlockContextMenu({
<span>Paste</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>V</span>
</PopoverItem>
{/* Duplicate - hide for starter blocks */}
{!hasStarterBlock && (
<PopoverItem
disabled={disableEdit}
@@ -97,20 +105,8 @@ export function BlockContextMenu({
</PopoverItem>
)}
{/* Delete */}
<PopoverItem
className='group'
disabled={disableEdit}
onClick={() => {
onDelete()
onClose()
}}
>
<span>Delete</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'></span>
</PopoverItem>
{/* Enable/Disable - hide if all blocks are notes */}
{/* Toggle and edit actions */}
{!allNoteBlocks && <PopoverDivider />}
{!allNoteBlocks && (
<PopoverItem
disabled={disableEdit}
@@ -122,8 +118,6 @@ export function BlockContextMenu({
{getToggleEnabledLabel()}
</PopoverItem>
)}
{/* Flip Handles - hide if all blocks are notes */}
{!allNoteBlocks && (
<PopoverItem
disabled={disableEdit}
@@ -135,8 +129,6 @@ export function BlockContextMenu({
Flip Handles
</PopoverItem>
)}
{/* Remove from Subflow - only show when applicable */}
{canRemoveFromSubflow && (
<PopoverItem
disabled={disableEdit}
@@ -149,7 +141,8 @@ export function BlockContextMenu({
</PopoverItem>
)}
{/* Rename - only for single block, not subflows */}
{/* Single block actions */}
{isSingleBlock && <PopoverDivider />}
{isSingleBlock && !isSubflow && (
<PopoverItem
disabled={disableEdit}
@@ -161,8 +154,6 @@ export function BlockContextMenu({
Rename
</PopoverItem>
)}
{/* Open Editor - only for single block */}
{isSingleBlock && (
<PopoverItem
onClick={() => {
@@ -173,6 +164,20 @@ export function BlockContextMenu({
Open Editor
</PopoverItem>
)}
{/* Destructive action */}
<PopoverDivider />
<PopoverItem
className='group'
disabled={disableEdit}
onClick={() => {
onDelete()
onClose()
}}
>
<span>Delete</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'></span>
</PopoverItem>
</PopoverContent>
</Popover>
)

View File

@@ -1,6 +1,12 @@
'use client'
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDivider,
PopoverItem,
} from '@/components/emcn'
import type { PaneContextMenuProps } from './types'
/**
@@ -28,7 +34,13 @@ export function PaneContextMenu({
canRedo = false,
}: PaneContextMenuProps) {
return (
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
<Popover
open={isOpen}
onOpenChange={onClose}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<PopoverAnchor
style={{
position: 'fixed',
@@ -39,7 +51,7 @@ export function PaneContextMenu({
}}
/>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
{/* Undo */}
{/* History actions */}
<PopoverItem
className='group'
disabled={disableEdit || !canUndo}
@@ -51,8 +63,6 @@ export function PaneContextMenu({
<span>Undo</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>Z</span>
</PopoverItem>
{/* Redo */}
<PopoverItem
className='group'
disabled={disableEdit || !canRedo}
@@ -65,7 +75,8 @@ export function PaneContextMenu({
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>Z</span>
</PopoverItem>
{/* Paste */}
{/* Edit and creation actions */}
<PopoverDivider />
<PopoverItem
className='group'
disabled={disableEdit || !hasClipboard}
@@ -77,8 +88,6 @@ export function PaneContextMenu({
<span>Paste</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>V</span>
</PopoverItem>
{/* Add Block */}
<PopoverItem
className='group'
disabled={disableEdit}
@@ -90,8 +99,6 @@ export function PaneContextMenu({
<span>Add Block</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>K</span>
</PopoverItem>
{/* Auto-layout */}
<PopoverItem
className='group'
disabled={disableEdit}
@@ -104,7 +111,8 @@ export function PaneContextMenu({
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>L</span>
</PopoverItem>
{/* Open Logs */}
{/* Navigation actions */}
<PopoverDivider />
<PopoverItem
className='group'
onClick={() => {
@@ -115,8 +123,6 @@ export function PaneContextMenu({
<span>Open Logs</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>L</span>
</PopoverItem>
{/* Open Variables */}
<PopoverItem
onClick={() => {
onOpenVariables()
@@ -125,8 +131,6 @@ export function PaneContextMenu({
>
Variables
</PopoverItem>
{/* Open Chat */}
<PopoverItem
onClick={() => {
onOpenChat()
@@ -136,7 +140,8 @@ export function PaneContextMenu({
Open Chat
</PopoverItem>
{/* Invite to Workspace - admin only */}
{/* Admin action */}
<PopoverDivider />
<PopoverItem
disabled={disableAdmin}
onClick={() => {

View File

@@ -89,7 +89,7 @@ function LinkWithPreview({ href, children }: { href: string; children: React.Rea
{children}
</a>
</Tooltip.Trigger>
<Tooltip.Content side='top' align='center' sideOffset={5} className='max-w-sm p-3'>
<Tooltip.Content side='top' align='center' sideOffset={5} className='max-w-sm'>
<span className='text-sm'>{href}</span>
</Tooltip.Content>
</Tooltip.Root>

View File

@@ -8,6 +8,7 @@ import {
PopoverAnchor,
PopoverBackButton,
PopoverContent,
PopoverDivider,
PopoverFolder,
PopoverItem,
PopoverScrollArea,
@@ -1426,7 +1427,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
}
return (
<Popover open={visible} onOpenChange={(open) => !open && onClose?.()}>
<Popover open={visible} onOpenChange={(open) => !open && onClose?.()} colorScheme='inverted'>
<PopoverAnchor asChild>
<div
className={cn('pointer-events-none', className)}
@@ -1502,23 +1503,24 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
}
}}
>
<span className='flex-1 truncate text-[var(--text-primary)]'>
<span className='flex-1 truncate'>
{tag.startsWith(TAG_PREFIXES.VARIABLE)
? tag.substring(TAG_PREFIXES.VARIABLE.length)
: tag}
</span>
{variableInfo && (
<span className='ml-auto text-[10px] text-[var(--text-secondary)]'>
<span className='ml-auto text-[10px] text-[var(--text-muted-inverse)]'>
{variableInfo.type}
</span>
)}
</PopoverItem>
)
})}
{nestedBlockTagGroups.length > 0 && <PopoverDivider rootOnly />}
</>
)}
{nestedBlockTagGroups.map((group: NestedBlockTagGroup) => {
{nestedBlockTagGroups.map((group: NestedBlockTagGroup, groupIndex: number) => {
const blockConfig = getBlock(group.blockType)
let blockColor = blockConfig?.bgColor || BLOCK_COLORS.DEFAULT
@@ -1565,9 +1567,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
}}
>
<TagIcon icon={tagIcon} color={blockColor} />
<span className='flex-1 truncate font-medium text-[var(--text-primary)]'>
{group.blockName}
</span>
<span className='flex-1 truncate font-medium'>{group.blockName}</span>
</PopoverItem>
{group.nestedTags.map((nestedTag) => {
if (nestedTag.fullTag === rootTag) {
@@ -1650,11 +1650,9 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
}
}}
>
<span className='flex-1 truncate text-[var(--text-primary)]'>
{child.display}
</span>
<span className='flex-1 truncate'>{child.display}</span>
{childType && childType !== 'any' && (
<span className='ml-auto text-[10px] text-[var(--text-secondary)]'>
<span className='ml-auto text-[10px] text-[var(--text-muted-inverse)]'>
{childType}
</span>
)}
@@ -1722,17 +1720,16 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
}
}}
>
<span className='flex-1 truncate text-[var(--text-primary)]'>
{nestedTag.display}
</span>
<span className='flex-1 truncate'>{nestedTag.display}</span>
{tagDescription && tagDescription !== 'any' && (
<span className='ml-auto text-[10px] text-[var(--text-secondary)]'>
<span className='ml-auto text-[10px] text-[var(--text-muted-inverse)]'>
{tagDescription}
</span>
)}
</PopoverItem>
)
})}
{groupIndex < nestedBlockTagGroups.length - 1 && <PopoverDivider rootOnly />}
</div>
)
})}

View File

@@ -38,6 +38,27 @@ const DEFAULT_ASSIGNMENT: Omit<VariableAssignment, 'id'> = {
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) => {

View File

@@ -1025,7 +1025,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
Webhook
</Badge>
</Tooltip.Trigger>
<Tooltip.Content side='top' className='max-w-[300px] p-4'>
<Tooltip.Content side='top' className='max-w-[300px]'>
{webhookProvider && webhookPath ? (
<>
<p className='text-sm'>{getProviderName(webhookProvider)} Webhook</p>

View File

@@ -165,7 +165,7 @@ const reactFlowStyles = [
'[&_.react-flow__renderer]:!bg-transparent',
'[&_.react-flow__background]:hidden',
].join(' ')
const reactFlowFitViewOptions = { padding: 0.6 } as const
const reactFlowFitViewOptions = { padding: 0.6, maxZoom: 1.0 } as const
const reactFlowProOptions = { hideAttribution: true } as const
interface SelectedEdgeInfo {
@@ -478,7 +478,7 @@ const WorkflowContent = React.memo(() => {
/** Connection line style - red for error handles, default otherwise. */
const connectionLineStyle = useMemo(
() => ({
stroke: isErrorConnectionDrag ? 'var(--text-error)' : 'var(--surface-7)',
stroke: isErrorConnectionDrag ? 'var(--text-error)' : 'var(--workflow-edge)',
strokeWidth: 2,
}),
[isErrorConnectionDrag]

View File

@@ -80,6 +80,12 @@ function GeneralSkeleton() {
<Skeleton className='h-[17px] w-[30px] rounded-full' />
</div>
{/* Snap to grid row */}
<div className='flex items-center justify-between'>
<Skeleton className='h-4 w-20' />
<Skeleton className='h-8 w-[100px] rounded-[4px]' />
</div>
{/* Telemetry row */}
<div className='flex items-center justify-between border-t pt-[16px]'>
<Skeleton className='h-4 w-44' />
@@ -87,13 +93,16 @@ function GeneralSkeleton() {
</div>
{/* Telemetry description */}
<Skeleton className='h-[12px] w-full' />
<Skeleton className='-mt-2 h-[12px] w-4/5' />
<div className='-mt-[8px] flex flex-col gap-1'>
<Skeleton className='h-[12px] w-full' />
<Skeleton className='h-[12px] w-4/5' />
</div>
{/* Action buttons */}
<div className='mt-auto flex items-center gap-[8px]'>
<Skeleton className='h-8 w-20 rounded-[4px]' />
<Skeleton className='h-8 w-28 rounded-[4px]' />
<Skeleton className='ml-auto h-8 w-24 rounded-[4px]' />
</div>
</div>
)

View File

@@ -1,6 +1,12 @@
'use client'
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDivider,
PopoverItem,
} from '@/components/emcn'
interface ContextMenuProps {
/**
@@ -142,7 +148,13 @@ export function ContextMenu({
disableCreateFolder = false,
}: ContextMenuProps) {
return (
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
<Popover
open={isOpen}
onOpenChange={onClose}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<PopoverAnchor
style={{
position: 'fixed',
@@ -153,6 +165,7 @@ export function ContextMenu({
}}
/>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
{/* Navigation actions */}
{showOpenInNewTab && onOpenInNewTab && (
<PopoverItem
onClick={() => {
@@ -163,6 +176,9 @@ export function ContextMenu({
Open in new tab
</PopoverItem>
)}
{showOpenInNewTab && onOpenInNewTab && <PopoverDivider />}
{/* Edit and create actions */}
{showRename && onRename && (
<PopoverItem
disabled={disableRename}
@@ -196,6 +212,9 @@ export function ContextMenu({
Create folder
</PopoverItem>
)}
{/* Copy and export actions */}
{(showDuplicate || showExport) && <PopoverDivider />}
{showDuplicate && onDuplicate && (
<PopoverItem
disabled={disableDuplicate}
@@ -218,6 +237,9 @@ export function ContextMenu({
Export
</PopoverItem>
)}
{/* Destructive action */}
<PopoverDivider />
<PopoverItem
disabled={disableDelete}
onClick={() => {

View File

@@ -180,10 +180,7 @@ export const PermissionsTable = ({
{resendingInvitationIds &&
user.invitationId &&
resendingInvitationIds[user.invitationId] ? (
<>
<Loader2 className='h-[12px] w-[12px] animate-spin' />
<span>Sending...</span>
</>
<span>Sending...</span>
) : resentInvitationIds &&
user.invitationId &&
resentInvitationIds[user.invitationId] ? (

View File

@@ -341,7 +341,7 @@ export function WorkspaceHeader({
<ArrowDown className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content className='py-[2.5px]'>
<Tooltip.Content>
<p>
{isImportingWorkspace ? 'Importing workspace...' : 'Import workspace'}
</p>
@@ -364,7 +364,7 @@ export function WorkspaceHeader({
<Plus className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content className='py-[2.5px]'>
<Tooltip.Content>
<p>
{isCreatingWorkspace ? 'Creating workspace...' : 'Create workspace'}
</p>

View File

@@ -529,7 +529,7 @@ export function Sidebar() {
<ArrowDown className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content className='py-[2.5px]'>
<Tooltip.Content>
<p>{isImporting ? 'Importing workflow...' : 'Import workflow'}</p>
</Tooltip.Content>
</Tooltip.Root>
@@ -544,7 +544,7 @@ export function Sidebar() {
<FolderPlus className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content className='py-[2.5px]'>
<Tooltip.Content>
<p>{isCreatingFolder ? 'Creating folder...' : 'Create folder'}</p>
</Tooltip.Content>
</Tooltip.Root>
@@ -559,7 +559,7 @@ export function Sidebar() {
<Plus className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content className='py-[2.5px]'>
<Tooltip.Content>
<p>{isCreatingWorkflow ? 'Creating workflow...' : 'Create workflow'}</p>
</Tooltip.Content>
</Tooltip.Root>

View File

@@ -57,6 +57,8 @@ export {
type PopoverBackButtonProps,
PopoverContent,
type PopoverContentProps,
PopoverDivider,
type PopoverDividerProps,
PopoverFolder,
type PopoverFolderProps,
PopoverItem,

View File

@@ -55,53 +55,102 @@ import { Check, ChevronLeft, ChevronRight, Search } from 'lucide-react'
import { cn } from '@/lib/core/utils/cn'
type PopoverSize = 'sm' | 'md'
/**
* 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<PopoverSize, string> = {
sm: 'h-[22px] text-[11px]',
md: 'h-[26px] text-[13px]',
}
/**
* Size-specific icon classes for popover items.
*/
const POPOVER_ICON_SIZE_CLASSES: Record<PopoverSize, string> = {
sm: 'h-3 w-3',
md: 'h-3.5 w-3.5',
}
/**
* Variant-specific active state styles for popover items.
*/
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)]',
}
/**
* 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)]',
}
type PopoverColorScheme = 'default' | 'inverted'
type PopoverVariant = 'default' | 'secondary'
/**
* Style constants for popover components.
* Organized by component type and property.
*/
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<PopoverSize, { item: string; icon: string; section: string }>,
/** 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
/**
* Gets the active/hover classes for a popover item.
* Uses variant for secondary, otherwise colorScheme determines default vs inverted.
*/
function getItemStateClasses(
variant: PopoverVariant,
colorScheme: PopoverColorScheme,
isActive: boolean
): string {
const state = isActive ? 'active' : 'hover'
if (variant === 'secondary') {
return STYLES.states.secondary[state]
}
return colorScheme === 'inverted' ? STYLES.states.inverted[state] : STYLES.states.default[state]
}
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
* <Popover open={open} onOpenChange={setOpen} variant="default" size="md">
* <PopoverAnchor>...</PopoverAnchor>
* <PopoverContent>...</PopoverContent>
* </Popover>
* ```
*/
const Popover: React.FC<PopoverProps> = ({
children,
variant = 'default',
size = 'md',
colorScheme = 'default',
...props
}) => {
const [currentFolder, setCurrentFolder] = React.useState<string | null>(null)
@@ -185,7 +235,7 @@ const Popover: React.FC<PopoverProps> = ({
setOnFolderSelect(null)
}, [])
const contextValue: PopoverContextValue = React.useMemo(
const contextValue = React.useMemo<PopoverContextValue>(
() => ({
openFolder,
closeFolder,
@@ -195,6 +245,7 @@ const Popover: React.FC<PopoverProps> = ({
onFolderSelect,
variant,
size,
colorScheme,
searchQuery,
setSearchQuery,
}),
@@ -206,6 +257,7 @@ const Popover: React.FC<PopoverProps> = ({
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
* <PopoverTrigger asChild>
* <Button>Open Menu</Button>
* </PopoverTrigger>
* ```
*/
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
* <PopoverContent maxHeight={300}>
* <PopoverItem>Item 1</PopoverItem>
* <PopoverItem>Item 2</PopoverItem>
* </PopoverContent>
* ```
* Popover content with automatic positioning and collision detection.
*/
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
@@ -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<
</PopoverPrimitive.Content>
)
if (disablePortal) {
return content
}
if (disablePortal) return content
return <PopoverPrimitive.Portal>{content}</PopoverPrimitive.Portal>
}
@@ -453,83 +455,52 @@ PopoverContent.displayName = 'PopoverContent'
export interface PopoverScrollAreaProps extends React.HTMLAttributes<HTMLDivElement> {}
/**
* Scrollable area container for popover items.
* Use this to wrap items that should scroll within the popover.
*
* @example
* ```tsx
* <PopoverContent>
* <PopoverScrollArea>
* <PopoverItem>Item 1</PopoverItem>
* <PopoverItem>Item 2</PopoverItem>
* </PopoverScrollArea>
* </PopoverContent>
* ```
* Scrollable container for popover items.
*/
const PopoverScrollArea = React.forwardRef<HTMLDivElement, PopoverScrollAreaProps>(
({ className, ...props }, ref) => {
return (
<div
className={cn(
'min-h-0 overflow-auto overscroll-contain',
// Add margin to wrapper divs containing sections (not individual items)
'[&>div:has([data-popover-section]):not(:first-child)]:mt-[6px]',
className
)}
ref={ref}
{...props}
/>
)
}
({ className, ...props }, ref) => (
<div
className={cn(
'min-h-0 overflow-auto overscroll-contain',
'[&>div:has([data-popover-section]):not(:first-child)]:mt-[6px]',
className
)}
ref={ref}
{...props}
/>
)
)
PopoverScrollArea.displayName = 'PopoverScrollArea'
export interface PopoverItemProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* 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
* <PopoverItem active={isActive} disabled={isDisabled} onClick={() => handleClick()}>
* <Icon className="h-3.5 w-3.5" />
* <span>Item label</span>
* </PopoverItem>
* ```
* Individual popover item with hover and active states.
*/
const PopoverItem = React.forwardRef<HTMLDivElement, PopoverItemProps>(
(
{ 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<HTMLDivElement>) => {
if (disabled) {
@@ -542,9 +513,10 @@ const PopoverItem = React.forwardRef<HTMLDivElement, PopoverItemProps>(
return (
<div
className={cn(
POPOVER_ITEM_BASE_CLASSES,
POPOVER_ITEM_SIZE_CLASSES[size],
active ? POPOVER_ITEM_ACTIVE_CLASSES[variant] : POPOVER_ITEM_HOVER_CLASSES[variant],
STYLES.itemBase,
STYLES.colorScheme[colorScheme].text,
STYLES.size[size].item,
getItemStateClasses(variant, colorScheme, !!active),
disabled && 'pointer-events-none cursor-not-allowed opacity-50',
className
)}
@@ -556,9 +528,7 @@ const PopoverItem = React.forwardRef<HTMLDivElement, PopoverItemProps>(
{...props}
>
{children}
{showCheck && active && (
<Check className={cn('ml-auto', POPOVER_ICON_SIZE_CLASSES[size])} />
)}
{showCheck && active && <Check className={cn('ml-auto', STYLES.size[size].icon)} />}
</div>
)
}
@@ -567,46 +537,27 @@ const PopoverItem = React.forwardRef<HTMLDivElement, PopoverItemProps>(
PopoverItem.displayName = 'PopoverItem'
export interface PopoverSectionProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* 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<PopoverSize, string> = {
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
* <PopoverSection>
* Section Title
* </PopoverSection>
* ```
* Section header for grouping popover items.
*/
const PopoverSection = React.forwardRef<HTMLDivElement, PopoverSectionProps>(
({ 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 (
<div
className={cn(
'mt-[6px] min-w-0 font-base text-[var(--text-tertiary)] first:mt-0 first:pt-0 dark:text-[var(--text-tertiary)]',
POPOVER_SECTION_SIZE_CLASSES[size],
'mt-[6px] min-w-0 font-base first:mt-0 first:pt-0',
STYLES.colorScheme[colorScheme].section,
STYLES.size[size].section,
className
)}
data-popover-section=''
@@ -620,76 +571,46 @@ const PopoverSection = React.forwardRef<HTMLDivElement, PopoverSectionProps>(
PopoverSection.displayName = 'PopoverSection'
export interface PopoverFolderProps extends Omit<React.HTMLAttributes<HTMLDivElement>, '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<void>
/**
* 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
* <PopoverFolder id="workflows" title="Workflows" icon={<Icon />}>
* <PopoverItem>Workflow 1</PopoverItem>
* <PopoverItem>Workflow 2</PopoverItem>
* </PopoverFolder>
* ```
* Expandable folder that shows nested content.
*/
const PopoverFolder = React.forwardRef<HTMLDivElement, PopoverFolderProps>(
({ 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 (
<div
ref={ref}
className={cn(
POPOVER_ITEM_BASE_CLASSES,
POPOVER_ITEM_SIZE_CLASSES[size],
active ? POPOVER_ITEM_ACTIVE_CLASSES[variant] : POPOVER_ITEM_HOVER_CLASSES[variant],
STYLES.itemBase,
STYLES.colorScheme[colorScheme].text,
STYLES.size[size].item,
getItemStateClasses(variant, colorScheme, !!active),
className
)}
role='menuitem'
@@ -700,7 +621,7 @@ const PopoverFolder = React.forwardRef<HTMLDivElement, PopoverFolderProps>(
>
{icon}
<span className='flex-1'>{title}</span>
<ChevronRight className={POPOVER_ICON_SIZE_CLASSES[size]} />
<ChevronRight className={STYLES.size[size].icon} />
</div>
)
}
@@ -709,42 +630,23 @@ const PopoverFolder = React.forwardRef<HTMLDivElement, PopoverFolderProps>(
PopoverFolder.displayName = 'PopoverFolder'
export interface PopoverBackButtonProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* 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
* <Popover>
* <PopoverBackButton />
* <PopoverContent>
* // content
* </PopoverContent>
* </Popover>
* ```
* Back button shown inside folders. Hidden at root level.
*/
const PopoverBackButton = React.forwardRef<HTMLDivElement, PopoverBackButtonProps>(
({ 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 (
<div className='flex flex-col'>
@@ -752,28 +654,27 @@ const PopoverBackButton = React.forwardRef<HTMLDivElement, PopoverBackButtonProp
ref={ref}
className={cn(
'peer',
POPOVER_ITEM_BASE_CLASSES,
POPOVER_ITEM_SIZE_CLASSES[size],
POPOVER_ITEM_HOVER_CLASSES[variant],
STYLES.itemBase,
STYLES.colorScheme[colorScheme].text,
STYLES.size[size].item,
getItemStateClasses(variant, colorScheme, false),
className
)}
role='button'
onClick={closeFolder}
{...props}
>
<ChevronLeft className={POPOVER_ICON_SIZE_CLASSES[size]} />
<ChevronLeft className={STYLES.size[size].icon} />
<span>Back</span>
</div>
{folderTitle && onFolderSelect && (
<div
ref={folderTitleRef}
className={cn(
POPOVER_ITEM_BASE_CLASSES,
POPOVER_ITEM_SIZE_CLASSES[size],
folderTitleActive
? POPOVER_ITEM_ACTIVE_CLASSES[variant]
: POPOVER_ITEM_HOVER_CLASSES[variant],
// Hide active/hover background when back button is hovered
STYLES.itemBase,
STYLES.colorScheme[colorScheme].text,
STYLES.size[size].item,
getItemStateClasses(variant, colorScheme, !!folderTitleActive),
'peer-hover:!bg-transparent'
)}
role='button'
@@ -789,8 +690,9 @@ const PopoverBackButton = React.forwardRef<HTMLDivElement, PopoverBackButtonProp
{folderTitle && !onFolderSelect && (
<div
className={cn(
'font-base text-[var(--text-tertiary)] dark:text-[var(--text-tertiary)]',
POPOVER_SECTION_SIZE_CLASSES[size]
'font-base',
STYLES.colorScheme[colorScheme].section,
STYLES.size[size].section
)}
>
{folderTitle}
@@ -805,43 +707,20 @@ PopoverBackButton.displayName = 'PopoverBackButton'
export interface PopoverSearchProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* 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<PopoverSize, string> = {
sm: 'px-[8px] py-[6px] text-[11px]',
md: 'px-[8px] py-[6px] text-[13px]',
}
/**
* Search input component for filtering popover items.
*
* @example
* ```tsx
* <Popover>
* <PopoverContent>
* <PopoverSearch placeholder="Search tools..." />
* <PopoverScrollArea>
* // items
* </PopoverScrollArea>
* </PopoverContent>
* </Popover>
* ```
* Search input for filtering popover items.
*/
const PopoverSearch = React.forwardRef<HTMLDivElement, PopoverSearchProps>(
({ className, placeholder = 'Search...', onValueChange, ...props }, ref) => {
const { searchQuery, setSearchQuery, size } = usePopoverContext()
const { searchQuery, setSearchQuery, size, colorScheme } = usePopoverContext()
const inputRef = React.useRef<HTMLInputElement>(null)
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -857,18 +736,19 @@ const PopoverSearch = React.forwardRef<HTMLDivElement, PopoverSearchProps>(
}, [setSearchQuery, onValueChange])
return (
<div
ref={ref}
className={cn('flex items-center', POPOVER_SEARCH_SIZE_CLASSES[size], className)}
{...props}
>
<div ref={ref} className={cn('flex items-center px-[8px] py-[6px]', className)} {...props}>
<Search
className={cn('mr-2 shrink-0 text-[var(--text-muted)]', POPOVER_ICON_SIZE_CLASSES[size])}
className={cn(
'mr-2 shrink-0',
STYLES.colorScheme[colorScheme].search,
STYLES.size[size].icon
)}
/>
<input
ref={inputRef}
className={cn(
'w-full bg-transparent font-base text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:outline-none',
'w-full bg-transparent font-base focus:outline-none',
STYLES.colorScheme[colorScheme].searchInput,
size === 'sm' ? 'text-[11px]' : 'text-[13px]'
)}
placeholder={placeholder}
@@ -882,6 +762,34 @@ const PopoverSearch = React.forwardRef<HTMLDivElement, PopoverSearchProps>(
PopoverSearch.displayName = 'PopoverSearch'
export interface PopoverDividerProps extends React.HTMLAttributes<HTMLDivElement> {
/** Only show when not inside any folder */
rootOnly?: boolean
}
/**
* Horizontal divider for separating popover sections.
*/
const PopoverDivider = React.forwardRef<HTMLDivElement, PopoverDividerProps>(
({ className, rootOnly, ...props }, ref) => {
const context = React.useContext(PopoverContext)
const colorScheme = context?.colorScheme || 'default'
if (rootOnly && context?.isInFolder) return null
return (
<div
ref={ref}
className={cn('my-[6px] border-t', STYLES.colorScheme[colorScheme].divider, className)}
role='separator'
{...props}
/>
)
}
)
PopoverDivider.displayName = 'PopoverDivider'
export {
Popover,
PopoverTrigger,
@@ -893,7 +801,8 @@ export {
PopoverFolder,
PopoverBackButton,
PopoverSearch,
PopoverDivider,
usePopoverContext,
}
export type { PopoverSize }
export type { PopoverSize, PopoverColorScheme }

View File

@@ -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}
<TooltipPrimitive.Arrow className='fill-black dark:fill-white' />
<TooltipPrimitive.Arrow className='fill-[#1b1b1b] dark:fill-[#fdfdfd]' />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
))

View File

@@ -11,7 +11,6 @@ export function SearchHighlight({ text, searchQuery, className = '' }: SearchHig
return <span className={className}>{text}</span>
}
// 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 ? (
<span
key={index}
className='bg-yellow-200 text-yellow-900 dark:bg-yellow-900/50 dark:text-yellow-200'
className='bg-[#bae6fd] text-[#0369a1] dark:bg-[rgba(51,180,255,0.2)] dark:text-[var(--brand-secondary)]'
>
{part}
</span>

View File

@@ -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

View File

@@ -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,
}
/**

View File

@@ -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<T, R>(
items: T[],
concurrency: number,
processor: (item: T, index: number) => Promise<R>
): Promise<R[]> {
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[][] = []
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`
)
try {
const batchEmbeddings = await callEmbeddingAPI(batch, config)
allEmbeddings.push(...batchEmbeddings)
const batchResults = await processWithConcurrency(
batches,
MAX_CONCURRENT_BATCHES,
async (batch, i) => {
const batchTokenCount = getTotalTokenCount(batch, embeddingModel)
logger.info(
`Generated ${batchEmbeddings.length} embeddings for batch ${i + 1}/${batches.length}`
`Processing batch ${i + 1}/${batches.length}: ${batch.length} texts, ${batchTokenCount} tokens`
)
} 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))
try {
const batchEmbeddings = await callEmbeddingAPI(batch, config)
logger.info(
`Generated ${batchEmbeddings.length} embeddings for batch ${i + 1}/${batches.length}`
)
return batchEmbeddings
} catch (error) {
logger.error(`Failed to generate embeddings for batch ${i + 1}:`, error)
throw error
}
}
}
)
const allEmbeddings = batchResults.flat()
logger.info(`Successfully generated ${allEmbeddings.length} embeddings total`)

View File

@@ -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> {

View File

@@ -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

View File

@@ -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<string, any>): Record<string, any> {
const normalizedBlocks: Record<string, any> = {}
@@ -19,6 +24,34 @@ function normalizeSubblockValues(blocks: Record<string, any>): Record<string, an
const normalizedSubBlocks: Record<string, any> = {}
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

View File

@@ -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<KalshiGetBalanceParams, KalshiGetB
handleKalshiError(data, response.status, 'get_balance')
}
const balance = data.balance || 0
const portfolioValue = data.portfolio_value
const balance = data.balance ?? 0
const portfolioValue = data.portfolio_value ?? 0
return {
success: true,
output: {
balance,
portfolioValue,
balanceDollars: balance / 100,
portfolioValueDollars: portfolioValue ? portfolioValue / 100 : undefined,
},
}
},
@@ -68,7 +64,5 @@ export const kalshiGetBalanceTool: ToolConfig<KalshiGetBalanceParams, KalshiGetB
outputs: {
balance: { type: 'number', description: 'Account balance in cents' },
portfolioValue: { type: 'number', description: 'Portfolio value in cents' },
balanceDollars: { type: 'number', description: 'Account balance in dollars' },
portfolioValueDollars: { type: 'number', description: 'Portfolio value in dollars' },
},
}

View File

@@ -82,7 +82,7 @@ export interface KalshiEvent {
// Balance type
export interface KalshiBalance {
balance: number // In cents
portfolio_value?: number // In cents
portfolio_value: number // In cents
}
// Position type