mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-06 21:54:01 -05:00
v0.5.50: import improvements, ui upgrades, kb styling and performance improvements
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { RenameDocumentModal } from './rename-document-modal'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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] ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -57,6 +57,8 @@ export {
|
||||
type PopoverBackButtonProps,
|
||||
PopoverContent,
|
||||
type PopoverContentProps,
|
||||
PopoverDivider,
|
||||
type PopoverDividerProps,
|
||||
PopoverFolder,
|
||||
type PopoverFolderProps,
|
||||
PopoverItem,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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`)
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user