Compare commits

..

16 Commits

Author SHA1 Message Date
Waleed
3792bdd252 v0.5.49: hitl improvements, new email styles, imap trigger, logs context menu (#2672)
* feat(logs-context-menu): consolidated logs utils and types, added logs record context menu (#2659)

* feat(email): welcome email; improvement(emails): ui/ux (#2658)

* feat(email): welcome email; improvement(emails): ui/ux

* improvement(emails): links, accounts, preview

* refactor(emails): file structure and wrapper components

* added envvar for personal emails sent, added isHosted gate

* fixed failing tests, added env mock

* fix: removed comment

---------

Co-authored-by: waleed <walif6@gmail.com>

* fix(logging): hitl + trigger dev crash protection (#2664)

* hitl gaps

* deal with trigger worker crashes

* cleanup import strcuture

* feat(imap): added support for imap trigger (#2663)

* feat(tools): added support for imap trigger

* feat(imap): added parity, tested

* ack PR comments

* final cleanup

* feat(i18n): update translations (#2665)

Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>

* fix(grain): updated grain trigger to auto-establish trigger (#2666)

Co-authored-by: aadamgough <adam@sim.ai>

* feat(admin): routes to manage deployments (#2667)

* feat(admin): routes to manage deployments

* fix naming fo deployed by

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date (#2668)

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date

* removed unused params, cleaned up redundant utils

* improvement(invite): aligned styling (#2669)

* improvement(invite): aligned with rest of app

* fix(invite): error handling

* fix: addressed comments

---------

Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>
Co-authored-by: Adam Gough <77861281+aadamgough@users.noreply.github.com>
Co-authored-by: aadamgough <adam@sim.ai>
2026-01-03 13:19:18 -08:00
Waleed
eb5d1f3e5b v0.5.48: copy-paste workflow blocks, docs updates, mcp tool fixes 2025-12-31 18:00:04 -08:00
Waleed
54ab82c8dd v0.5.47: deploy workflow as mcp, kb chunks tokenizer, UI improvements, jira service management tools 2025-12-30 23:18:58 -08:00
Waleed
f895bf469b v0.5.46: build improvements, greptile, light mode improvements 2025-12-29 02:17:52 -08:00
Waleed
dd3209af06 v0.5.45: light mode fixes, realtime usage indicator, docker build improvements 2025-12-27 19:57:42 -08:00
Waleed
b6ba3b50a7 v0.5.44: keyboard shortcuts, autolayout, light mode, byok, testing improvements 2025-12-26 21:25:19 -08:00
Waleed
b304233062 v0.5.43: export logs, circleback, grain, vertex, code hygiene, schedule improvements 2025-12-23 19:19:18 -08:00
Vikhyath Mondreti
57e4b49bd6 v0.5.42: fix memory migration 2025-12-23 01:24:54 -08:00
Vikhyath Mondreti
e12dd204ed v0.5.41: memory fixes, copilot improvements, knowledgebase improvements, LLM providers standardization 2025-12-23 00:15:18 -08:00
Vikhyath Mondreti
3d9d9cbc54 v0.5.40: supabase ops to allow non-public schemas, jira uuid 2025-12-21 22:28:05 -08:00
Waleed
0f4ec962ad v0.5.39: notion, workflow variables fixes 2025-12-20 20:44:00 -08:00
Waleed
4827866f9a v0.5.38: snap to grid, copilot ux improvements, billing line items 2025-12-20 17:24:38 -08:00
Waleed
3e697d9ed9 v0.5.37: redaction utils consolidation, logs updates, autoconnect improvements, additional kb tag types 2025-12-19 22:31:55 -08:00
Martin Yankov
4431a1a484 fix(helm): add custom egress rules to realtime network policy (#2481)
The realtime service network policy was missing the custom egress rules section
that allows configuration of additional egress rules via values.yaml. This caused
the realtime pods to be unable to connect to external databases (e.g., PostgreSQL
on port 5432) when using external database configurations.

The app network policy already had this section, but the realtime network policy
was missing it, creating an inconsistency and preventing the realtime service
from accessing external databases configured via networkPolicy.egress values.

This fix adds the same custom egress rules template section to the realtime
network policy, matching the app network policy behavior and allowing users to
configure database connectivity via values.yaml.
2025-12-19 18:59:08 -08:00
Waleed
4d1a9a3f22 v0.5.36: hitl improvements, opengraph, slack fixes, one-click unsubscribe, auth checks, new db indexes 2025-12-19 01:27:49 -08:00
Vikhyath Mondreti
eb07a080fb v0.5.35: helm updates, copilot improvements, 404 for docs, salesforce fixes, subflow resize clamping 2025-12-18 16:23:19 -08:00
56 changed files with 735 additions and 1994 deletions

View File

@@ -50,8 +50,8 @@
@layer base {
:root,
.light {
--bg: #fefefe; /* main canvas - neutral near-white */
--surface-1: #fefefe; /* sidebar, panels */
--bg: #fdfdfd; /* main canvas - neutral near-white */
--surface-1: #fcfcfc; /* sidebar, panels */
--surface-2: #ffffff; /* blocks, cards, modals - pure white */
--surface-3: #f7f7f7; /* popovers, headers */
--surface-4: #f5f5f5; /* buttons base */
@@ -70,7 +70,6 @@
--text-muted: #737373;
--text-subtle: #8c8c8c;
--text-inverse: #ffffff;
--text-muted-inverse: #a0a0a0;
--text-error: #ef4444;
/* Borders / dividers */
@@ -187,7 +186,6 @@
--text-muted: #787878;
--text-subtle: #7d7d7d;
--text-inverse: #1b1b1b;
--text-muted-inverse: #b3b3b3;
--text-error: #ef4444;
/* --border-strong: #303030; */
@@ -333,38 +331,38 @@
}
::-webkit-scrollbar-track {
background: transparent;
background: var(--surface-1);
}
::-webkit-scrollbar-thumb {
background-color: #c0c0c0;
background-color: var(--surface-7);
border-radius: var(--radius);
}
::-webkit-scrollbar-thumb:hover {
background-color: #a8a8a8;
background-color: var(--surface-7);
}
/* Dark Mode Global Scrollbar */
.dark ::-webkit-scrollbar-track {
background: transparent;
background: var(--surface-4);
}
.dark ::-webkit-scrollbar-thumb {
background-color: #5a5a5a;
background-color: var(--surface-7);
}
.dark ::-webkit-scrollbar-thumb:hover {
background-color: #6a6a6a;
background-color: var(--surface-7);
}
* {
scrollbar-width: thin;
scrollbar-color: #c0c0c0 transparent;
scrollbar-color: var(--surface-7) var(--surface-1);
}
.dark * {
scrollbar-color: #5a5a5a transparent;
scrollbar-color: var(--surface-7) var(--surface-4);
}
.copilot-scrollable {

View File

@@ -1,42 +0,0 @@
'use client'
import { useEffect } from 'react'
import { Tooltip } from '@/components/emcn'
import { Copilot } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot'
import { useCopilotStore } from '@/stores/panel/copilot/store'
/**
* Superagent page - standalone AI agent with full credential access
* Uses the exact same Copilot UI but with superagent mode forced
*/
export default function AgentPage() {
const { setMode } = useCopilotStore()
// Set superagent mode on mount
useEffect(() => {
setMode('superagent')
}, [setMode])
return (
<Tooltip.Provider delayDuration={600} skipDelayDuration={0}>
<div className='flex h-screen flex-col bg-[var(--surface-1)]'>
{/* Header */}
<header className='flex h-14 flex-shrink-0 items-center justify-between border-b border-[var(--border)] px-4'>
<div className='flex items-center gap-3'>
<h1 className='font-semibold text-lg text-[var(--text-primary)]'>Superagent</h1>
<span className='rounded-full bg-[var(--accent)]/10 px-2 py-0.5 font-medium text-[var(--accent)] text-xs'>
Full Access
</span>
</div>
</header>
{/* Copilot - exact same component in standalone mode */}
<div className='flex-1 overflow-hidden p-4'>
<div className='mx-auto h-full max-w-4xl'>
<Copilot panelWidth={800} standalone />
</div>
</div>
</div>
</Tooltip.Provider>
)
}

View File

@@ -38,7 +38,7 @@ const ChatMessageSchema = z.object({
message: z.string().min(1, 'Message is required'),
userMessageId: z.string().optional(), // ID from frontend for the user message
chatId: z.string().optional(),
workflowId: z.string().optional(),
workflowId: z.string().min(1, 'Workflow ID is required'),
model: z
.enum([
'gpt-5-fast',
@@ -63,7 +63,7 @@ const ChatMessageSchema = z.object({
])
.optional()
.default('claude-4.5-opus'),
mode: z.enum(['ask', 'agent', 'plan', 'superagent']).optional().default('agent'),
mode: z.enum(['ask', 'agent', 'plan']).optional().default('agent'),
prefetch: z.boolean().optional(),
createNewChat: z.boolean().optional().default(false),
stream: z.boolean().optional().default(true),
@@ -339,7 +339,7 @@ export async function POST(req: NextRequest) {
}
} | null = null
if (mode === 'agent' || mode === 'superagent') {
if (mode === 'agent') {
// Build base tools (executed locally, not deferred)
// Include function_execute for code execution capability
baseTools = [

View File

@@ -25,7 +25,7 @@ const ExecuteToolSchema = z.object({
toolCallId: z.string(),
toolName: z.string(),
arguments: z.record(z.any()).optional().default({}),
workflowId: z.string().nullish(), // Accept undefined or null for superagent mode
workflowId: z.string().optional(),
})
/**

View File

@@ -5,7 +5,6 @@ import {
checkWebhookPreprocessing,
findWebhookAndWorkflow,
handleProviderChallenges,
handleProviderReachabilityTest,
parseWebhookBody,
queueWebhookExecution,
verifyProviderAuth,
@@ -124,11 +123,6 @@ export async function POST(
return authError
}
const reachabilityResponse = handleProviderReachabilityTest(foundWebhook, body, requestId)
if (reachabilityResponse) {
return reachabilityResponse
}
let preprocessError: NextResponse | null = null
try {
preprocessError = await checkWebhookPreprocessing(foundWorkflow, foundWebhook, requestId)

View File

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

View File

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

View File

@@ -15,7 +15,6 @@ import {
} from 'lucide-react'
import { useParams, useRouter, useSearchParams } from 'next/navigation'
import {
Badge,
Breadcrumb,
Button,
Checkbox,
@@ -108,31 +107,14 @@ interface DocumentProps {
documentName?: string
}
function truncateContent(content: string, maxLength = 150, searchQuery = ''): 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 {
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)}...`
}
@@ -673,21 +655,13 @@ 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)
},
[selectedChunks, baseHandleContextMenu]
[baseHandleContextMenu]
)
/**
@@ -972,114 +946,106 @@ export function Document({
</TableCell>
</TableRow>
) : (
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)}
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 }}
>
<TableCell
className='w-[52px] py-[8px]'
style={{ paddingLeft: '20.5px', paddingRight: 0 }}
<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}
>
<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>
)
})
<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>
))
)}
</TableBody>
</Table>
@@ -1240,11 +1206,8 @@ export function Document({
onClose={handleContextMenuClose}
hasChunk={contextMenuChunk !== null}
isChunkEnabled={contextMenuChunk?.enabled ?? true}
selectedCount={selectedChunks.size}
enabledCount={enabledCount}
disabledCount={disabledCount}
onOpenInNewTab={
contextMenuChunk && selectedChunks.size === 1
contextMenuChunk
? () => {
const url = `/workspace/${workspaceId}/knowledge/${knowledgeBaseId}/${documentId}?chunk=${contextMenuChunk.id}`
window.open(url, '_blank')
@@ -1252,7 +1215,7 @@ export function Document({
: undefined
}
onEdit={
contextMenuChunk && selectedChunks.size === 1
contextMenuChunk
? () => {
setSelectedChunk(contextMenuChunk)
setIsModalOpen(true)
@@ -1260,7 +1223,7 @@ export function Document({
: undefined
}
onCopyContent={
contextMenuChunk && selectedChunks.size === 1
contextMenuChunk
? () => {
navigator.clipboard.writeText(contextMenuChunk.content)
}
@@ -1268,22 +1231,12 @@ export function Document({
}
onToggleEnabled={
contextMenuChunk && userPermissions.canEdit
? selectedChunks.size > 1
? () => {
if (disabledCount > 0) {
handleBulkEnable()
} else {
handleBulkDisable()
}
}
: () => handleToggleEnabled(contextMenuChunk.id)
? () => handleToggleEnabled(contextMenuChunk.id)
: undefined
}
onDelete={
contextMenuChunk && userPermissions.canEdit
? selectedChunks.size > 1
? handleBulkDelete
: () => handleDeleteChunk(contextMenuChunk.id)
? () => handleDeleteChunk(contextMenuChunk.id)
: undefined
}
onAddChunk={

View File

@@ -2,7 +2,6 @@
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,
@@ -48,12 +47,10 @@ 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,
@@ -407,7 +404,6 @@ 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 })
@@ -436,8 +432,6 @@ 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,
@@ -705,60 +699,6 @@ 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
*/
@@ -1028,21 +968,13 @@ 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)
},
[selectedDocuments, baseHandleContextMenu]
[baseHandleContextMenu]
)
/**
@@ -1279,9 +1211,7 @@ export function KnowledgeBase({
<TableRow
key={doc.id}
className={`${
isSelected
? 'bg-[var(--surface-3)] dark:bg-[var(--surface-4)]'
: 'hover:bg-[var(--surface-3)] dark:hover:bg-[var(--surface-4)]'
isSelected ? 'bg-[var(--surface-2)]' : 'hover:bg-[var(--surface-2)]'
} ${doc.processingStatus === 'completed' ? 'cursor-pointer' : 'cursor-default'}`}
onClick={() => {
if (doc.processingStatus === 'completed') {
@@ -1628,17 +1558,6 @@ 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}
@@ -1661,11 +1580,8 @@ export function KnowledgeBase({
? getDocumentTags(contextMenuDocument, tagDefinitions).length > 0
: false
}
selectedCount={selectedDocuments.size}
enabledCount={enabledCount}
disabledCount={disabledCount}
onOpenInNewTab={
contextMenuDocument && selectedDocuments.size === 1
contextMenuDocument
? () => {
const urlParams = new URLSearchParams({
kbName: knowledgeBaseName,
@@ -1678,26 +1594,13 @@ export function KnowledgeBase({
}
: undefined
}
onRename={
contextMenuDocument && selectedDocuments.size === 1 && userPermissions.canEdit
? () => handleRenameDocument(contextMenuDocument)
: undefined
}
onToggleEnabled={
contextMenuDocument && userPermissions.canEdit
? selectedDocuments.size > 1
? () => {
if (disabledCount > 0) {
handleBulkEnable()
} else {
handleBulkDisable()
}
}
: () => handleToggleEnabled(contextMenuDocument.id)
? () => handleToggleEnabled(contextMenuDocument.id)
: undefined
}
onViewTags={
contextMenuDocument && selectedDocuments.size === 1
contextMenuDocument
? () => {
const urlParams = new URLSearchParams({
kbName: knowledgeBaseName,
@@ -1711,9 +1614,7 @@ export function KnowledgeBase({
}
onDelete={
contextMenuDocument && userPermissions.canEdit
? selectedDocuments.size > 1
? handleBulkDelete
: () => handleDeleteDocument(contextMenuDocument.id)
? () => handleDeleteDocument(contextMenuDocument.id)
: undefined
}
onAddDocument={userPermissions.canEdit ? handleAddDocuments : undefined}

View File

@@ -11,7 +11,6 @@ interface DocumentContextMenuProps {
* Document-specific actions (shown when right-clicking on a document)
*/
onOpenInNewTab?: () => void
onRename?: () => void
onToggleEnabled?: () => void
onViewTags?: () => void
onDelete?: () => void
@@ -43,24 +42,11 @@ 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,
@@ -68,7 +54,6 @@ export function DocumentContextMenu({
menuRef,
onClose,
onOpenInNewTab,
onRename,
onToggleEnabled,
onViewTags,
onDelete,
@@ -79,20 +64,7 @@ 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
@@ -107,7 +79,7 @@ export function DocumentContextMenu({
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
{hasDocument ? (
<>
{!isMultiSelect && onOpenInNewTab && (
{onOpenInNewTab && (
<PopoverItem
onClick={() => {
onOpenInNewTab()
@@ -117,17 +89,7 @@ export function DocumentContextMenu({
Open in new tab
</PopoverItem>
)}
{!isMultiSelect && onRename && (
<PopoverItem
onClick={() => {
onRename()
onClose()
}}
>
Rename
</PopoverItem>
)}
{!isMultiSelect && hasTags && onViewTags && (
{hasTags && onViewTags && (
<PopoverItem
onClick={() => {
onViewTags()
@@ -145,7 +107,7 @@ export function DocumentContextMenu({
onClose()
}}
>
{getToggleLabel()}
{isDocumentEnabled ? 'Disable' : 'Enable'}
</PopoverItem>
)}
{onDelete && (

View File

@@ -2,4 +2,3 @@ export { ActionBar } from './action-bar/action-bar'
export { AddDocumentsModal } from './add-documents-modal/add-documents-modal'
export { BaseTagsModal } from './base-tags-modal/base-tags-modal'
export { DocumentContextMenu } from './document-context-menu'
export { RenameDocumentModal } from './rename-document-modal'

View File

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

View File

@@ -1,136 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import {
Button,
Input,
Label,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
const logger = createLogger('RenameDocumentModal')
interface RenameDocumentModalProps {
open: boolean
onOpenChange: (open: boolean) => void
documentId: string
initialName: string
onSave: (documentId: string, newName: string) => Promise<void>
}
/**
* Modal for renaming a document.
* Only changes the display name, not the underlying storage key.
*/
export function RenameDocumentModal({
open,
onOpenChange,
documentId,
initialName,
onSave,
}: RenameDocumentModalProps) {
const [name, setName] = useState(initialName)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (open) {
setName(initialName)
setError(null)
}
}, [open, initialName])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const trimmedName = name.trim()
if (!trimmedName) {
setError('Name is required')
return
}
if (trimmedName === initialName) {
onOpenChange(false)
return
}
setIsSubmitting(true)
setError(null)
try {
await onSave(documentId, trimmedName)
onOpenChange(false)
} catch (err) {
logger.error('Error renaming document:', err)
setError(err instanceof Error ? err.message : 'Failed to rename document')
} finally {
setIsSubmitting(false)
}
}
return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent>
<ModalHeader>Rename Document</ModalHeader>
<form onSubmit={handleSubmit} className='flex min-h-0 flex-1 flex-col'>
<ModalBody className='!pb-[16px]'>
<div className='space-y-[12px]'>
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='document-name'>Name</Label>
<Input
id='document-name'
value={name}
onChange={(e) => {
setName(e.target.value)
setError(null)
}}
placeholder='Enter document name'
className={cn(error && 'border-[var(--text-error)]')}
disabled={isSubmitting}
autoFocus
maxLength={255}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
data-lpignore='true'
data-form-type='other'
/>
</div>
</div>
</ModalBody>
<ModalFooter>
<div className='flex w-full items-center justify-between gap-[12px]'>
{error ? (
<p className='min-w-0 flex-1 truncate text-[11px] text-[var(--text-error)] leading-tight'>
{error}
</p>
) : (
<div />
)}
<div className='flex flex-shrink-0 gap-[8px]'>
<Button
variant='default'
onClick={() => onOpenChange(false)}
type='button'
disabled={isSubmitting}
>
Cancel
</Button>
<Button variant='tertiary' type='submit' disabled={isSubmitting || !name?.trim()}>
{isSubmitting ? 'Renaming...' : 'Rename'}
</Button>
</div>
</div>
</ModalFooter>
</form>
</ModalContent>
</Modal>
)
}

View File

@@ -185,10 +185,6 @@ 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)
@@ -196,6 +192,12 @@ export function NotificationSettings({
[subscriptions]
)
useEffect(() => {
if (!isLoading && !hasSubscriptions && !editingId) {
setShowForm(true)
}
}, [isLoading, hasSubscriptions, editingId, activeTab])
const resetForm = useCallback(() => {
setFormData({
workflowIds: [],
@@ -1208,7 +1210,7 @@ export function NotificationSettings({
)
const renderTabContent = () => {
if (displayForm) {
if (showForm) {
return renderForm()
}
@@ -1277,7 +1279,7 @@ export function NotificationSettings({
</ModalTabs>
<ModalFooter>
{displayForm ? (
{showForm ? (
<>
{hasSubscriptions && (
<Button

View File

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

View File

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

View File

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

View File

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

View File

@@ -276,7 +276,7 @@ function shouldShowRunSkipButtons(toolCall: CopilotToolCall): boolean {
const mode = useCopilotStore.getState().mode
const isAutoAllowed = useCopilotStore.getState().isToolAutoAllowed(toolCall.name)
if (
(mode === 'build' || mode === 'superagent') &&
mode === 'build' &&
isIntegrationTool(toolCall.name) &&
toolCall.state === 'pending' &&
!isAutoAllowed
@@ -564,11 +564,11 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
// Allow rendering if:
// 1. Tool is in CLASS_TOOL_METADATA (client tools), OR
// 2. We're in build or superagent mode (integration tools are executed server-side)
// 2. We're in build mode (integration tools are executed server-side)
const isClientTool = !!CLASS_TOOL_METADATA[toolCall.name]
const isIntegrationToolInAgentMode = (mode === 'build' || mode === 'superagent') && !isClientTool
const isIntegrationToolInBuildMode = mode === 'build' && !isClientTool
if (!isClientTool && !isIntegrationToolInAgentMode) {
if (!isClientTool && !isIntegrationToolInBuildMode) {
return null
}
const isExpandableTool =

View File

@@ -1,7 +1,7 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { ListTree, MessageSquare, Package, Zap } from 'lucide-react'
import { ListTree, MessageSquare, Package } from 'lucide-react'
import {
Badge,
Popover,
@@ -13,10 +13,10 @@ import {
import { cn } from '@/lib/core/utils/cn'
interface ModeSelectorProps {
/** Current mode - 'ask', 'build', 'plan', or 'superagent' */
mode: 'ask' | 'build' | 'plan' | 'superagent'
/** Current mode - 'ask', 'build', or 'plan' */
mode: 'ask' | 'build' | 'plan'
/** Callback when mode changes */
onModeChange?: (mode: 'ask' | 'build' | 'plan' | 'superagent') => void
onModeChange?: (mode: 'ask' | 'build' | 'plan') => void
/** Whether the input is near the top of viewport (affects dropdown direction) */
isNearTop: boolean
/** Whether the selector is disabled */
@@ -42,9 +42,6 @@ export function ModeSelector({ mode, onModeChange, isNearTop, disabled }: ModeSe
if (mode === 'plan') {
return <ListTree className='h-3 w-3' />
}
if (mode === 'superagent') {
return <Zap className='h-3 w-3' />
}
return <Package className='h-3 w-3' />
}
@@ -55,13 +52,10 @@ export function ModeSelector({ mode, onModeChange, isNearTop, disabled }: ModeSe
if (mode === 'plan') {
return 'Plan'
}
if (mode === 'superagent') {
return 'Superagent'
}
return 'Build'
}
const handleSelect = (selectedMode: 'ask' | 'build' | 'plan' | 'superagent') => {
const handleSelect = (selectedMode: 'ask' | 'build' | 'plan') => {
onModeChange?.(selectedMode)
setOpen(false)
}

View File

@@ -51,8 +51,8 @@ interface UserInputProps {
isAborting?: boolean
placeholder?: string
className?: string
mode?: 'ask' | 'build' | 'plan' | 'superagent'
onModeChange?: (mode: 'ask' | 'build' | 'plan' | 'superagent') => void
mode?: 'ask' | 'build' | 'plan'
onModeChange?: (mode: 'ask' | 'build' | 'plan') => void
value?: string
onChange?: (value: string) => void
panelWidth?: number

View File

@@ -8,8 +8,8 @@ import { Button } from '@/components/emcn'
interface WelcomeProps {
/** Callback when a suggested question is clicked */
onQuestionClick?: (question: string) => void
/** Current copilot mode ('ask' for Q&A, 'plan' for planning, 'build' for workflow building, 'superagent' for full access) */
mode?: 'ask' | 'build' | 'plan' | 'superagent'
/** Current copilot mode ('ask' for Q&A, 'plan' for planning, 'build' for workflow building) */
mode?: 'ask' | 'build' | 'plan'
}
/**

View File

@@ -49,8 +49,6 @@ const logger = createLogger('Copilot')
interface CopilotProps {
/** Width of the copilot panel in pixels */
panelWidth: number
/** If true, runs in standalone mode without workflow context (for superagent) */
standalone?: boolean
}
/**
@@ -69,7 +67,7 @@ interface CopilotRef {
* Copilot component - AI-powered assistant for workflow management
* Provides chat interface, message history, and intelligent workflow suggestions
*/
export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth, standalone = false }, ref) => {
export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref) => {
const userInputRef = useRef<UserInputRef>(null)
const copilotContainerRef = useRef<HTMLDivElement>(null)
const cancelEditCallbackRef = useRef<(() => void) | null>(null)
@@ -124,7 +122,6 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth, stand
loadAutoAllowedTools,
currentChat,
isSendingMessage,
standalone,
})
// Handle scroll management
@@ -301,7 +298,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth, stand
*/
const handleSubmit = useCallback(
async (query: string, fileAttachments?: MessageFileAttachment[], contexts?: any[]) => {
if (!query || isSendingMessage || (!activeWorkflowId && !standalone)) return
if (!query || isSendingMessage || !activeWorkflowId) return
if (showPlanTodos) {
const store = useCopilotStore.getState()
@@ -319,7 +316,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth, stand
logger.error('Failed to send message:', error)
}
},
[isSendingMessage, activeWorkflowId, sendMessage, showPlanTodos, standalone]
[isSendingMessage, activeWorkflowId, sendMessage, showPlanTodos]
)
/**
@@ -490,11 +487,11 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth, stand
ref={userInputRef}
onSubmit={handleSubmit}
onAbort={handleAbort}
disabled={!activeWorkflowId && !standalone}
disabled={!activeWorkflowId}
isLoading={isSendingMessage}
isAborting={isAborting}
mode={mode}
onModeChange={standalone ? undefined : setMode}
onModeChange={setMode}
value={inputValue}
onChange={setInputValue}
panelWidth={panelWidth}
@@ -597,11 +594,11 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth, stand
ref={userInputRef}
onSubmit={handleSubmit}
onAbort={handleAbort}
disabled={!activeWorkflowId && !standalone}
disabled={!activeWorkflowId}
isLoading={isSendingMessage}
isAborting={isAborting}
mode={mode}
onModeChange={standalone ? undefined : setMode}
onModeChange={setMode}
value={inputValue}
onChange={setInputValue}
panelWidth={panelWidth}

View File

@@ -15,8 +15,6 @@ interface UseCopilotInitializationProps {
loadAutoAllowedTools: () => Promise<void>
currentChat: any
isSendingMessage: boolean
/** If true, initializes without requiring a workflowId (for standalone agent mode) */
standalone?: boolean
}
/**
@@ -36,7 +34,6 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
loadAutoAllowedTools,
currentChat,
isSendingMessage,
standalone = false,
} = props
const [isInitialized, setIsInitialized] = useState(false)
@@ -49,14 +46,6 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
* Never loads during message streaming to prevent interrupting active conversations
*/
useEffect(() => {
// Standalone mode: initialize immediately without workflow
if (standalone && !hasMountedRef.current && !isSendingMessage) {
hasMountedRef.current = true
setIsInitialized(true)
logger.info('Standalone mode initialized')
return
}
if (activeWorkflowId && !hasMountedRef.current && !isSendingMessage) {
hasMountedRef.current = true
setIsInitialized(false)
@@ -66,7 +55,7 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
// Use false to let the store decide if a reload is needed based on cache
loadChats(false)
}
}, [activeWorkflowId, setCopilotWorkflowId, loadChats, isSendingMessage, standalone])
}, [activeWorkflowId, setCopilotWorkflowId, loadChats, isSendingMessage])
/**
* Initialize the component - only on mount and genuine workflow changes
@@ -74,9 +63,6 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
* Never reloads during message streaming to preserve active conversations
*/
useEffect(() => {
// Skip workflow tracking in standalone mode
if (standalone) return
// Handle genuine workflow changes (not initial mount, not same workflow)
// Only reload if not currently streaming to avoid interrupting conversations
if (
@@ -114,23 +100,19 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
setCopilotWorkflowId,
loadChats,
isSendingMessage,
standalone,
])
/**
* Fetch context usage when component is initialized and has a current chat
*/
useEffect(() => {
// In standalone mode, skip context usage fetch (no workflow context)
if (standalone) return
if (isInitialized && currentChat?.id && activeWorkflowId) {
logger.info('[Copilot] Component initialized, fetching context usage')
fetchContextUsage().catch((err) => {
logger.warn('[Copilot] Failed to fetch context usage on mount', err)
})
}
}, [isInitialized, currentChat?.id, activeWorkflowId, fetchContextUsage, standalone])
}, [isInitialized, currentChat?.id, activeWorkflowId, fetchContextUsage])
/**
* Load auto-allowed tools once on mount

View File

@@ -12,7 +12,6 @@ import {
getCodeEditorProps,
highlight,
languages,
Textarea,
Tooltip,
} from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'
@@ -75,8 +74,6 @@ interface ConditionInputProps {
previewValue?: string | null
/** Whether the component is disabled */
disabled?: boolean
/** Mode: 'condition' for code editor, 'router' for text input */
mode?: 'condition' | 'router'
}
/**
@@ -104,9 +101,7 @@ export function ConditionInput({
isPreview = false,
previewValue,
disabled = false,
mode = 'condition',
}: ConditionInputProps) {
const isRouterMode = mode === 'router'
const params = useParams()
const workspaceId = params.workspaceId as string
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId)
@@ -166,50 +161,32 @@ export function ConditionInput({
const shouldPersistRef = useRef<boolean>(false)
/**
* Creates default blocks with stable IDs.
* For conditions: if/else blocks. For router: one route block.
* Creates default if/else conditional blocks with stable IDs.
*
* @returns Array of default blocks
* @returns Array of two default blocks (if and else)
*/
const createDefaultBlocks = (): ConditionalBlock[] => {
if (isRouterMode) {
return [
{
id: generateStableId(blockId, 'route1'),
title: 'route1',
value: '',
showTags: false,
showEnvVars: false,
searchTerm: '',
cursorPosition: 0,
activeSourceBlockId: null,
},
]
}
return [
{
id: generateStableId(blockId, 'if'),
title: 'if',
value: '',
showTags: false,
showEnvVars: false,
searchTerm: '',
cursorPosition: 0,
activeSourceBlockId: null,
},
{
id: generateStableId(blockId, 'else'),
title: 'else',
value: '',
showTags: false,
showEnvVars: false,
searchTerm: '',
cursorPosition: 0,
activeSourceBlockId: null,
},
]
}
const createDefaultBlocks = (): ConditionalBlock[] => [
{
id: generateStableId(blockId, 'if'),
title: 'if',
value: '',
showTags: false,
showEnvVars: false,
searchTerm: '',
cursorPosition: 0,
activeSourceBlockId: null,
},
{
id: generateStableId(blockId, 'else'),
title: 'else',
value: '',
showTags: false,
showEnvVars: false,
searchTerm: '',
cursorPosition: 0,
activeSourceBlockId: null,
},
]
// Initialize with a loading state instead of default blocks
const [conditionalBlocks, setConditionalBlocks] = useState<ConditionalBlock[]>([])
@@ -293,13 +270,10 @@ export function ConditionInput({
const parsedBlocks = safeParseJSON(effectiveValueStr)
if (parsedBlocks) {
// For router mode, keep original titles. For condition mode, assign if/else if/else
const blocksWithCorrectTitles = isRouterMode
? parsedBlocks
: parsedBlocks.map((block, index) => ({
...block,
title: index === 0 ? 'if' : index === parsedBlocks.length - 1 ? 'else' : 'else if',
}))
const blocksWithCorrectTitles = parsedBlocks.map((block, index) => ({
...block,
title: index === 0 ? 'if' : index === parsedBlocks.length - 1 ? 'else' : 'else if',
}))
setConditionalBlocks(blocksWithCorrectTitles)
hasInitializedRef.current = true
@@ -599,17 +573,12 @@ export function ConditionInput({
/**
* Updates block titles based on their position in the array.
* For conditions: First block is 'if', last is 'else', middle ones are 'else if'.
* For router: Titles are user-editable and not auto-updated.
* First block is always 'if', last is 'else', middle ones are 'else if'.
*
* @param blocks - Array of conditional blocks
* @returns Updated blocks with correct titles
*/
const updateBlockTitles = (blocks: ConditionalBlock[]): ConditionalBlock[] => {
if (isRouterMode) {
// For router mode, don't change titles - they're user-editable
return blocks
}
return blocks.map((block, index) => ({
...block,
title: index === 0 ? 'if' : index === blocks.length - 1 ? 'else' : 'else if',
@@ -621,15 +590,13 @@ export function ConditionInput({
if (isPreview || disabled) return
const blockIndex = conditionalBlocks.findIndex((block) => block.id === afterId)
if (!isRouterMode && conditionalBlocks[blockIndex]?.title === 'else') return
if (conditionalBlocks[blockIndex]?.title === 'else') return
const newBlockId = isRouterMode
? generateStableId(blockId, `route-${Date.now()}`)
: generateStableId(blockId, `else-if-${Date.now()}`)
const newBlockId = generateStableId(blockId, `else-if-${Date.now()}`)
const newBlock: ConditionalBlock = {
id: newBlockId,
title: isRouterMode ? `route-${Date.now()}` : '',
title: '',
value: '',
showTags: false,
showEnvVars: false,
@@ -743,15 +710,13 @@ export function ConditionInput({
<div
className={cn(
'flex items-center justify-between overflow-hidden bg-transparent px-[10px] py-[5px]',
isRouterMode
? 'rounded-t-[4px] border-[var(--border-1)] border-b'
: block.title === 'else'
? 'rounded-[4px] border-0'
: 'rounded-t-[4px] border-[var(--border-1)] border-b'
block.title === 'else'
? 'rounded-[4px] border-0'
: 'rounded-t-[4px] border-[var(--border-1)] border-b'
)}
>
<span className='font-medium text-[14px] text-[var(--text-tertiary)]'>
{isRouterMode ? `Route ${index + 1}` : block.title}
{block.title}
</span>
<div className='flex items-center gap-[8px]'>
<Tooltip.Root>
@@ -759,7 +724,7 @@ export function ConditionInput({
<Button
variant='ghost'
onClick={() => addBlock(block.id)}
disabled={isPreview || disabled || (!isRouterMode && block.title === 'else')}
disabled={isPreview || disabled || block.title === 'else'}
className='h-auto p-0'
>
<Plus className='h-[14px] w-[14px]' />
@@ -774,12 +739,7 @@ export function ConditionInput({
<Button
variant='ghost'
onClick={() => moveBlock(block.id, 'up')}
disabled={
isPreview ||
index === 0 ||
disabled ||
(!isRouterMode && block.title === 'else')
}
disabled={isPreview || index === 0 || disabled || block.title === 'else'}
className='h-auto p-0'
>
<ChevronUp className='h-[14px] w-[14px]' />
@@ -798,8 +758,8 @@ export function ConditionInput({
isPreview ||
disabled ||
index === conditionalBlocks.length - 1 ||
(!isRouterMode && conditionalBlocks[index + 1]?.title === 'else') ||
(!isRouterMode && block.title === 'else')
conditionalBlocks[index + 1]?.title === 'else' ||
block.title === 'else'
}
className='h-auto p-0'
>
@@ -815,122 +775,18 @@ export function ConditionInput({
<Button
variant='ghost'
onClick={() => removeBlock(block.id)}
disabled={isPreview || disabled || conditionalBlocks.length === 1}
disabled={isPreview || conditionalBlocks.length === 1 || disabled}
className='h-auto p-0 text-[var(--text-error)] hover:text-[var(--text-error)]'
>
<Trash className='h-[14px] w-[14px]' />
<span className='sr-only'>Delete Block</span>
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
{isRouterMode ? 'Delete Route' : 'Delete Condition'}
</Tooltip.Content>
<Tooltip.Content>Delete Condition</Tooltip.Content>
</Tooltip.Root>
</div>
</div>
{/* Router mode: show description textarea with tag/env var support */}
{isRouterMode && (
<div
className='relative'
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => handleDrop(block.id, e)}
>
<Textarea
data-router-block-id={block.id}
value={block.value}
onChange={(e) => {
if (!isPreview && !disabled) {
const newValue = e.target.value
const pos = e.target.selectionStart ?? 0
const tagTrigger = checkTagTrigger(newValue, pos)
const envVarTrigger = checkEnvVarTrigger(newValue, pos)
shouldPersistRef.current = true
setConditionalBlocks((blocks) =>
blocks.map((b) =>
b.id === block.id
? {
...b,
value: newValue,
showTags: tagTrigger.show,
showEnvVars: envVarTrigger.show,
searchTerm: envVarTrigger.show ? envVarTrigger.searchTerm : '',
cursorPosition: pos,
}
: b
)
)
}
}}
onBlur={() => {
setTimeout(() => {
setConditionalBlocks((blocks) =>
blocks.map((b) =>
b.id === block.id ? { ...b, showTags: false, showEnvVars: false } : b
)
)
}, 150)
}}
placeholder='Describe when this route should be taken...'
disabled={disabled || isPreview}
className='min-h-[60px] resize-none rounded-none border-0 px-3 py-2 text-sm placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0'
rows={2}
/>
{block.showEnvVars && (
<EnvVarDropdown
visible={block.showEnvVars}
onSelect={(newValue) => handleEnvVarSelectImmediate(block.id, newValue)}
searchTerm={block.searchTerm}
inputValue={block.value}
cursorPosition={block.cursorPosition}
workspaceId={workspaceId}
onClose={() => {
setConditionalBlocks((blocks) =>
blocks.map((b) =>
b.id === block.id
? {
...b,
showEnvVars: false,
searchTerm: '',
}
: b
)
)
}}
/>
)}
{block.showTags && (
<TagDropdown
visible={block.showTags}
onSelect={(newValue) => handleTagSelectImmediate(block.id, newValue)}
blockId={blockId}
activeSourceBlockId={block.activeSourceBlockId}
inputValue={block.value}
cursorPosition={block.cursorPosition}
onClose={() => {
setConditionalBlocks((blocks) =>
blocks.map((b) =>
b.id === block.id
? {
...b,
showTags: false,
activeSourceBlockId: null,
}
: b
)
)
}}
/>
)}
</div>
)}
{/* Condition mode: show code editor */}
{!isRouterMode &&
block.title !== 'else' &&
{block.title !== 'else' &&
(() => {
const blockLineCount = block.value.split('\n').length
const blockGutterWidth = calculateGutterWidth(blockLineCount)

View File

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

View File

@@ -38,27 +38,6 @@ 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,
@@ -85,8 +64,8 @@ export function VariablesInput({
(v: Variable) => v.workflowId === workflowId
)
const rawValue = isPreview ? previewValue : storeValue
const assignments: VariableAssignment[] = parseVariableAssignments(rawValue)
const value = isPreview ? previewValue : storeValue
const assignments: VariableAssignment[] = value || []
const isReadOnly = isPreview || disabled
const getAvailableVariablesFor = (currentAssignmentId: string) => {

View File

@@ -605,18 +605,6 @@ function SubBlockComponent({
/>
)
case 'router-input':
return (
<ConditionInput
blockId={blockId}
subBlockId={config.id}
isPreview={isPreview}
previewValue={previewValue as any}
disabled={isDisabled}
mode='router'
/>
)
case 'eval-input':
return (
<EvalInput

View File

@@ -841,37 +841,6 @@ export const WorkflowBlock = memo(function WorkflowBlock({
]
}, [type, subBlockState, id])
/**
* Compute per-route rows (id/value) for router_v2 blocks so we can render
* one row per route with its own output handle.
* Uses same structure as conditions: { id, title, value }
*/
const routerRows = useMemo(() => {
if (type !== 'router_v2') return [] as { id: string; value: string }[]
const routesValue = subBlockState.routes?.value
const raw = typeof routesValue === 'string' ? routesValue : undefined
try {
if (raw) {
const parsed = JSON.parse(raw) as unknown
if (Array.isArray(parsed)) {
return parsed.map((item: unknown, index: number) => {
const routeItem = item as { id?: string; value?: string }
return {
id: routeItem?.id ?? `${id}-route-${index}`,
value: routeItem?.value ?? '',
}
})
}
}
} catch (error) {
logger.warn('Failed to parse router routes value', { error, blockId: id })
}
return [{ id: `${id}-route-route1`, value: '' }]
}, [type, subBlockState, id])
/**
* Compute and publish deterministic layout metrics for workflow blocks.
* This avoids ResizeObserver/animation-frame jitter and prevents initial "jump".
@@ -888,8 +857,6 @@ export const WorkflowBlock = memo(function WorkflowBlock({
let rowsCount = 0
if (type === 'condition') {
rowsCount = conditionRows.length + defaultHandlesRow
} else if (type === 'router_v2') {
rowsCount = routerRows.length + defaultHandlesRow
} else {
const subblockRowCount = subBlockRows.reduce((acc, row) => acc + row.length, 0)
rowsCount = subblockRowCount + defaultHandlesRow
@@ -912,7 +879,6 @@ export const WorkflowBlock = memo(function WorkflowBlock({
displayTriggerMode,
subBlockRows.length,
conditionRows.length,
routerRows.length,
horizontalHandles,
],
})
@@ -1059,7 +1025,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
Webhook
</Badge>
</Tooltip.Trigger>
<Tooltip.Content side='top' className='max-w-[300px]'>
<Tooltip.Content side='top' className='max-w-[300px] p-4'>
{webhookProvider && webhookPath ? (
<>
<p className='text-sm'>{getProviderName(webhookProvider)} Webhook</p>
@@ -1115,32 +1081,24 @@ export const WorkflowBlock = memo(function WorkflowBlock({
value={getDisplayValue(cond.value)}
/>
))
: type === 'router_v2'
? routerRows.map((route, index) => (
<SubBlockRow
key={route.id}
title={`Route ${index + 1}`}
value={getDisplayValue(route.value)}
/>
))
: subBlockRows.map((row, rowIndex) =>
row.map((subBlock) => {
const rawValue = subBlockState[subBlock.id]?.value
return (
<SubBlockRow
key={`${subBlock.id}-${rowIndex}`}
title={subBlock.title ?? subBlock.id}
value={getDisplayValue(rawValue)}
subBlock={subBlock}
rawValue={rawValue}
workspaceId={workspaceId}
workflowId={currentWorkflowId}
blockId={id}
allSubBlockValues={subBlockState}
/>
)
})
)}
: subBlockRows.map((row, rowIndex) =>
row.map((subBlock) => {
const rawValue = subBlockState[subBlock.id]?.value
return (
<SubBlockRow
key={`${subBlock.id}-${rowIndex}`}
title={subBlock.title ?? subBlock.id}
value={getDisplayValue(rawValue)}
subBlock={subBlock}
rawValue={rawValue}
workspaceId={workspaceId}
workflowId={currentWorkflowId}
blockId={id}
allSubBlockValues={subBlockState}
/>
)
})
)}
{shouldShowDefaultHandles && <SubBlockRow title='error' />}
</div>
)}
@@ -1195,57 +1153,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
</>
)}
{type === 'router_v2' && (
<>
{routerRows.map((route, routeIndex) => {
const topOffset =
HANDLE_POSITIONS.CONDITION_START_Y +
routeIndex * HANDLE_POSITIONS.CONDITION_ROW_HEIGHT
return (
<Handle
key={`handle-${route.id}`}
type='source'
position={Position.Right}
id={`router-${route.id}`}
className={getHandleClasses('right')}
style={{ top: `${topOffset}px`, transform: 'translateY(-50%)' }}
data-nodeid={id}
data-handleid={`router-${route.id}`}
isConnectableStart={true}
isConnectableEnd={false}
isValidConnection={(connection) => {
if (connection.target === id) return false
const edges = useWorkflowStore.getState().edges
return !wouldCreateCycle(edges, connection.source!, connection.target!)
}}
/>
)
})}
<Handle
type='source'
position={Position.Right}
id='error'
className={getHandleClasses('right', true)}
style={{
right: '-7px',
top: 'auto',
bottom: `${HANDLE_POSITIONS.ERROR_BOTTOM_OFFSET}px`,
transform: 'translateY(50%)',
}}
data-nodeid={id}
data-handleid='error'
isConnectableStart={true}
isConnectableEnd={false}
isValidConnection={(connection) => {
if (connection.target === id) return false
const edges = useWorkflowStore.getState().edges
return !wouldCreateCycle(edges, connection.source!, connection.target!)
}}
/>
</>
)}
{type !== 'condition' && type !== 'router_v2' && type !== 'response' && (
{type !== 'condition' && type !== 'response' && (
<>
<Handle
type='source'

View File

@@ -165,7 +165,7 @@ const reactFlowStyles = [
'[&_.react-flow__renderer]:!bg-transparent',
'[&_.react-flow__background]:hidden',
].join(' ')
const reactFlowFitViewOptions = { padding: 0.6, maxZoom: 1.0 } as const
const reactFlowFitViewOptions = { padding: 0.6 } 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(--workflow-edge)',
stroke: isErrorConnectionDrag ? 'var(--text-error)' : 'var(--surface-7)',
strokeWidth: 2,
}),
[isErrorConnectionDrag]

View File

@@ -80,12 +80,6 @@ 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' />
@@ -93,16 +87,13 @@ function GeneralSkeleton() {
</div>
{/* Telemetry description */}
<div className='-mt-[8px] flex flex-col gap-1'>
<Skeleton className='h-[12px] w-full' />
<Skeleton className='h-[12px] w-4/5' />
</div>
<Skeleton className='h-[12px] w-full' />
<Skeleton className='-mt-2 h-[12px] w-4/5' />
{/* Action buttons */}
<div className='mt-auto flex items-center gap-[8px]'>
<Skeleton className='h-8 w-20 rounded-[4px]' />
<Skeleton className='h-8 w-28 rounded-[4px]' />
<Skeleton className='ml-auto h-8 w-24 rounded-[4px]' />
</div>
</div>
)

View File

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

View File

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

View File

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

View File

@@ -529,7 +529,7 @@ export function Sidebar() {
<ArrowDown className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<Tooltip.Content className='py-[2.5px]'>
<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>
<Tooltip.Content className='py-[2.5px]'>
<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>
<Tooltip.Content className='py-[2.5px]'>
<p>{isCreatingWorkflow ? 'Creating workflow...' : 'Create workflow'}</p>
</Tooltip.Content>
</Tooltip.Root>

View File

@@ -51,9 +51,6 @@ interface TargetBlock {
currentState?: any
}
/**
* Generates the system prompt for the legacy router (block-based).
*/
export const generateRouterPrompt = (prompt: string, targetBlocks?: TargetBlock[]): string => {
const basePrompt = `You are an intelligent routing agent responsible for directing workflow requests to the most appropriate block. Your task is to analyze the input and determine the single most suitable destination based on the request.
@@ -110,88 +107,9 @@ Example: "2acd9007-27e8-4510-a487-73d3b825e7c1"
Remember: Your response must be ONLY the block ID - no additional text, formatting, or explanation.`
}
/**
* Generates the system prompt for the port-based router (v2).
* Instead of selecting a block by ID, it selects a route by evaluating all route descriptions.
*/
export const generateRouterV2Prompt = (
context: string,
routes: Array<{ id: string; title: string; value: string }>
): string => {
const routesInfo = routes
.map(
(route, index) => `
Route ${index + 1}:
ID: ${route.id}
Description: ${route.value || 'No description provided'}
---`
)
.join('\n')
return `You are an intelligent routing agent. Your task is to analyze the provided context and select the most appropriate route from the available options.
Available Routes:
${routesInfo}
Context to analyze:
${context}
Instructions:
1. Carefully analyze the context against each route's description
2. Select the route that best matches the context's intent and requirements
3. Consider the semantic meaning, not just keyword matching
4. If multiple routes could match, choose the most specific one
Response Format:
Return ONLY the route ID as a single string, no punctuation, no explanation.
Example: "route-abc123"
Remember: Your response must be ONLY the route ID - no additional text, formatting, or explanation.`
}
/**
* Helper to get model options for both router versions.
*/
const getModelOptions = () => {
const providersState = useProvidersStore.getState()
const baseModels = providersState.providers.base.models
const ollamaModels = providersState.providers.ollama.models
const vllmModels = providersState.providers.vllm.models
const openrouterModels = providersState.providers.openrouter.models
const allModels = Array.from(
new Set([...baseModels, ...ollamaModels, ...vllmModels, ...openrouterModels])
)
return allModels.map((model) => {
const icon = getProviderIcon(model)
return { label: model, id: model, ...(icon && { icon }) }
})
}
/**
* Helper to get API key condition for both router versions.
*/
const getApiKeyCondition = () => {
return isHosted
? {
field: 'model',
value: [...getHostedModels(), ...providers.vertex.models],
not: true,
}
: () => ({
field: 'model',
value: [...getCurrentOllamaModels(), ...getCurrentVLLMModels(), ...providers.vertex.models],
not: true,
})
}
/**
* Legacy Router Block (block-based routing).
* Hidden from toolbar but still supported for existing workflows.
*/
export const RouterBlock: BlockConfig<RouterResponse> = {
type: 'router',
name: 'Router (Legacy)',
name: 'Router',
description: 'Route workflow',
authMode: AuthMode.ApiKey,
longDescription:
@@ -203,7 +121,6 @@ export const RouterBlock: BlockConfig<RouterResponse> = {
category: 'blocks',
bgColor: '#28C43F',
icon: ConnectIcon,
hideFromToolbar: true, // Hide legacy version from toolbar
subBlocks: [
{
id: 'prompt',
@@ -219,7 +136,21 @@ export const RouterBlock: BlockConfig<RouterResponse> = {
placeholder: 'Type or select a model...',
required: true,
defaultValue: 'claude-sonnet-4-5',
options: getModelOptions,
options: () => {
const providersState = useProvidersStore.getState()
const baseModels = providersState.providers.base.models
const ollamaModels = providersState.providers.ollama.models
const vllmModels = providersState.providers.vllm.models
const openrouterModels = providersState.providers.openrouter.models
const allModels = Array.from(
new Set([...baseModels, ...ollamaModels, ...vllmModels, ...openrouterModels])
)
return allModels.map((model) => {
const icon = getProviderIcon(model)
return { label: model, id: model, ...(icon && { icon }) }
})
},
},
{
id: 'vertexCredential',
@@ -242,7 +173,22 @@ export const RouterBlock: BlockConfig<RouterResponse> = {
password: true,
connectionDroppable: false,
required: true,
condition: getApiKeyCondition(),
// Hide API key for hosted models, Ollama models, vLLM models, and Vertex models (uses OAuth)
condition: isHosted
? {
field: 'model',
value: [...getHostedModels(), ...providers.vertex.models],
not: true, // Show for all models EXCEPT those listed
}
: () => ({
field: 'model',
value: [
...getCurrentOllamaModels(),
...getCurrentVLLMModels(),
...providers.vertex.models,
],
not: true, // Show for all models EXCEPT Ollama, vLLM, and Vertex models
}),
},
{
id: 'azureEndpoint',
@@ -357,185 +303,3 @@ export const RouterBlock: BlockConfig<RouterResponse> = {
selectedPath: { type: 'json', description: 'Selected routing path' },
},
}
/**
* Router V2 Block (port-based routing).
* Uses route definitions with descriptions instead of downstream block names.
*/
interface RouterV2Response extends ToolResponse {
output: {
context: string
model: string
tokens?: {
prompt?: number
completion?: number
total?: number
}
cost?: {
input: number
output: number
total: number
}
selectedRoute: string
selectedPath: {
blockId: string
blockType: string
blockTitle: string
}
}
}
export const RouterV2Block: BlockConfig<RouterV2Response> = {
type: 'router_v2',
name: 'Router',
description: 'Route workflow based on context',
authMode: AuthMode.ApiKey,
longDescription:
'Intelligently route workflow execution to different paths based on context analysis. Define multiple routes with descriptions, and an LLM will determine which route to take based on the provided context.',
bestPractices: `
- Write clear, specific descriptions for each route
- The context field should contain all relevant information for routing decisions
- Route descriptions should be mutually exclusive when possible
- Use descriptive route names to make the workflow readable
`,
category: 'blocks',
bgColor: '#28C43F',
icon: ConnectIcon,
subBlocks: [
{
id: 'context',
title: 'Context',
type: 'long-input',
placeholder: 'Enter the context to analyze for routing...',
required: true,
},
{
id: 'routes',
type: 'router-input',
},
{
id: 'model',
title: 'Model',
type: 'combobox',
placeholder: 'Type or select a model...',
required: true,
defaultValue: 'claude-sonnet-4-5',
options: getModelOptions,
},
{
id: 'vertexCredential',
title: 'Google Cloud Account',
type: 'oauth-input',
serviceId: 'vertex-ai',
requiredScopes: ['https://www.googleapis.com/auth/cloud-platform'],
placeholder: 'Select Google Cloud account',
required: true,
condition: {
field: 'model',
value: providers.vertex.models,
},
},
{
id: 'apiKey',
title: 'API Key',
type: 'short-input',
placeholder: 'Enter your API key',
password: true,
connectionDroppable: false,
required: true,
condition: getApiKeyCondition(),
},
{
id: 'azureEndpoint',
title: 'Azure OpenAI Endpoint',
type: 'short-input',
password: true,
placeholder: 'https://your-resource.openai.azure.com',
connectionDroppable: false,
condition: {
field: 'model',
value: providers['azure-openai'].models,
},
},
{
id: 'azureApiVersion',
title: 'Azure API Version',
type: 'short-input',
placeholder: '2024-07-01-preview',
connectionDroppable: false,
condition: {
field: 'model',
value: providers['azure-openai'].models,
},
},
{
id: 'vertexProject',
title: 'Vertex AI Project',
type: 'short-input',
placeholder: 'your-gcp-project-id',
connectionDroppable: false,
required: true,
condition: {
field: 'model',
value: providers.vertex.models,
},
},
{
id: 'vertexLocation',
title: 'Vertex AI Location',
type: 'short-input',
placeholder: 'us-central1',
connectionDroppable: false,
required: true,
condition: {
field: 'model',
value: providers.vertex.models,
},
},
],
tools: {
access: [
'openai_chat',
'anthropic_chat',
'google_chat',
'xai_chat',
'deepseek_chat',
'deepseek_reasoner',
],
config: {
tool: (params: Record<string, any>) => {
const model = params.model || 'gpt-4o'
if (!model) {
throw new Error('No model selected')
}
const tool = getAllModelProviders()[model as ProviderId]
if (!tool) {
throw new Error(`Invalid model selected: ${model}`)
}
return tool
},
},
},
inputs: {
context: { type: 'string', description: 'Context for routing decision' },
routes: { type: 'json', description: 'Route definitions with descriptions' },
model: { type: 'string', description: 'AI model to use' },
apiKey: { type: 'string', description: 'Provider API key' },
azureEndpoint: { type: 'string', description: 'Azure OpenAI endpoint URL' },
azureApiVersion: { type: 'string', description: 'Azure API version' },
vertexProject: { type: 'string', description: 'Google Cloud project ID for Vertex AI' },
vertexLocation: { type: 'string', description: 'Google Cloud location for Vertex AI' },
vertexCredential: {
type: 'string',
description: 'Google Cloud OAuth credential ID for Vertex AI',
},
},
outputs: {
context: { type: 'string', description: 'Context used for routing' },
model: { type: 'string', description: 'Model used' },
tokens: { type: 'json', description: 'Token usage' },
cost: { type: 'json', description: 'Cost information' },
selectedRoute: { type: 'string', description: 'Selected route ID' },
selectedPath: { type: 'json', description: 'Selected routing path' },
},
}

View File

@@ -92,7 +92,7 @@ import { RDSBlock } from '@/blocks/blocks/rds'
import { RedditBlock } from '@/blocks/blocks/reddit'
import { ResendBlock } from '@/blocks/blocks/resend'
import { ResponseBlock } from '@/blocks/blocks/response'
import { RouterBlock, RouterV2Block } from '@/blocks/blocks/router'
import { RouterBlock } from '@/blocks/blocks/router'
import { RssBlock } from '@/blocks/blocks/rss'
import { S3Block } from '@/blocks/blocks/s3'
import { SalesforceBlock } from '@/blocks/blocks/salesforce'
@@ -243,7 +243,6 @@ export const registry: Record<string, BlockConfig> = {
response: ResponseBlock,
rss: RssBlock,
router: RouterBlock,
router_v2: RouterV2Block,
s3: S3Block,
salesforce: SalesforceBlock,
schedule: ScheduleBlock,

View File

@@ -78,7 +78,6 @@ export type SubBlockType =
| 'workflow-selector' // Workflow selector for agent tools
| 'workflow-input-mapper' // Dynamic workflow input mapper based on selected workflow
| 'text' // Read-only text display
| 'router-input' // Router route definitions with descriptions
/**
* Selector types that require display name hydration

View File

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

View File

@@ -55,102 +55,53 @@ import { Check, ChevronLeft, ChevronRight, Search } from 'lucide-react'
import { cn } from '@/lib/core/utils/cn'
type PopoverSize = 'sm' | 'md'
type PopoverColorScheme = 'default' | 'inverted'
type PopoverVariant = 'default' | 'secondary'
/**
* Style constants for popover components.
* Organized by component type and property.
* Shared base styles for all popover interactive items.
* Ensures consistent styling across items, folders, and back button.
*/
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
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'
/**
* Gets the active/hover classes for a popover item.
* Uses variant for secondary, otherwise colorScheme determines default vs inverted.
* Size-specific styles for popover items.
* SM: 11px text, 22px height
* MD: 13px text, 26px height
*/
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]
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 PopoverVariant = 'default' | 'secondary'
interface PopoverContextValue {
openFolder: (
id: string,
@@ -165,7 +116,6 @@ interface PopoverContextValue {
onFolderSelect: (() => void) | null
variant: PopoverVariant
size: PopoverSize
colorScheme: PopoverColorScheme
searchQuery: string
setSearchQuery: (query: string) => void
}
@@ -193,23 +143,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)
@@ -235,7 +185,7 @@ const Popover: React.FC<PopoverProps> = ({
setOnFolderSelect(null)
}, [])
const contextValue = React.useMemo<PopoverContextValue>(
const contextValue: PopoverContextValue = React.useMemo(
() => ({
openFolder,
closeFolder,
@@ -245,7 +195,6 @@ const Popover: React.FC<PopoverProps> = ({
onFolderSelect,
variant,
size,
colorScheme,
searchQuery,
setSearchQuery,
}),
@@ -257,7 +206,6 @@ const Popover: React.FC<PopoverProps> = ({
onFolderSelect,
variant,
size,
colorScheme,
searchQuery,
]
)
@@ -274,6 +222,13 @@ 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
@@ -289,48 +244,74 @@ export interface PopoverContentProps
'side' | 'align' | 'sideOffset' | 'alignOffset' | 'collisionPadding'
> {
/**
* Renders content inline instead of in a portal.
* Useful inside dialogs where portals interfere with scroll locking.
* 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.
* @default false
*/
disablePortal?: boolean
/** Maximum height in pixels */
/**
* Maximum height for the popover content in pixels
*/
maxHeight?: number
/** Maximum width in pixels. Enables text truncation when set. */
/**
* Maximum width for the popover content in pixels.
* When provided, Popover will also enable default truncation for inner text and section headers.
*/
maxWidth?: number
/** Minimum width in pixels */
/**
* Minimum width for the popover content in pixels
*/
minWidth?: number
/**
* Preferred side to display
* Preferred side to display the popover
* @default 'bottom'
*/
side?: 'top' | 'right' | 'bottom' | 'left'
/**
* Alignment relative to anchor
* Alignment of the popover relative to anchor
* @default 'start'
*/
align?: 'start' | 'center' | 'end'
/** Offset from anchor. Defaults to 20px for top, 14px for other sides. */
/**
* Offset from the anchor in pixels.
* Defaults to 22px for top side (to avoid covering cursor) and 10px for other sides.
*/
sideOffset?: number
/**
* Padding from viewport edges
* Padding from viewport edges in pixels
* @default 8
*/
collisionPadding?: number
/**
* Adds border to content
* When true, adds a border to the popover content
* @default false
*/
border?: boolean
/**
* Flip to avoid viewport collisions
* When true, the popover will flip to avoid collisions with viewport edges
* @default true
*/
avoidCollisions?: boolean
}
/**
* Popover content with automatic positioning and collision detection.
* 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>
* ```
*/
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
@@ -359,10 +340,13 @@ 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 ||
@@ -375,21 +359,29 @@ 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 ((isScrollingDown && isAtBottom) || (!isScrollingDown && isAtTop)) return
// 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
}
// 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)
},
@@ -398,6 +390,7 @@ 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)
},
@@ -419,9 +412,11 @@ const PopoverContent = React.forwardRef<
onCloseAutoFocus={handleCloseAutoFocus}
{...restProps}
className={cn(
'z-[10000200] flex flex-col overflow-auto outline-none will-change-transform',
STYLES.colorScheme[colorScheme].content,
STYLES.content,
// 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.
hasUserWidthConstraint && '[&_.flex-1]:truncate [&_[data-popover-section]]:truncate',
border && 'border border-[var(--border-1)]',
className
@@ -429,6 +424,7 @@ 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`
@@ -444,7 +440,9 @@ const PopoverContent = React.forwardRef<
</PopoverPrimitive.Content>
)
if (disablePortal) return content
if (disablePortal) {
return content
}
return <PopoverPrimitive.Portal>{content}</PopoverPrimitive.Portal>
}
@@ -455,52 +453,83 @@ PopoverContent.displayName = 'PopoverContent'
export interface PopoverScrollAreaProps extends React.HTMLAttributes<HTMLDivElement> {}
/**
* Scrollable container for popover items.
* 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>
* ```
*/
const PopoverScrollArea = React.forwardRef<HTMLDivElement, PopoverScrollAreaProps>(
({ 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}
/>
)
({ 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}
/>
)
}
)
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
/** Only show when not inside any folder */
/**
* If true, this item will only show when not inside any folder
*/
rootOnly?: boolean
/** Whether this item is disabled */
/**
* Whether this item is disabled
*/
disabled?: boolean
/**
* Show checkmark when active
* Whether to show a checkmark when active
* @default false
*/
showCheck?: boolean
}
/**
* Individual popover item with hover and active states.
* 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>
* ```
*/
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 && context?.isInFolder) return null
// If rootOnly is true and we're in a folder, don't render
if (rootOnly && context?.isInFolder) {
return null
}
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (disabled) {
@@ -513,10 +542,9 @@ const PopoverItem = React.forwardRef<HTMLDivElement, PopoverItemProps>(
return (
<div
className={cn(
STYLES.itemBase,
STYLES.colorScheme[colorScheme].text,
STYLES.size[size].item,
getItemStateClasses(variant, colorScheme, !!active),
POPOVER_ITEM_BASE_CLASSES,
POPOVER_ITEM_SIZE_CLASSES[size],
active ? POPOVER_ITEM_ACTIVE_CLASSES[variant] : POPOVER_ITEM_HOVER_CLASSES[variant],
disabled && 'pointer-events-none cursor-not-allowed opacity-50',
className
)}
@@ -528,7 +556,9 @@ const PopoverItem = React.forwardRef<HTMLDivElement, PopoverItemProps>(
{...props}
>
{children}
{showCheck && active && <Check className={cn('ml-auto', STYLES.size[size].icon)} />}
{showCheck && active && (
<Check className={cn('ml-auto', POPOVER_ICON_SIZE_CLASSES[size])} />
)}
</div>
)
}
@@ -537,27 +567,46 @@ const PopoverItem = React.forwardRef<HTMLDivElement, PopoverItemProps>(
PopoverItem.displayName = 'PopoverItem'
export interface PopoverSectionProps extends React.HTMLAttributes<HTMLDivElement> {
/** Only show when not inside any folder */
/**
* If true, this section will only show when not inside any folder
*/
rootOnly?: boolean
}
/**
* Section header for grouping popover items.
* 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>
* ```
*/
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 && context?.isInFolder) return null
// If rootOnly is true and we're in a folder, don't render
if (rootOnly && context?.isInFolder) {
return null
}
return (
<div
className={cn(
'mt-[6px] min-w-0 font-base first:mt-0 first:pt-0',
STYLES.colorScheme[colorScheme].section,
STYLES.size[size].section,
'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],
className
)}
data-popover-section=''
@@ -571,46 +620,76 @@ const PopoverSection = React.forwardRef<HTMLDivElement, PopoverSectionProps>(
PopoverSection.displayName = 'PopoverSection'
export interface PopoverFolderProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'children'> {
/** Unique folder identifier */
/**
* Unique identifier for the folder
*/
id: string
/** Display title */
/**
* Display title for the folder
*/
title: string
/** Icon before title */
/**
* Icon to display before the title
*/
icon?: React.ReactNode
/** Callback when folder opens (for lazy loading) */
/**
* Function to call when folder is opened (for lazy loading)
*/
onOpen?: () => void | Promise<void>
/** Callback when folder title is selected from within folder view */
/**
* Function to call when the folder title is selected (from within the folder view)
*/
onSelect?: () => void
/** Folder contents */
/**
* Children to render when folder is open
*/
children?: React.ReactNode
/** Whether currently active/selected */
/**
* Whether this item is currently active/selected
*/
active?: boolean
}
/**
* Expandable folder that shows nested content.
* 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>
* ```
*/
const PopoverFolder = React.forwardRef<HTMLDivElement, PopoverFolderProps>(
({ className, id, title, icon, onOpen, onSelect, children, active, ...props }, ref) => {
const { openFolder, currentFolder, isInFolder, variant, size, colorScheme } =
usePopoverContext()
const { openFolder, currentFolder, isInFolder, variant, size } = usePopoverContext()
if (isInFolder && currentFolder !== id) return null
if (currentFolder === id) return <>{children}</>
// Don't render if we're in a different folder
if (isInFolder && currentFolder !== id) {
return null
}
// 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(
STYLES.itemBase,
STYLES.colorScheme[colorScheme].text,
STYLES.size[size].item,
getItemStateClasses(variant, colorScheme, !!active),
POPOVER_ITEM_BASE_CLASSES,
POPOVER_ITEM_SIZE_CLASSES[size],
active ? POPOVER_ITEM_ACTIVE_CLASSES[variant] : POPOVER_ITEM_HOVER_CLASSES[variant],
className
)}
role='menuitem'
@@ -621,7 +700,7 @@ const PopoverFolder = React.forwardRef<HTMLDivElement, PopoverFolderProps>(
>
{icon}
<span className='flex-1'>{title}</span>
<ChevronRight className={STYLES.size[size].icon} />
<ChevronRight className={POPOVER_ICON_SIZE_CLASSES[size]} />
</div>
)
}
@@ -630,23 +709,42 @@ const PopoverFolder = React.forwardRef<HTMLDivElement, PopoverFolderProps>(
PopoverFolder.displayName = 'PopoverFolder'
export interface PopoverBackButtonProps extends React.HTMLAttributes<HTMLDivElement> {
/** Ref callback for folder title element */
/**
* Ref callback for the folder title element (when selectable)
*/
folderTitleRef?: (el: HTMLElement | null) => void
/** Whether folder title is active/selected */
/**
* Whether the folder title is currently active/selected
*/
folderTitleActive?: boolean
/** Callback on folder title mouse enter */
/**
* Callback when mouse enters the folder title
*/
onFolderTitleMouseEnter?: () => void
}
/**
* Back button shown inside folders. Hidden at root level.
* Back button component that appears when inside a folder.
* Automatically hidden when at root level.
*
* @example
* ```tsx
* <Popover>
* <PopoverBackButton />
* <PopoverContent>
* // content
* </PopoverContent>
* </Popover>
* ```
*/
const PopoverBackButton = React.forwardRef<HTMLDivElement, PopoverBackButtonProps>(
({ className, folderTitleRef, folderTitleActive, onFolderTitleMouseEnter, ...props }, ref) => {
const { isInFolder, closeFolder, folderTitle, onFolderSelect, variant, size, colorScheme } =
const { isInFolder, closeFolder, folderTitle, onFolderSelect, variant, size } =
usePopoverContext()
if (!isInFolder) return null
if (!isInFolder) {
return null
}
return (
<div className='flex flex-col'>
@@ -654,27 +752,28 @@ const PopoverBackButton = React.forwardRef<HTMLDivElement, PopoverBackButtonProp
ref={ref}
className={cn(
'peer',
STYLES.itemBase,
STYLES.colorScheme[colorScheme].text,
STYLES.size[size].item,
getItemStateClasses(variant, colorScheme, false),
POPOVER_ITEM_BASE_CLASSES,
POPOVER_ITEM_SIZE_CLASSES[size],
POPOVER_ITEM_HOVER_CLASSES[variant],
className
)}
role='button'
onClick={closeFolder}
{...props}
>
<ChevronLeft className={STYLES.size[size].icon} />
<ChevronLeft className={POPOVER_ICON_SIZE_CLASSES[size]} />
<span>Back</span>
</div>
{folderTitle && onFolderSelect && (
<div
ref={folderTitleRef}
className={cn(
STYLES.itemBase,
STYLES.colorScheme[colorScheme].text,
STYLES.size[size].item,
getItemStateClasses(variant, colorScheme, !!folderTitleActive),
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
'peer-hover:!bg-transparent'
)}
role='button'
@@ -690,9 +789,8 @@ const PopoverBackButton = React.forwardRef<HTMLDivElement, PopoverBackButtonProp
{folderTitle && !onFolderSelect && (
<div
className={cn(
'font-base',
STYLES.colorScheme[colorScheme].section,
STYLES.size[size].section
'font-base text-[var(--text-tertiary)] dark:text-[var(--text-tertiary)]',
POPOVER_SECTION_SIZE_CLASSES[size]
)}
>
{folderTitle}
@@ -707,20 +805,43 @@ PopoverBackButton.displayName = 'PopoverBackButton'
export interface PopoverSearchProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* Placeholder text
* Placeholder text for the search input
* @default 'Search...'
*/
placeholder?: string
/** Callback when query changes */
/**
* Callback when search query changes
*/
onValueChange?: (value: string) => void
}
/**
* Search input for filtering popover items.
* 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>
* ```
*/
const PopoverSearch = React.forwardRef<HTMLDivElement, PopoverSearchProps>(
({ className, placeholder = 'Search...', onValueChange, ...props }, ref) => {
const { searchQuery, setSearchQuery, size, colorScheme } = usePopoverContext()
const { searchQuery, setSearchQuery, size } = usePopoverContext()
const inputRef = React.useRef<HTMLInputElement>(null)
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -736,19 +857,18 @@ const PopoverSearch = React.forwardRef<HTMLDivElement, PopoverSearchProps>(
}, [setSearchQuery, onValueChange])
return (
<div ref={ref} className={cn('flex items-center px-[8px] py-[6px]', className)} {...props}>
<div
ref={ref}
className={cn('flex items-center', POPOVER_SEARCH_SIZE_CLASSES[size], className)}
{...props}
>
<Search
className={cn(
'mr-2 shrink-0',
STYLES.colorScheme[colorScheme].search,
STYLES.size[size].icon
)}
className={cn('mr-2 shrink-0 text-[var(--text-muted)]', POPOVER_ICON_SIZE_CLASSES[size])}
/>
<input
ref={inputRef}
className={cn(
'w-full bg-transparent font-base focus:outline-none',
STYLES.colorScheme[colorScheme].searchInput,
'w-full bg-transparent font-base text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:outline-none',
size === 'sm' ? 'text-[11px]' : 'text-[13px]'
)}
placeholder={placeholder}
@@ -762,34 +882,6 @@ 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,
@@ -801,8 +893,7 @@ export {
PopoverFolder,
PopoverBackButton,
PopoverSearch,
PopoverDivider,
usePopoverContext,
}
export type { PopoverSize, PopoverColorScheme }
export type { PopoverSize }

View File

@@ -45,13 +45,13 @@ const Content = React.forwardRef<
collisionPadding={8}
avoidCollisions={true}
className={cn(
'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',
'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',
className
)}
{...props}
>
{props.children}
<TooltipPrimitive.Arrow className='fill-[#1b1b1b] dark:fill-[#fdfdfd]' />
<TooltipPrimitive.Arrow className='fill-black dark:fill-white' />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
))

View File

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

View File

@@ -2,7 +2,6 @@ export enum BlockType {
PARALLEL = 'parallel',
LOOP = 'loop',
ROUTER = 'router',
ROUTER_V2 = 'router_v2',
CONDITION = 'condition',
START_TRIGGER = 'start_trigger',
@@ -272,11 +271,7 @@ export function isConditionBlockType(blockType: string | undefined): boolean {
}
export function isRouterBlockType(blockType: string | undefined): boolean {
return blockType === BlockType.ROUTER || blockType === BlockType.ROUTER_V2
}
export function isRouterV2BlockType(blockType: string | undefined): boolean {
return blockType === BlockType.ROUTER_V2
return blockType === BlockType.ROUTER
}
export function isAgentBlockType(blockType: string | undefined): boolean {

View File

@@ -1,10 +1,5 @@
import { createLogger } from '@sim/logger'
import {
EDGE,
isConditionBlockType,
isRouterBlockType,
isRouterV2BlockType,
} from '@/executor/constants'
import { EDGE, isConditionBlockType, isRouterBlockType } from '@/executor/constants'
import type { DAG } from '@/executor/dag/builder'
import {
buildBranchNodeId,
@@ -24,17 +19,10 @@ interface ConditionConfig {
condition: string
}
interface RouterV2RouteConfig {
id: string
title: string
description: string
}
interface EdgeMetadata {
blockTypeMap: Map<string, string>
conditionConfigMap: Map<string, ConditionConfig[]>
routerBlockIds: Set<string>
routerV2ConfigMap: Map<string, RouterV2RouteConfig[]>
}
export class EdgeConstructor {
@@ -70,7 +58,6 @@ export class EdgeConstructor {
const blockTypeMap = new Map<string, string>()
const conditionConfigMap = new Map<string, ConditionConfig[]>()
const routerBlockIds = new Set<string>()
const routerV2ConfigMap = new Map<string, RouterV2RouteConfig[]>()
for (const block of workflow.blocks) {
const blockType = block.metadata?.id ?? ''
@@ -82,19 +69,12 @@ export class EdgeConstructor {
if (conditions) {
conditionConfigMap.set(block.id, conditions)
}
} else if (isRouterV2BlockType(blockType)) {
// Router V2 uses port-based routing with route configs
const routes = this.parseRouterV2Config(block)
if (routes) {
routerV2ConfigMap.set(block.id, routes)
}
} else if (isRouterBlockType(blockType)) {
// Legacy router uses target block IDs
routerBlockIds.add(block.id)
}
}
return { blockTypeMap, conditionConfigMap, routerBlockIds, routerV2ConfigMap }
return { blockTypeMap, conditionConfigMap, routerBlockIds }
}
private parseConditionConfig(block: any): ConditionConfig[] | null {
@@ -120,29 +100,6 @@ export class EdgeConstructor {
}
}
private parseRouterV2Config(block: any): RouterV2RouteConfig[] | null {
try {
const routesJson = block.config.params?.routes
if (typeof routesJson === 'string') {
return JSON.parse(routesJson)
}
if (Array.isArray(routesJson)) {
return routesJson
}
return null
} catch (error) {
logger.warn('Failed to parse router v2 config', {
blockId: block.id,
error: error instanceof Error ? error.message : String(error),
})
return null
}
}
private generateSourceHandle(
source: string,
target: string,
@@ -166,26 +123,6 @@ export class EdgeConstructor {
}
}
// Router V2 uses port-based routing - handle is already set from UI (router-{routeId})
// We don't modify it here, just validate it exists
if (metadata.routerV2ConfigMap.has(source)) {
// For router_v2, the sourceHandle should already be set from the UI
// If not set and not an error handle, generate based on route index
if (!handle || (!handle.startsWith(EDGE.ROUTER_PREFIX) && handle !== EDGE.ERROR)) {
const routes = metadata.routerV2ConfigMap.get(source)
if (routes && routes.length > 0) {
const edgesFromRouter = workflow.connections.filter((c) => c.source === source)
const edgeIndex = edgesFromRouter.findIndex((e) => e.target === target)
if (edgeIndex >= 0 && edgeIndex < routes.length) {
const correspondingRoute = routes[edgeIndex]
handle = `${EDGE.ROUTER_PREFIX}${correspondingRoute.id}`
}
}
}
}
// Legacy router uses target block ID
if (metadata.routerBlockIds.has(source) && handle !== EDGE.ERROR) {
handle = `${EDGE.ROUTER_PREFIX}${target}`
}

View File

@@ -4,60 +4,29 @@ import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { generateRouterPrompt, generateRouterV2Prompt } from '@/blocks/blocks/router'
import { generateRouterPrompt } from '@/blocks/blocks/router'
import type { BlockOutput } from '@/blocks/types'
import {
BlockType,
DEFAULTS,
HTTP,
isAgentBlockType,
isRouterV2BlockType,
ROUTER,
} from '@/executor/constants'
import { BlockType, DEFAULTS, HTTP, isAgentBlockType, ROUTER } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { calculateCost, getProviderFromModel } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types'
const logger = createLogger('RouterBlockHandler')
interface RouteDefinition {
id: string
title: string
value: string
}
/**
* Handler for Router blocks that dynamically select execution paths.
* Supports both legacy router (block-based) and router_v2 (port-based).
*/
export class RouterBlockHandler implements BlockHandler {
constructor(private pathTracker?: any) {}
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === BlockType.ROUTER || block.metadata?.id === BlockType.ROUTER_V2
return block.metadata?.id === BlockType.ROUTER
}
async execute(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<BlockOutput> {
const isV2 = isRouterV2BlockType(block.metadata?.id)
if (isV2) {
return this.executeV2(ctx, block, inputs)
}
return this.executeLegacy(ctx, block, inputs)
}
/**
* Execute legacy router (block-based routing).
*/
private async executeLegacy(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<BlockOutput> {
const targetBlocks = this.getTargetBlocks(ctx, block)
@@ -175,168 +144,6 @@ export class RouterBlockHandler implements BlockHandler {
}
}
/**
* Execute router v2 (port-based routing).
* Uses route definitions with descriptions instead of downstream block names.
*/
private async executeV2(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<BlockOutput> {
const routes = this.parseRoutes(inputs.routes)
if (routes.length === 0) {
throw new Error('No routes defined for router')
}
const routerConfig = {
context: inputs.context,
model: inputs.model || ROUTER.DEFAULT_MODEL,
apiKey: inputs.apiKey,
vertexProject: inputs.vertexProject,
vertexLocation: inputs.vertexLocation,
vertexCredential: inputs.vertexCredential,
}
const providerId = getProviderFromModel(routerConfig.model)
try {
const url = new URL('/api/providers', getBaseUrl())
const messages = [{ role: 'user', content: routerConfig.context }]
const systemPrompt = generateRouterV2Prompt(routerConfig.context, routes)
let finalApiKey: string | undefined = routerConfig.apiKey
if (providerId === 'vertex' && routerConfig.vertexCredential) {
finalApiKey = await this.resolveVertexCredential(routerConfig.vertexCredential)
}
const providerRequest: Record<string, any> = {
provider: providerId,
model: routerConfig.model,
systemPrompt: systemPrompt,
context: JSON.stringify(messages),
temperature: ROUTER.INFERENCE_TEMPERATURE,
apiKey: finalApiKey,
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,
}
if (providerId === 'vertex') {
providerRequest.vertexProject = routerConfig.vertexProject
providerRequest.vertexLocation = routerConfig.vertexLocation
}
if (providerId === 'azure-openai') {
providerRequest.azureEndpoint = inputs.azureEndpoint
providerRequest.azureApiVersion = inputs.azureApiVersion
}
const response = await fetch(url.toString(), {
method: 'POST',
headers: {
'Content-Type': HTTP.CONTENT_TYPE.JSON,
},
body: JSON.stringify(providerRequest),
})
if (!response.ok) {
let errorMessage = `Provider API request failed with status ${response.status}`
try {
const errorData = await response.json()
if (errorData.error) {
errorMessage = errorData.error
}
} catch (_e) {}
throw new Error(errorMessage)
}
const result = await response.json()
const chosenRouteId = result.content.trim()
const chosenRoute = routes.find((r) => r.id === chosenRouteId)
if (!chosenRoute) {
logger.error(
`Invalid routing decision. Response content: "${result.content}", available routes:`,
routes.map((r) => ({ id: r.id, title: r.title }))
)
throw new Error(`Invalid routing decision: ${chosenRouteId}`)
}
// Find the target block connected to this route's handle
const connection = ctx.workflow?.connections.find(
(conn) => conn.source === block.id && conn.sourceHandle === `router-${chosenRoute.id}`
)
const targetBlock = connection
? ctx.workflow?.blocks.find((b) => b.id === connection.target)
: null
const tokens = result.tokens || {
input: DEFAULTS.TOKENS.PROMPT,
output: DEFAULTS.TOKENS.COMPLETION,
total: DEFAULTS.TOKENS.TOTAL,
}
const cost = calculateCost(
result.model,
tokens.input || DEFAULTS.TOKENS.PROMPT,
tokens.output || DEFAULTS.TOKENS.COMPLETION,
false
)
return {
context: inputs.context,
model: result.model,
tokens: {
input: tokens.input || DEFAULTS.TOKENS.PROMPT,
output: tokens.output || DEFAULTS.TOKENS.COMPLETION,
total: tokens.total || DEFAULTS.TOKENS.TOTAL,
},
cost: {
input: cost.input,
output: cost.output,
total: cost.total,
},
selectedRoute: chosenRoute.id,
selectedPath: targetBlock
? {
blockId: targetBlock.id,
blockType: targetBlock.metadata?.id || DEFAULTS.BLOCK_TYPE,
blockTitle: targetBlock.metadata?.name || DEFAULTS.BLOCK_TITLE,
}
: {
blockId: '',
blockType: DEFAULTS.BLOCK_TYPE,
blockTitle: chosenRoute.title,
},
} as BlockOutput
} catch (error) {
logger.error('Router V2 execution failed:', error)
throw error
}
}
/**
* Parse routes from input (can be JSON string or array).
*/
private parseRoutes(input: any): RouteDefinition[] {
try {
if (typeof input === 'string') {
return JSON.parse(input)
}
if (Array.isArray(input)) {
return input
}
return []
} catch (error) {
logger.error('Failed to parse routes:', { input, error })
return []
}
}
private getTargetBlocks(ctx: ExecutionContext, block: SerializedBlock) {
return ctx.workflow?.connections
.filter((conn) => conn.source === block.id)

View File

@@ -27,7 +27,7 @@ export interface CopilotMessage {
* Chat config stored in database
*/
export interface CopilotChatConfig {
mode?: 'ask' | 'build' | 'plan' | 'superagent'
mode?: 'ask' | 'build' | 'plan'
model?: string
}
@@ -65,7 +65,7 @@ export interface SendMessageRequest {
userMessageId?: string // ID from frontend for the user message
chatId?: string
workflowId?: string
mode?: 'ask' | 'agent' | 'plan' | 'superagent'
mode?: 'ask' | 'agent' | 'plan'
model?:
| 'gpt-5-fast'
| 'gpt-5'

View File

@@ -174,9 +174,9 @@ export const env = createEnv({
KB_CONFIG_RETRY_FACTOR: z.number().optional().default(2), // Retry backoff factor
KB_CONFIG_MIN_TIMEOUT: z.number().optional().default(1000), // Min timeout in ms
KB_CONFIG_MAX_TIMEOUT: z.number().optional().default(10000), // Max timeout in ms
KB_CONFIG_CONCURRENCY_LIMIT: z.number().optional().default(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_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_DELAY_BETWEEN_DOCUMENTS: z.number().optional().default(50), // Delay between documents in ms
// Real-time Communication

View File

@@ -29,10 +29,10 @@ const TIMEOUTS = {
// Configuration for handling large documents
const LARGE_DOC_CONFIG = {
MAX_CHUNKS_PER_BATCH: 500,
MAX_EMBEDDING_BATCH: env.KB_CONFIG_BATCH_SIZE || 2000,
MAX_FILE_SIZE: 100 * 1024 * 1024,
MAX_CHUNKS_PER_DOCUMENT: 100000,
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
}
/**

View File

@@ -7,7 +7,6 @@ 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
@@ -122,29 +121,8 @@ async function callEmbeddingAPI(inputs: string[], config: EmbeddingConfig): Prom
}
/**
* 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
* Generate embeddings for multiple texts with token-aware batching
* Uses tiktoken for token counting
*/
export async function generateEmbeddings(
texts: string[],
@@ -160,35 +138,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, ${MAX_CONCURRENT_BATCHES} concurrent)`
`Split ${texts.length} texts into ${batches.length} batches (max ${MAX_TOKENS_PER_REQUEST} tokens per batch)`
)
const batchResults = await processWithConcurrency(
batches,
MAX_CONCURRENT_BATCHES,
async (batch, i) => {
const batchTokenCount = getTotalTokenCount(batch, embeddingModel)
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)
logger.info(
`Processing batch ${i + 1}/${batches.length}: ${batch.length} texts, ${batchTokenCount} tokens`
`Generated ${batchEmbeddings.length} embeddings for batch ${i + 1}/${batches.length}`
)
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
}
} catch (error) {
logger.error(`Failed to generate embeddings for batch ${i + 1}:`, error)
throw error
}
)
const allEmbeddings = batchResults.flat()
if (i + 1 < batches.length) {
await new Promise((resolve) => setTimeout(resolve, 100))
}
}
logger.info(`Successfully generated ${allEmbeddings.length} embeddings total`)

View File

@@ -121,34 +121,6 @@ export async function handleProviderChallenges(
return null
}
/**
* Handle provider-specific reachability tests that occur AFTER webhook lookup.
*
* @param webhook - The webhook record from the database
* @param body - The parsed request body
* @param requestId - Request ID for logging
* @returns NextResponse if this is a verification request, null to continue normal flow
*/
export function handleProviderReachabilityTest(
webhook: any,
body: any,
requestId: string
): NextResponse | null {
const provider = webhook?.provider
if (provider === 'grain') {
const isVerificationRequest = !body || Object.keys(body).length === 0 || !body.type
if (isVerificationRequest) {
logger.info(
`[${requestId}] Grain reachability test detected - returning 200 for webhook verification`
)
return NextResponse.json({ status: 'ok', message: 'Webhook endpoint verified' })
}
}
return null
}
export async function findWebhookAndWorkflow(
options: WebhookProcessorOptions
): Promise<{ webhook: any; workflow: any } | null> {

View File

@@ -161,49 +161,6 @@ 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
@@ -226,9 +183,6 @@ export function sanitizeWorkflowForSharing(
Object.values(sanitized.blocks).forEach((block: any) => {
if (!block?.type) return
// First, remove any malformed subBlocks that may have been created by bugs
removeMalformedSubBlocks(block)
const blockConfig = getBlock(block.type)
// Process subBlocks with config

View File

@@ -1073,16 +1073,16 @@ const sseHandlers: Record<string, SSEHandler> = {
// Integration tools: Check if auto-allowed, otherwise wait for user confirmation
// This handles tools like google_calendar_*, exa_*, etc. that aren't in the client registry
// Relevant in 'build' mode (with workflow) or 'superagent' mode (standalone)
// Only relevant if mode is 'build' (agent)
const { mode, workflowId, autoAllowedTools } = get()
if ((mode === 'build' && workflowId) || mode === 'superagent') {
if (mode === 'build' && workflowId) {
// Check if tool was NOT found in client registry (def is undefined from above)
const def = name ? getTool(name) : undefined
const inst = getClientTool(id) as any
if (!def && !inst && name) {
// Check if this tool is auto-allowed
if (autoAllowedTools.includes(name)) {
logger.info('[copilot] Integration tool auto-allowed, executing', { id, name, mode })
logger.info('[build mode] Integration tool auto-allowed, executing', { id, name })
// Auto-execute the tool
setTimeout(() => {
@@ -1090,10 +1090,9 @@ const sseHandlers: Record<string, SSEHandler> = {
}, 0)
} else {
// Integration tools stay in pending state until user confirms
logger.info('[copilot] Integration tool awaiting user confirmation', {
logger.info('[build mode] Integration tool awaiting user confirmation', {
id,
name,
mode,
})
}
}
@@ -1983,8 +1982,7 @@ export const useCopilotStore = create<CopilotStore>()(
messageId?: string
}
// Allow sending without workflowId in superagent mode
if (!workflowId && mode !== 'superagent') return
if (!workflowId) return
const abortController = new AbortController()
set({ isSendingMessage: true, error: null, abortController })
@@ -2055,8 +2053,8 @@ export const useCopilotStore = create<CopilotStore>()(
}
// Call copilot API
const apiMode: 'ask' | 'agent' | 'plan' | 'superagent' =
mode === 'ask' ? 'ask' : mode === 'plan' ? 'plan' : mode === 'superagent' ? 'superagent' : 'agent'
const apiMode: 'ask' | 'agent' | 'plan' =
mode === 'ask' ? 'ask' : mode === 'plan' ? 'plan' : 'agent'
const result = await sendStreamingMessage({
message: messageToSend,
userMessageId: userMessage.id,
@@ -2918,10 +2916,9 @@ export const useCopilotStore = create<CopilotStore>()(
},
executeIntegrationTool: async (toolCallId: string) => {
const { toolCallsById, workflowId, mode } = get()
const { toolCallsById, workflowId } = get()
const toolCall = toolCallsById[toolCallId]
// In superagent mode, workflowId is optional
if (!toolCall || (!workflowId && mode !== 'superagent')) return
if (!toolCall || !workflowId) return
const { id, name, params } = toolCall

View File

@@ -58,7 +58,7 @@ import type { CopilotChat as ApiCopilotChat } from '@/lib/copilot/api'
export type CopilotChat = ApiCopilotChat
export type CopilotMode = 'ask' | 'build' | 'plan' | 'superagent'
export type CopilotMode = 'ask' | 'build' | 'plan'
export interface CopilotState {
mode: CopilotMode

View File

@@ -5,14 +5,9 @@ import type { WorkflowState } from '../workflow/types'
const logger = createLogger('WorkflowJsonImporter')
/**
* Normalize subblock values by converting empty strings to null and filtering out invalid subblocks.
* Normalize subblock values by converting empty strings to null.
* 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> = {}
@@ -24,34 +19,6 @@ function normalizeSubblockValues(blocks: Record<string, any>): Record<string, an
const normalizedSubBlocks: Record<string, any> = {}
Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]: [string, any]) => {
// Skip subBlocks with invalid keys (literal "undefined" string)
if (subBlockId === 'undefined') {
logger.warn(`Skipping malformed subBlock with key "undefined" in block ${blockId}`)
return
}
// Skip subBlocks that are null or not objects
if (!subBlock || typeof subBlock !== 'object') {
logger.warn(`Skipping invalid subBlock ${subBlockId} in block ${blockId}: not an object`)
return
}
// Skip subBlocks with type "unknown" (malformed data)
if (subBlock.type === 'unknown') {
logger.warn(
`Skipping malformed subBlock ${subBlockId} in block ${blockId}: type is "unknown"`
)
return
}
// Skip subBlocks missing required id field
if (!subBlock.id) {
logger.warn(
`Skipping malformed subBlock ${subBlockId} in block ${blockId}: missing id field`
)
return
}
const normalizedSubBlock = { ...subBlock }
// Convert empty strings to null for consistency

View File

@@ -8,7 +8,9 @@ export interface KalshiGetBalanceResponse {
success: boolean
output: {
balance: number // In cents
portfolioValue: number // In cents
portfolioValue?: number // In cents
balanceDollars: number // Converted to dollars
portfolioValueDollars?: number // Converted to dollars
}
}
@@ -49,14 +51,16 @@ export const kalshiGetBalanceTool: ToolConfig<KalshiGetBalanceParams, KalshiGetB
handleKalshiError(data, response.status, 'get_balance')
}
const balance = data.balance ?? 0
const portfolioValue = data.portfolio_value ?? 0
const balance = data.balance || 0
const portfolioValue = data.portfolio_value
return {
success: true,
output: {
balance,
portfolioValue,
balanceDollars: balance / 100,
portfolioValueDollars: portfolioValue ? portfolioValue / 100 : undefined,
},
}
},
@@ -64,5 +68,7 @@ export const kalshiGetBalanceTool: ToolConfig<KalshiGetBalanceParams, KalshiGetB
outputs: {
balance: { type: 'number', description: 'Account balance in cents' },
portfolioValue: { type: 'number', description: 'Portfolio value in cents' },
balanceDollars: { type: 'number', description: 'Account balance in dollars' },
portfolioValueDollars: { type: 'number', description: 'Portfolio value in dollars' },
},
}

View File

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