Compare commits

..

6 Commits

Author SHA1 Message Date
Vikhyath Mondreti
7544557841 feat(langsmith): add langsmith tools for logging, output selector use tool-aware listing 2026-01-14 13:59:39 -08:00
Vikhyath Mondreti
3f1dccd6aa fix(batch-add): on batch add persist subblock values (#2819)
* fix(batch-add): on batch add persist subblock values

* consolidate merge subblock

* consolidate more code
2026-01-14 13:01:26 -08:00
Waleed
468ec2ea81 fix(terminal-colors): change algo to compute colors based on hash of execution id and pointer from bottom (#2817) 2026-01-14 12:06:02 -08:00
Waleed
d7e0d9ba43 fix(i18n): update translations action to run once per week on sunday (#2816) 2026-01-14 11:23:26 -08:00
Waleed
51477c12cc fix(terminal): pop all entries from a single execution when the limit is exceeded (#2815) 2026-01-14 11:05:38 -08:00
Waleed
a3535639f1 fix(copilot): rewrote user input popover to optimize UX (#2814)
* fix(copilot): rewrote user input popover to optimize UX

* cleanup

* make keyboard and moues share state

* escape goes one level up on slash popover
2026-01-14 11:04:53 -08:00
48 changed files with 2666 additions and 2215 deletions

View File

@@ -577,6 +577,17 @@ export const ServiceBlock: BlockConfig = {
See the `/add-trigger` skill for creating triggers.
## Icon Requirement
If the icon doesn't already exist in `@/components/icons.tsx`, **do NOT search for it yourself**. After completing the block, ask the user to provide the SVG:
```
The block is complete, but I need an icon for {Service}.
Please provide the SVG and I'll convert it to a React component.
You can usually find this in the service's brand/press kit page, or copy it from their website.
```
## Checklist Before Finishing
- [ ] All subBlocks have `id`, `title` (except switch), and `type`
@@ -588,4 +599,5 @@ See the `/add-trigger` skill for creating triggers.
- [ ] Tools.config.tool returns correct tool ID
- [ ] Outputs match tool outputs
- [ ] Block registered in registry.ts
- [ ] If icon missing: asked user to provide SVG
- [ ] If triggers exist: `triggers` config set, trigger subBlocks spread

View File

@@ -226,17 +226,26 @@ export function {Service}Icon(props: SVGProps<SVGSVGElement>) {
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
{/* SVG paths from brand assets */}
{/* SVG paths from user-provided SVG */}
</svg>
)
}
```
### Finding Icons
1. Check the service's brand/press kit page
2. Download SVG logo
3. Convert to React component
4. Ensure it accepts and spreads props
### Getting Icons
**Do NOT search for icons yourself.** At the end of implementation, ask the user to provide the SVG:
```
I've completed the integration. Before I can add the icon, please provide the SVG for {Service}.
You can usually find this in the service's brand/press kit page, or copy it from their website.
Paste the SVG code here and I'll convert it to a React component.
```
Once the user provides the SVG:
1. Extract the SVG paths/content
2. Create a React component that spreads props
3. Ensure viewBox is preserved from the original SVG
## Step 5: Create Triggers (Optional)
@@ -405,6 +414,7 @@ If creating V2 versions (API-aligned outputs):
- [ ] If triggers: spread trigger subBlocks with `getTrigger()`
### Icon
- [ ] Asked user to provide SVG
- [ ] Added icon to `components/icons.tsx`
- [ ] Icon spreads props correctly
@@ -433,11 +443,18 @@ You: I'll add the Stripe integration. Let me:
1. First, research the Stripe API using Context7
2. Create the tools for key operations (payments, subscriptions, etc.)
3. Create the block with operation dropdown
4. Add the Stripe icon
5. Register everything
6. Generate docs
4. Register everything
5. Generate docs
6. Ask you for the Stripe icon SVG
[Proceed with implementation...]
[After completing steps 1-5...]
I've completed the Stripe integration. Before I can add the icon, please provide the SVG for Stripe.
You can usually find this in the service's brand/press kit page, or copy it from their website.
Paste the SVG code here and I'll convert it to a React component.
```
## Common Gotchas

View File

@@ -1,11 +1,10 @@
name: 'Auto-translate Documentation'
on:
push:
branches: [ staging ]
paths:
- 'apps/docs/content/docs/en/**'
- 'apps/docs/i18n.json'
schedule:
# Run every Sunday at midnight UTC
- cron: '0 0 * * 0'
workflow_dispatch: # Allow manual triggers
permissions:
contents: write
@@ -20,6 +19,7 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: staging
token: ${{ secrets.GH_PAT }}
fetch-depth: 0
@@ -68,12 +68,11 @@ jobs:
title: "feat(i18n): update translations"
body: |
## Summary
Automated translation updates triggered by changes to documentation.
This PR was automatically created after content changes were made, updating translations for all supported languages using Lingo.dev AI translation engine.
**Original trigger**: ${{ github.event.head_commit.message }}
**Commit**: ${{ github.sha }}
Automated weekly translation updates for documentation.
This PR was automatically created by the scheduled weekly i18n workflow, updating translations for all supported languages using Lingo.dev AI translation engine.
**Triggered**: Weekly scheduled run
**Workflow**: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
## Type of Change
@@ -107,7 +106,7 @@ jobs:
## Screenshots/Videos
<!-- Translation changes are text-based - no visual changes expected -->
<!-- Reviewers should check the documentation site renders correctly for all languages -->
branch: auto-translate/staging-merge-${{ github.run_id }}
branch: auto-translate/weekly-${{ github.run_id }}
base: staging
labels: |
i18n
@@ -145,6 +144,8 @@ jobs:
bun install --frozen-lockfile
- name: Build documentation to verify translations
env:
DATABASE_URL: postgresql://dummy:dummy@localhost:5432/dummy
run: |
cd apps/docs
bun run build
@@ -153,7 +154,7 @@ jobs:
run: |
cd apps/docs
echo "## Translation Status Report" >> $GITHUB_STEP_SUMMARY
echo "**Triggered by merge to staging branch**" >> $GITHUB_STEP_SUMMARY
echo "**Weekly scheduled translation run**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
en_count=$(find content/docs/en -name "*.mdx" | wc -l)

View File

@@ -1853,6 +1853,14 @@ export function LinearIcon(props: React.SVGProps<SVGSVGElement>) {
)
}
export function LangsmithIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'>
<circle cx='12' cy='12' r='10' />
</svg>
)
}
export function LemlistIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 180 181' fill='none'>

View File

@@ -55,6 +55,7 @@ import {
JiraIcon,
JiraServiceManagementIcon,
KalshiIcon,
LangsmithIcon,
LemlistIcon,
LinearIcon,
LinkedInIcon,
@@ -180,6 +181,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
jira_service_management: JiraServiceManagementIcon,
kalshi: KalshiIcon,
knowledge: PackageSearchIcon,
langsmith: LangsmithIcon,
lemlist: LemlistIcon,
linear: LinearIcon,
linkedin: LinkedInIcon,
@@ -231,7 +233,6 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
supabase: SupabaseIcon,
tavily: TavilyIcon,
telegram: TelegramIcon,
thinking: BrainIcon,
tinybird: TinybirdIcon,
translate: TranslateIcon,
trello: TrelloIcon,

View File

@@ -0,0 +1,59 @@
---
title: LangSmith
description: Forward workflow runs to LangSmith for observability
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="langsmith"
color="#1B5DFF"
/>
## Usage Instructions
Send run data to LangSmith to trace executions, attach metadata, and monitor workflow performance.
## Tools
### `langsmith_create_run`
Forward a single run to LangSmith for ingestion.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | LangSmith API key |
| `runId` | string | Yes | Unique run identifier |
| `name` | string | Yes | Run name |
| `runType` | string | Yes | Run type \(tool, chain, llm, retriever, embedding, prompt, parser\) |
| `startTime` | string | Yes | Run start time in ISO-8601 format |
| `endTime` | string | No | Run end time in ISO-8601 format |
#### Output
This tool does not produce any outputs.
### `langsmith_create_runs_batch`
Forward multiple runs to LangSmith in a single batch.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | LangSmith API key |
| `post` | json | No | Array of new runs to ingest |
| `patch` | json | No | Array of runs to update/patch |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `accepted` | boolean | Whether the batch was accepted for ingestion |
| `runIds` | array | Run identifiers provided in the request |

View File

@@ -52,6 +52,7 @@
"jira_service_management",
"kalshi",
"knowledge",
"langsmith",
"lemlist",
"linear",
"linkedin",
@@ -103,7 +104,6 @@
"supabase",
"tavily",
"telegram",
"thinking",
"tinybird",
"translate",
"trello",

View File

@@ -63,8 +63,3 @@ Execute SQL queries against Tinybird Pipes and Data Sources using the Query API.
| `statistics` | json | Query execution statistics - elapsed time, rows read, bytes read \(only available with FORMAT JSON\) |
## Notes
- Category: `tools`
- Type: `tinybird`

View File

@@ -8,6 +8,7 @@ import {
extractFieldsFromSchema,
parseResponseFormatSafely,
} from '@/lib/core/utils/response-format'
import { getToolOutputs } from '@/lib/workflows/blocks/block-outputs'
import { getBlock } from '@/blocks'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
@@ -128,6 +129,10 @@ export function OutputSelect({
? baselineWorkflow.blocks?.[block.id]?.subBlocks?.responseFormat?.value
: subBlockValues?.[block.id]?.responseFormat
const responseFormat = parseResponseFormatSafely(responseFormatValue, block.id)
const operationValue =
shouldUseBaseline && baselineWorkflow
? baselineWorkflow.blocks?.[block.id]?.subBlocks?.operation?.value
: subBlockValues?.[block.id]?.operation
let outputsToProcess: Record<string, unknown> = {}
@@ -141,7 +146,12 @@ export function OutputSelect({
outputsToProcess = blockConfig?.outputs || {}
}
} else {
outputsToProcess = blockConfig?.outputs || {}
const toolOutputs =
blockConfig && typeof operationValue === 'string'
? getToolOutputs(blockConfig, operationValue)
: {}
outputsToProcess =
Object.keys(toolOutputs).length > 0 ? toolOutputs : blockConfig?.outputs || {}
}
if (Object.keys(outputsToProcess).length === 0) return

View File

@@ -93,14 +93,10 @@ function calculateAdaptiveDelay(displayedLength: number, totalLength: number): n
*/
export const SmoothStreamingText = memo(
({ content, isStreaming }: SmoothStreamingTextProps) => {
// Initialize with full content when not streaming to avoid flash on page load
const [displayedContent, setDisplayedContent] = useState(() =>
isStreaming ? '' : content
)
const [displayedContent, setDisplayedContent] = useState('')
const contentRef = useRef(content)
const rafRef = useRef<number | null>(null)
// Initialize index based on streaming state
const indexRef = useRef(isStreaming ? 0 : content.length)
const indexRef = useRef(0)
const lastFrameTimeRef = useRef<number>(0)
const isAnimatingRef = useRef(false)

View File

@@ -46,16 +46,12 @@ interface SmoothThinkingTextProps {
*/
const SmoothThinkingText = memo(
({ content, isStreaming }: SmoothThinkingTextProps) => {
// Initialize with full content when not streaming to avoid flash on page load
const [displayedContent, setDisplayedContent] = useState(() =>
isStreaming ? '' : content
)
const [displayedContent, setDisplayedContent] = useState('')
const [showGradient, setShowGradient] = useState(false)
const contentRef = useRef(content)
const textRef = useRef<HTMLDivElement>(null)
const rafRef = useRef<number | null>(null)
// Initialize index based on streaming state
const indexRef = useRef(isStreaming ? 0 : content.length)
const indexRef = useRef(0)
const lastFrameTimeRef = useRef<number>(0)
const isAnimatingRef = useRef(false)

View File

@@ -8,7 +8,6 @@ import { Button, Code, getCodeEditorProps, highlight, languages } from '@/compon
import { ClientToolCallState } from '@/lib/copilot/tools/client/base-tool'
import { getClientTool } from '@/lib/copilot/tools/client/manager'
import { getRegisteredTools } from '@/lib/copilot/tools/client/registry'
// Initialize all tool UI configs
import '@/lib/copilot/tools/client/init-tool-configs'
import {
getSubagentLabels as getSubagentLabelsFromConfig,

View File

@@ -1,6 +1,6 @@
export { AttachedFilesDisplay } from './attached-files-display/attached-files-display'
export { ContextPills } from './context-pills/context-pills'
export { MentionMenu } from './mention-menu/mention-menu'
export { type MentionFolderNav, MentionMenu } from './mention-menu/mention-menu'
export { ModeSelector } from './mode-selector/mode-selector'
export { ModelSelector } from './model-selector/model-selector'
export { SlashMenu } from './slash-menu/slash-menu'
export { type SlashFolderNav, SlashMenu } from './slash-menu/slash-menu'

View File

@@ -0,0 +1,151 @@
'use client'
import type { ComponentType, ReactNode, SVGProps } from 'react'
import { PopoverItem } from '@/components/emcn'
import { formatCompactTimestamp } from '@/lib/core/utils/formatting'
import {
FOLDER_CONFIGS,
MENU_STATE_TEXT_CLASSES,
type MentionFolderId,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
const ICON_CONTAINER =
'relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
export function BlockIcon({
bgColor,
Icon,
}: {
bgColor?: string
Icon?: ComponentType<SVGProps<SVGSVGElement>>
}) {
return (
<div className={ICON_CONTAINER} style={{ background: bgColor || '#6B7280' }}>
{Icon && <Icon className='!h-[10px] !w-[10px] !text-white' />}
</div>
)
}
export function WorkflowColorDot({ color }: { color?: string }) {
return <div className={ICON_CONTAINER} style={{ backgroundColor: color || '#3972F6' }} />
}
interface FolderContentProps {
/** Folder ID to render content for */
folderId: MentionFolderId
/** Items to render (already filtered) */
items: any[]
/** Whether data is loading */
isLoading: boolean
/** Current search query (for determining empty vs no-match message) */
currentQuery: string
/** Currently active item index (for keyboard navigation) */
activeIndex: number
/** Callback when an item is clicked */
onItemClick: (item: any) => void
}
export function renderItemIcon(folderId: MentionFolderId, item: any): ReactNode {
switch (folderId) {
case 'workflows':
return <WorkflowColorDot color={item.color} />
case 'blocks':
case 'workflow-blocks':
return <BlockIcon bgColor={item.bgColor} Icon={item.iconComponent} />
default:
return null
}
}
function renderItemSuffix(folderId: MentionFolderId, item: any): ReactNode {
switch (folderId) {
case 'templates':
return <span className='text-[10px] text-[var(--text-muted)]'>{item.stars}</span>
case 'logs':
return (
<>
<span className='text-[10px] text-[var(--text-tertiary)]'>·</span>
<span className='whitespace-nowrap text-[10px]'>
{formatCompactTimestamp(item.createdAt)}
</span>
<span className='text-[10px] text-[var(--text-tertiary)]'>·</span>
<span className='text-[10px] capitalize'>{(item.trigger || 'manual').toLowerCase()}</span>
</>
)
default:
return null
}
}
export function FolderContent({
folderId,
items,
isLoading,
currentQuery,
activeIndex,
onItemClick,
}: FolderContentProps) {
const config = FOLDER_CONFIGS[folderId]
if (isLoading) {
return <div className={MENU_STATE_TEXT_CLASSES}>Loading...</div>
}
if (items.length === 0) {
return (
<div className={MENU_STATE_TEXT_CLASSES}>
{currentQuery ? config.noMatchMessage : config.emptyMessage}
</div>
)
}
return (
<>
{items.map((item, index) => (
<PopoverItem
key={config.getId(item)}
onClick={() => onItemClick(item)}
data-idx={index}
active={index === activeIndex}
>
{renderItemIcon(folderId, item)}
<span className={folderId === 'logs' ? 'min-w-0 flex-1 truncate' : 'truncate'}>
{config.getLabel(item)}
</span>
{renderItemSuffix(folderId, item)}
</PopoverItem>
))}
</>
)
}
export function FolderPreviewContent({
folderId,
items,
isLoading,
onItemClick,
}: Omit<FolderContentProps, 'currentQuery' | 'activeIndex'>) {
const config = FOLDER_CONFIGS[folderId]
if (isLoading) {
return <div className={MENU_STATE_TEXT_CLASSES}>Loading...</div>
}
if (items.length === 0) {
return <div className={MENU_STATE_TEXT_CLASSES}>{config.emptyMessage}</div>
}
return (
<>
{items.map((item) => (
<PopoverItem key={config.getId(item)} onClick={() => onItemClick(item)}>
{renderItemIcon(folderId, item)}
<span className={folderId === 'logs' ? 'min-w-0 flex-1 truncate' : 'truncate'}>
{config.getLabel(item)}
</span>
{renderItemSuffix(folderId, item)}
</PopoverItem>
))}
</>
)
}

View File

@@ -1,6 +1,6 @@
'use client'
import { useMemo } from 'react'
import { useCallback, useEffect, useMemo } from 'react'
import {
Popover,
PopoverAnchor,
@@ -9,47 +9,43 @@ import {
PopoverFolder,
PopoverItem,
PopoverScrollArea,
usePopoverContext,
} from '@/components/emcn'
import type { useMentionData } from '../../hooks/use-mention-data'
import type { useMentionMenu } from '../../hooks/use-mention-menu'
function formatTimestamp(iso: string): string {
try {
const d = new Date(iso)
const mm = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
const hh = String(d.getHours()).padStart(2, '0')
const min = String(d.getMinutes()).padStart(2, '0')
return `${mm}-${dd} ${hh}:${min}`
} catch {
return iso
}
}
const STATE_TEXT_CLASSES = 'px-[8px] py-[8px] text-[12px] text-[var(--text-muted)]'
const LoadingState = () => <div className={STATE_TEXT_CLASSES}>Loading...</div>
const EmptyState = ({ message }: { message: string }) => (
<div className={STATE_TEXT_CLASSES}>{message}</div>
)
import { formatCompactTimestamp } from '@/lib/core/utils/formatting'
import {
FOLDER_CONFIGS,
FOLDER_ORDER,
MENU_STATE_TEXT_CLASSES,
type MentionCategory,
type MentionFolderId,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
import {
useCaretViewport,
type useMentionData,
type useMentionMenu,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks'
import {
getFolderData as getFolderDataUtil,
getFolderEnsureLoaded as getFolderEnsureLoadedUtil,
getFolderLoading as getFolderLoadingUtil,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
import { FolderContent, FolderPreviewContent, renderItemIcon } from './folder-content'
interface AggregatedItem {
id: string
label: string
category:
| 'chats'
| 'workflows'
| 'knowledge'
| 'blocks'
| 'workflow-blocks'
| 'templates'
| 'logs'
| 'docs'
category: MentionCategory
data: any
icon?: React.ReactNode
}
export interface MentionFolderNav {
isInFolder: boolean
currentFolder: string | null
openFolder: (id: string, title: string) => void
closeFolder: () => void
}
interface MentionMenuProps {
mentionMenu: ReturnType<typeof useMentionMenu>
mentionData: ReturnType<typeof useMentionData>
@@ -64,170 +60,124 @@ interface MentionMenuProps {
insertLogMention: (log: any) => void
insertDocsMention: () => void
}
onFolderNavChange?: (nav: MentionFolderNav) => void
}
export function MentionMenu({
type InsertHandlerMap = Record<MentionFolderId, (item: any) => void>
function MentionMenuContent({
mentionMenu,
mentionData,
message,
insertHandlers,
onFolderNavChange,
}: MentionMenuProps) {
const { currentFolder, openFolder, closeFolder } = usePopoverContext()
const {
mentionMenuRef,
menuListRef,
getActiveMentionQueryAtPosition,
getCaretPos,
submenuActiveIndex,
mentionActiveIndex,
openSubmenuFor,
setOpenSubmenuFor,
setSubmenuActiveIndex,
} = mentionMenu
const {
insertPastChatMention,
insertWorkflowMention,
insertKnowledgeMention,
insertBlockMention,
insertWorkflowBlockMention,
insertTemplateMention,
insertLogMention,
insertDocsMention,
} = insertHandlers
/**
* Get the current query string after @
*/
const currentQuery = useMemo(() => {
const caretPos = getCaretPos()
const active = getActiveMentionQueryAtPosition(caretPos, message)
return active?.query.trim().toLowerCase() || ''
}, [message, getCaretPos, getActiveMentionQueryAtPosition])
/**
* Collect and filter all available items based on query
*/
const isInFolder = currentFolder !== null
const showAggregatedView = currentQuery.length > 0
const isInFolderNavigationMode = !isInFolder && !showAggregatedView
useEffect(() => {
setSubmenuActiveIndex(0)
}, [isInFolder, setSubmenuActiveIndex])
useEffect(() => {
if (onFolderNavChange) {
onFolderNavChange({
isInFolder,
currentFolder,
openFolder,
closeFolder,
})
}
}, [onFolderNavChange, isInFolder, currentFolder, openFolder, closeFolder])
const insertHandlerMap = useMemo(
(): InsertHandlerMap => ({
chats: insertHandlers.insertPastChatMention,
workflows: insertHandlers.insertWorkflowMention,
knowledge: insertHandlers.insertKnowledgeMention,
blocks: insertHandlers.insertBlockMention,
'workflow-blocks': insertHandlers.insertWorkflowBlockMention,
templates: insertHandlers.insertTemplateMention,
logs: insertHandlers.insertLogMention,
}),
[insertHandlers]
)
const getFolderData = useCallback(
(folderId: MentionFolderId) => getFolderDataUtil(mentionData, folderId),
[mentionData]
)
const getFolderLoading = useCallback(
(folderId: MentionFolderId) => getFolderLoadingUtil(mentionData, folderId),
[mentionData]
)
const getEnsureLoaded = useCallback(
(folderId: MentionFolderId) => getFolderEnsureLoadedUtil(mentionData, folderId),
[mentionData]
)
const filterFolderItems = useCallback(
(folderId: MentionFolderId, query: string): any[] => {
const config = FOLDER_CONFIGS[folderId]
const items = getFolderData(folderId)
if (!query) return items
const q = query.toLowerCase()
return items.filter((item) => config.filterFn(item, q))
},
[getFolderData]
)
const getFilteredFolderItems = useCallback(
(folderId: MentionFolderId): any[] => {
return isInFolder ? filterFolderItems(folderId, currentQuery) : getFolderData(folderId)
},
[isInFolder, currentQuery, filterFolderItems, getFolderData]
)
const filteredAggregatedItems = useMemo(() => {
if (!currentQuery) return []
const items: AggregatedItem[] = []
const q = currentQuery.toLowerCase()
// Chats
mentionData.pastChats.forEach((chat) => {
const label = chat.title || 'New Chat'
if (label.toLowerCase().includes(currentQuery)) {
items.push({
id: `chat-${chat.id}`,
label,
category: 'chats',
data: chat,
})
}
})
for (const folderId of FOLDER_ORDER) {
const config = FOLDER_CONFIGS[folderId]
const folderData = getFolderData(folderId)
// Workflows
mentionData.workflows.forEach((wf) => {
const label = wf.name || 'Untitled Workflow'
if (label.toLowerCase().includes(currentQuery)) {
items.push({
id: `workflow-${wf.id}`,
label,
category: 'workflows',
data: wf,
icon: (
<div
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ backgroundColor: wf.color || '#3972F6' }}
/>
),
})
}
})
folderData.forEach((item) => {
if (config.filterFn(item, q)) {
items.push({
id: `${folderId}-${config.getId(item)}`,
label: config.getLabel(item),
category: folderId as MentionCategory,
data: item,
icon: renderItemIcon(folderId, item),
})
}
})
}
// Knowledge bases
mentionData.knowledgeBases.forEach((kb) => {
const label = kb.name || 'Untitled'
if (label.toLowerCase().includes(currentQuery)) {
items.push({
id: `knowledge-${kb.id}`,
label,
category: 'knowledge',
data: kb,
})
}
})
// Blocks
mentionData.blocksList.forEach((blk) => {
const label = blk.name || blk.id
if (label.toLowerCase().includes(currentQuery)) {
const Icon = blk.iconComponent
items.push({
id: `block-${blk.id}`,
label,
category: 'blocks',
data: blk,
icon: (
<div
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ background: blk.bgColor || '#6B7280' }}
>
{Icon && <Icon className='!h-[10px] !w-[10px] text-white' />}
</div>
),
})
}
})
// Workflow blocks
mentionData.workflowBlocks.forEach((blk) => {
const label = blk.name || blk.id
if (label.toLowerCase().includes(currentQuery)) {
const Icon = blk.iconComponent
items.push({
id: `workflow-block-${blk.id}`,
label,
category: 'workflow-blocks',
data: blk,
icon: (
<div
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ background: blk.bgColor || '#6B7280' }}
>
{Icon && <Icon className='!h-[10px] !w-[10px] text-white' />}
</div>
),
})
}
})
// Templates
mentionData.templatesList.forEach((tpl) => {
const label = tpl.name
if (label.toLowerCase().includes(currentQuery)) {
items.push({
id: `template-${tpl.id}`,
label,
category: 'templates',
data: tpl,
})
}
})
// Logs
mentionData.logsList.forEach((log) => {
const label = log.workflowName
if (label.toLowerCase().includes(currentQuery)) {
items.push({
id: `log-${log.id}`,
label,
category: 'logs',
data: log,
})
}
})
// Docs
if ('docs'.includes(currentQuery)) {
if ('docs'.includes(q)) {
items.push({
id: 'docs',
label: 'Docs',
@@ -237,107 +187,114 @@ export function MentionMenu({
}
return items
}, [currentQuery, mentionData])
}, [currentQuery, getFolderData])
/**
* Handle click on aggregated item
*/
const handleAggregatedItemClick = (item: AggregatedItem) => {
switch (item.category) {
case 'chats':
insertPastChatMention(item.data)
break
case 'workflows':
insertWorkflowMention(item.data)
break
case 'knowledge':
insertKnowledgeMention(item.data)
break
case 'blocks':
insertBlockMention(item.data)
break
case 'workflow-blocks':
insertWorkflowBlockMention(item.data)
break
case 'templates':
insertTemplateMention(item.data)
break
case 'logs':
insertLogMention(item.data)
break
case 'docs':
insertDocsMention()
break
}
}
// Open state derived directly from mention menu
const open = !!mentionMenu.showMentionMenu
// Show filtered aggregated view when there's a query
const showAggregatedView = currentQuery.length > 0
// Folder order for keyboard navigation - matches render order
const FOLDER_ORDER = [
'Chats', // 0
'Workflows', // 1
'Knowledge', // 2
'Blocks', // 3
'Workflow Blocks', // 4
'Templates', // 5
'Logs', // 6
'Docs', // 7
] as const
const isInFolderNavigationMode = !openSubmenuFor && !showAggregatedView
const textareaEl = mentionMenu.textareaRef.current
if (!textareaEl) return null
const caretPos = getCaretPos()
const textareaRect = textareaEl.getBoundingClientRect()
const style = window.getComputedStyle(textareaEl)
const mirrorDiv = document.createElement('div')
mirrorDiv.style.position = 'absolute'
mirrorDiv.style.visibility = 'hidden'
mirrorDiv.style.whiteSpace = 'pre-wrap'
mirrorDiv.style.wordWrap = 'break-word'
mirrorDiv.style.font = style.font
mirrorDiv.style.padding = style.padding
mirrorDiv.style.border = style.border
mirrorDiv.style.width = style.width
mirrorDiv.style.lineHeight = style.lineHeight
mirrorDiv.style.boxSizing = style.boxSizing
mirrorDiv.style.letterSpacing = style.letterSpacing
mirrorDiv.style.textTransform = style.textTransform
mirrorDiv.style.textIndent = style.textIndent
mirrorDiv.style.textAlign = style.textAlign
mirrorDiv.textContent = message.substring(0, caretPos)
const caretMarker = document.createElement('span')
caretMarker.style.display = 'inline-block'
caretMarker.style.width = '0px'
caretMarker.style.padding = '0'
caretMarker.style.border = '0'
mirrorDiv.appendChild(caretMarker)
document.body.appendChild(mirrorDiv)
const markerRect = caretMarker.getBoundingClientRect()
const mirrorRect = mirrorDiv.getBoundingClientRect()
document.body.removeChild(mirrorDiv)
const caretViewport = {
left: textareaRect.left + (markerRect.left - mirrorRect.left) - textareaEl.scrollLeft,
top: textareaRect.top + (markerRect.top - mirrorRect.top) - textareaEl.scrollTop,
}
const margin = 8
const spaceBelow = window.innerHeight - caretViewport.top - margin
const side: 'top' | 'bottom' = spaceBelow >= caretViewport.top - margin ? 'bottom' : 'top'
const handleAggregatedItemClick = useCallback(
(item: AggregatedItem) => {
if (item.category === 'docs') {
insertHandlers.insertDocsMention()
return
}
const handler = insertHandlerMap[item.category as MentionFolderId]
if (handler) {
handler(item.data)
}
},
[insertHandlerMap, insertHandlers]
)
return (
<Popover open={open} onOpenChange={() => {}}>
<PopoverScrollArea ref={menuListRef} className='space-y-[2px]'>
{isInFolder ? (
<FolderContent
folderId={currentFolder as MentionFolderId}
items={getFilteredFolderItems(currentFolder as MentionFolderId)}
isLoading={getFolderLoading(currentFolder as MentionFolderId)}
currentQuery={currentQuery}
activeIndex={submenuActiveIndex}
onItemClick={insertHandlerMap[currentFolder as MentionFolderId]}
/>
) : showAggregatedView ? (
<>
{filteredAggregatedItems.length === 0 ? (
<div className={MENU_STATE_TEXT_CLASSES}>No results found</div>
) : (
filteredAggregatedItems.map((item, index) => (
<PopoverItem
key={item.id}
onClick={() => handleAggregatedItemClick(item)}
data-idx={index}
active={index === submenuActiveIndex}
>
{item.icon}
<span className='flex-1 truncate'>{item.label}</span>
{item.category === 'logs' && (
<>
<span className='text-[10px] text-[var(--text-tertiary)]'>·</span>
<span className='whitespace-nowrap text-[10px]'>
{formatCompactTimestamp(item.data.createdAt)}
</span>
</>
)}
</PopoverItem>
))
)}
</>
) : (
<>
{FOLDER_ORDER.map((folderId, folderIndex) => {
const config = FOLDER_CONFIGS[folderId]
const ensureLoaded = getEnsureLoaded(folderId)
return (
<PopoverFolder
key={folderId}
id={folderId}
title={config.title}
onOpen={() => ensureLoaded?.()}
active={isInFolderNavigationMode && mentionActiveIndex === folderIndex}
data-idx={folderIndex}
>
<FolderPreviewContent
folderId={folderId}
items={getFolderData(folderId)}
isLoading={getFolderLoading(folderId)}
onItemClick={insertHandlerMap[folderId]}
/>
</PopoverFolder>
)
})}
<PopoverItem
rootOnly
onClick={() => insertHandlers.insertDocsMention()}
active={isInFolderNavigationMode && mentionActiveIndex === FOLDER_ORDER.length}
data-idx={FOLDER_ORDER.length}
>
<span>Docs</span>
</PopoverItem>
</>
)}
</PopoverScrollArea>
)
}
export function MentionMenu({
mentionMenu,
mentionData,
message,
insertHandlers,
onFolderNavChange,
}: MentionMenuProps) {
const { mentionMenuRef, textareaRef, getCaretPos } = mentionMenu
const caretPos = getCaretPos()
const { caretViewport, side } = useCaretViewport({ textareaRef, message, caretPos })
if (!caretViewport) return null
return (
<Popover open={true} onOpenChange={() => {}}>
<PopoverAnchor asChild>
<div
style={{
@@ -357,401 +314,19 @@ export function MentionMenu({
collisionPadding={6}
maxHeight={360}
className='pointer-events-auto'
style={{
width: `224px`,
}}
style={{ width: '224px' }}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
onMouseDown={(e) => e.preventDefault()}
>
<PopoverBackButton onClick={() => setOpenSubmenuFor(null)} />
<PopoverScrollArea ref={menuListRef} className='space-y-[2px]'>
{openSubmenuFor ? (
// Submenu view - showing contents of a specific folder
<>
{openSubmenuFor === 'Chats' && (
<>
{mentionData.isLoadingPastChats ? (
<LoadingState />
) : mentionData.pastChats.length === 0 ? (
<EmptyState message='No past chats' />
) : (
mentionData.pastChats.map((chat, index) => (
<PopoverItem
key={chat.id}
onClick={() => insertPastChatMention(chat)}
data-idx={index}
active={index === submenuActiveIndex}
>
<span className='truncate'>{chat.title || 'New Chat'}</span>
</PopoverItem>
))
)}
</>
)}
{openSubmenuFor === 'Workflows' && (
<>
{mentionData.isLoadingWorkflows ? (
<LoadingState />
) : mentionData.workflows.length === 0 ? (
<EmptyState message='No workflows' />
) : (
mentionData.workflows.map((wf, index) => (
<PopoverItem
key={wf.id}
onClick={() => insertWorkflowMention(wf)}
data-idx={index}
active={index === submenuActiveIndex}
>
<div
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ backgroundColor: wf.color || '#3972F6' }}
/>
<span className='truncate'>{wf.name || 'Untitled Workflow'}</span>
</PopoverItem>
))
)}
</>
)}
{openSubmenuFor === 'Knowledge' && (
<>
{mentionData.isLoadingKnowledge ? (
<LoadingState />
) : mentionData.knowledgeBases.length === 0 ? (
<EmptyState message='No knowledge bases' />
) : (
mentionData.knowledgeBases.map((kb, index) => (
<PopoverItem
key={kb.id}
onClick={() => insertKnowledgeMention(kb)}
data-idx={index}
active={index === submenuActiveIndex}
>
<span className='truncate'>{kb.name || 'Untitled'}</span>
</PopoverItem>
))
)}
</>
)}
{openSubmenuFor === 'Blocks' && (
<>
{mentionData.isLoadingBlocks ? (
<LoadingState />
) : mentionData.blocksList.length === 0 ? (
<EmptyState message='No blocks found' />
) : (
mentionData.blocksList.map((blk, index) => {
const Icon = blk.iconComponent
return (
<PopoverItem
key={blk.id}
onClick={() => insertBlockMention(blk)}
data-idx={index}
active={index === submenuActiveIndex}
>
<div
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ background: blk.bgColor || '#6B7280' }}
>
{Icon && <Icon className='!h-[10px] !w-[10px] text-white' />}
</div>
<span className='truncate'>{blk.name || blk.id}</span>
</PopoverItem>
)
})
)}
</>
)}
{openSubmenuFor === 'Workflow Blocks' && (
<>
{mentionData.isLoadingWorkflowBlocks ? (
<LoadingState />
) : mentionData.workflowBlocks.length === 0 ? (
<EmptyState message='No blocks in this workflow' />
) : (
mentionData.workflowBlocks.map((blk, index) => {
const Icon = blk.iconComponent
return (
<PopoverItem
key={blk.id}
onClick={() => insertWorkflowBlockMention(blk)}
data-idx={index}
active={index === submenuActiveIndex}
>
<div
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ background: blk.bgColor || '#6B7280' }}
>
{Icon && <Icon className='!h-[10px] !w-[10px] text-white' />}
</div>
<span className='truncate'>{blk.name || blk.id}</span>
</PopoverItem>
)
})
)}
</>
)}
{openSubmenuFor === 'Templates' && (
<>
{mentionData.isLoadingTemplates ? (
<LoadingState />
) : mentionData.templatesList.length === 0 ? (
<EmptyState message='No templates found' />
) : (
mentionData.templatesList.map((tpl, index) => (
<PopoverItem
key={tpl.id}
onClick={() => insertTemplateMention(tpl)}
data-idx={index}
active={index === submenuActiveIndex}
>
<span className='flex-1 truncate'>{tpl.name}</span>
<span className='text-[10px] text-[var(--text-muted)]'>{tpl.stars}</span>
</PopoverItem>
))
)}
</>
)}
{openSubmenuFor === 'Logs' && (
<>
{mentionData.isLoadingLogs ? (
<LoadingState />
) : mentionData.logsList.length === 0 ? (
<EmptyState message='No executions found' />
) : (
mentionData.logsList.map((log, index) => (
<PopoverItem
key={log.id}
onClick={() => insertLogMention(log)}
data-idx={index}
active={index === submenuActiveIndex}
>
<span className='min-w-0 flex-1 truncate'>{log.workflowName}</span>
<span className='text-[10px] text-[var(--text-tertiary)]'>·</span>
<span className='whitespace-nowrap text-[10px]'>
{formatTimestamp(log.createdAt)}
</span>
<span className='text-[10px] text-[var(--text-tertiary)]'>·</span>
<span className='text-[10px] capitalize'>
{(log.trigger || 'manual').toLowerCase()}
</span>
</PopoverItem>
))
)}
</>
)}
</>
) : showAggregatedView ? (
// Aggregated filtered view
<>
{filteredAggregatedItems.length === 0 ? (
<EmptyState message='No results found' />
) : (
filteredAggregatedItems.map((item, index) => (
<PopoverItem
key={item.id}
onClick={() => handleAggregatedItemClick(item)}
data-idx={index}
active={index === submenuActiveIndex}
>
{item.icon}
<span className='flex-1 truncate'>{item.label}</span>
{item.category === 'logs' && (
<>
<span className='text-[10px] text-[var(--text-tertiary)]'>·</span>
<span className='whitespace-nowrap text-[10px]'>
{formatTimestamp(item.data.createdAt)}
</span>
</>
)}
</PopoverItem>
))
)}
</>
) : (
// Folder navigation view
<>
<PopoverFolder
id='chats'
title='Chats'
onOpen={() => mentionData.ensurePastChatsLoaded()}
active={isInFolderNavigationMode && mentionActiveIndex === 0}
data-idx={0}
>
{mentionData.isLoadingPastChats ? (
<LoadingState />
) : mentionData.pastChats.length === 0 ? (
<EmptyState message='No past chats' />
) : (
mentionData.pastChats.map((chat) => (
<PopoverItem key={chat.id} onClick={() => insertPastChatMention(chat)}>
<span className='truncate'>{chat.title || 'New Chat'}</span>
</PopoverItem>
))
)}
</PopoverFolder>
<PopoverFolder
id='workflows'
title='All workflows'
onOpen={() => mentionData.ensureWorkflowsLoaded()}
active={isInFolderNavigationMode && mentionActiveIndex === 1}
data-idx={1}
>
{mentionData.isLoadingWorkflows ? (
<LoadingState />
) : mentionData.workflows.length === 0 ? (
<EmptyState message='No workflows' />
) : (
mentionData.workflows.map((wf) => (
<PopoverItem key={wf.id} onClick={() => insertWorkflowMention(wf)}>
<div
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ backgroundColor: wf.color || '#3972F6' }}
/>
<span className='truncate'>{wf.name || 'Untitled Workflow'}</span>
</PopoverItem>
))
)}
</PopoverFolder>
<PopoverFolder
id='knowledge'
title='Knowledge Bases'
onOpen={() => mentionData.ensureKnowledgeLoaded()}
active={isInFolderNavigationMode && mentionActiveIndex === 2}
data-idx={2}
>
{mentionData.isLoadingKnowledge ? (
<LoadingState />
) : mentionData.knowledgeBases.length === 0 ? (
<EmptyState message='No knowledge bases' />
) : (
mentionData.knowledgeBases.map((kb) => (
<PopoverItem key={kb.id} onClick={() => insertKnowledgeMention(kb)}>
<span className='truncate'>{kb.name || 'Untitled'}</span>
</PopoverItem>
))
)}
</PopoverFolder>
<PopoverFolder
id='blocks'
title='Blocks'
onOpen={() => mentionData.ensureBlocksLoaded()}
active={isInFolderNavigationMode && mentionActiveIndex === 3}
data-idx={3}
>
{mentionData.isLoadingBlocks ? (
<LoadingState />
) : mentionData.blocksList.length === 0 ? (
<EmptyState message='No blocks found' />
) : (
mentionData.blocksList.map((blk) => {
const Icon = blk.iconComponent
return (
<PopoverItem key={blk.id} onClick={() => insertBlockMention(blk)}>
<div
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ background: blk.bgColor || '#6B7280' }}
>
{Icon && <Icon className='!h-[10px] !w-[10px] text-white' />}
</div>
<span className='truncate'>{blk.name || blk.id}</span>
</PopoverItem>
)
})
)}
</PopoverFolder>
<PopoverFolder
id='workflow-blocks'
title='Workflow Blocks'
onOpen={() => mentionData.ensureWorkflowBlocksLoaded()}
active={isInFolderNavigationMode && mentionActiveIndex === 4}
data-idx={4}
>
{mentionData.isLoadingWorkflowBlocks ? (
<LoadingState />
) : mentionData.workflowBlocks.length === 0 ? (
<EmptyState message='No blocks in this workflow' />
) : (
mentionData.workflowBlocks.map((blk) => {
const Icon = blk.iconComponent
return (
<PopoverItem key={blk.id} onClick={() => insertWorkflowBlockMention(blk)}>
<div
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ background: blk.bgColor || '#6B7280' }}
>
{Icon && <Icon className='!h-[10px] !w-[10px] text-white' />}
</div>
<span className='truncate'>{blk.name || blk.id}</span>
</PopoverItem>
)
})
)}
</PopoverFolder>
<PopoverFolder
id='templates'
title='Templates'
onOpen={() => mentionData.ensureTemplatesLoaded()}
active={isInFolderNavigationMode && mentionActiveIndex === 5}
data-idx={5}
>
{mentionData.isLoadingTemplates ? (
<LoadingState />
) : mentionData.templatesList.length === 0 ? (
<EmptyState message='No templates found' />
) : (
mentionData.templatesList.map((tpl) => (
<PopoverItem key={tpl.id} onClick={() => insertTemplateMention(tpl)}>
<span className='flex-1 truncate'>{tpl.name}</span>
<span className='text-[10px] text-[var(--text-muted)]'>{tpl.stars}</span>
</PopoverItem>
))
)}
</PopoverFolder>
<PopoverFolder
id='logs'
title='Logs'
onOpen={() => mentionData.ensureLogsLoaded()}
active={isInFolderNavigationMode && mentionActiveIndex === 6}
data-idx={6}
>
{mentionData.isLoadingLogs ? (
<LoadingState />
) : mentionData.logsList.length === 0 ? (
<EmptyState message='No executions found' />
) : (
mentionData.logsList.map((log) => (
<PopoverItem key={log.id} onClick={() => insertLogMention(log)}>
<span className='min-w-0 flex-1 truncate'>{log.workflowName}</span>
<span className='text-[10px] text-[var(--text-tertiary)]'>·</span>
<span className='whitespace-nowrap text-[10px]'>
{formatTimestamp(log.createdAt)}
</span>
<span className='text-[10px] text-[var(--text-tertiary)]'>·</span>
<span className='text-[10px] capitalize'>
{(log.trigger || 'manual').toLowerCase()}
</span>
</PopoverItem>
))
)}
</PopoverFolder>
<PopoverItem
rootOnly
onClick={() => insertDocsMention()}
active={isInFolderNavigationMode && mentionActiveIndex === 7}
data-idx={7}
>
<span>Docs</span>
</PopoverItem>
</>
)}
</PopoverScrollArea>
<PopoverBackButton />
<MentionMenuContent
mentionMenu={mentionMenu}
mentionData={mentionData}
message={message}
insertHandlers={insertHandlers}
onFolderNavChange={onFolderNavChange}
/>
</PopoverContent>
</Popover>
)

View File

@@ -1,6 +1,6 @@
'use client'
import { useMemo } from 'react'
import { useEffect, useMemo } from 'react'
import {
Popover,
PopoverAnchor,
@@ -9,51 +9,57 @@ import {
PopoverFolder,
PopoverItem,
PopoverScrollArea,
usePopoverContext,
} from '@/components/emcn'
import {
ALL_SLASH_COMMANDS,
MENU_STATE_TEXT_CLASSES,
TOP_LEVEL_COMMANDS,
WEB_COMMANDS,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
import { useCaretViewport } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks'
import type { useMentionMenu } from '../../hooks/use-mention-menu'
const TOP_LEVEL_COMMANDS = [
{ id: 'fast', label: 'Fast' },
{ id: 'research', label: 'Research' },
{ id: 'superagent', label: 'Actions' },
] as const
const WEB_COMMANDS = [
{ id: 'search', label: 'Search' },
{ id: 'read', label: 'Read' },
{ id: 'scrape', label: 'Scrape' },
{ id: 'crawl', label: 'Crawl' },
] as const
const ALL_COMMANDS = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS]
export interface SlashFolderNav {
isInFolder: boolean
openWebFolder: () => void
closeFolder: () => void
}
interface SlashMenuProps {
mentionMenu: ReturnType<typeof useMentionMenu>
message: string
onSelectCommand: (command: string) => void
onFolderNavChange?: (nav: SlashFolderNav) => void
}
export function SlashMenu({ mentionMenu, message, onSelectCommand }: SlashMenuProps) {
function SlashMenuContent({
mentionMenu,
message,
onSelectCommand,
onFolderNavChange,
}: SlashMenuProps) {
const { currentFolder, openFolder, closeFolder } = usePopoverContext()
const {
mentionMenuRef,
menuListRef,
getActiveSlashQueryAtPosition,
getCaretPos,
submenuActiveIndex,
mentionActiveIndex,
openSubmenuFor,
setOpenSubmenuFor,
setSubmenuActiveIndex,
} = mentionMenu
const caretPos = getCaretPos()
const currentQuery = useMemo(() => {
const caretPos = getCaretPos()
const active = getActiveSlashQueryAtPosition(caretPos, message)
return active?.query.trim().toLowerCase() || ''
}, [message, getCaretPos, getActiveSlashQueryAtPosition])
}, [message, caretPos, getActiveSlashQueryAtPosition])
const filteredCommands = useMemo(() => {
if (!currentQuery) return null
return ALL_COMMANDS.filter(
return ALL_SLASH_COMMANDS.filter(
(cmd) =>
cmd.id.toLowerCase().includes(currentQuery) ||
cmd.label.toLowerCase().includes(currentQuery)
@@ -61,52 +67,106 @@ export function SlashMenu({ mentionMenu, message, onSelectCommand }: SlashMenuPr
}, [currentQuery])
const showAggregatedView = currentQuery.length > 0
const isInFolderNavigationMode = !openSubmenuFor && !showAggregatedView
const isInFolder = currentFolder !== null
const isInFolderNavigationMode = !isInFolder && !showAggregatedView
const textareaEl = mentionMenu.textareaRef.current
if (!textareaEl) return null
useEffect(() => {
if (onFolderNavChange) {
onFolderNavChange({
isInFolder,
openWebFolder: () => {
openFolder('web', 'Web')
setSubmenuActiveIndex(0)
},
closeFolder: () => {
closeFolder()
setSubmenuActiveIndex(0)
},
})
}
}, [onFolderNavChange, isInFolder, openFolder, closeFolder, setSubmenuActiveIndex])
return (
<PopoverScrollArea ref={menuListRef} className='space-y-[2px]'>
{isInFolder ? (
<>
{WEB_COMMANDS.map((cmd, index) => (
<PopoverItem
key={cmd.id}
onClick={() => onSelectCommand(cmd.id)}
data-idx={index}
active={index === submenuActiveIndex}
>
<span className='truncate'>{cmd.label}</span>
</PopoverItem>
))}
</>
) : showAggregatedView ? (
<>
{filteredCommands && filteredCommands.length === 0 ? (
<div className={MENU_STATE_TEXT_CLASSES}>No commands found</div>
) : (
filteredCommands?.map((cmd, index) => (
<PopoverItem
key={cmd.id}
onClick={() => onSelectCommand(cmd.id)}
data-idx={index}
active={index === submenuActiveIndex}
>
<span className='truncate'>{cmd.label}</span>
</PopoverItem>
))
)}
</>
) : (
<>
{TOP_LEVEL_COMMANDS.map((cmd, index) => (
<PopoverItem
key={cmd.id}
onClick={() => onSelectCommand(cmd.id)}
data-idx={index}
active={isInFolderNavigationMode && index === mentionActiveIndex}
>
<span className='truncate'>{cmd.label}</span>
</PopoverItem>
))}
<PopoverFolder
id='web'
title='Web'
onOpen={() => setSubmenuActiveIndex(0)}
active={isInFolderNavigationMode && mentionActiveIndex === TOP_LEVEL_COMMANDS.length}
data-idx={TOP_LEVEL_COMMANDS.length}
>
{WEB_COMMANDS.map((cmd) => (
<PopoverItem key={cmd.id} onClick={() => onSelectCommand(cmd.id)}>
<span className='truncate'>{cmd.label}</span>
</PopoverItem>
))}
</PopoverFolder>
</>
)}
</PopoverScrollArea>
)
}
export function SlashMenu({
mentionMenu,
message,
onSelectCommand,
onFolderNavChange,
}: SlashMenuProps) {
const { mentionMenuRef, textareaRef, getCaretPos } = mentionMenu
const caretPos = getCaretPos()
const textareaRect = textareaEl.getBoundingClientRect()
const style = window.getComputedStyle(textareaEl)
const mirrorDiv = document.createElement('div')
mirrorDiv.style.position = 'absolute'
mirrorDiv.style.visibility = 'hidden'
mirrorDiv.style.whiteSpace = 'pre-wrap'
mirrorDiv.style.wordWrap = 'break-word'
mirrorDiv.style.font = style.font
mirrorDiv.style.padding = style.padding
mirrorDiv.style.border = style.border
mirrorDiv.style.width = style.width
mirrorDiv.style.lineHeight = style.lineHeight
mirrorDiv.style.boxSizing = style.boxSizing
mirrorDiv.style.letterSpacing = style.letterSpacing
mirrorDiv.style.textTransform = style.textTransform
mirrorDiv.style.textIndent = style.textIndent
mirrorDiv.style.textAlign = style.textAlign
mirrorDiv.textContent = message.substring(0, caretPos)
const { caretViewport, side } = useCaretViewport({
textareaRef,
message,
caretPos,
})
const caretMarker = document.createElement('span')
caretMarker.style.display = 'inline-block'
caretMarker.style.width = '0px'
caretMarker.style.padding = '0'
caretMarker.style.border = '0'
mirrorDiv.appendChild(caretMarker)
document.body.appendChild(mirrorDiv)
const markerRect = caretMarker.getBoundingClientRect()
const mirrorRect = mirrorDiv.getBoundingClientRect()
document.body.removeChild(mirrorDiv)
const caretViewport = {
left: textareaRect.left + (markerRect.left - mirrorRect.left) - textareaEl.scrollLeft,
top: textareaRect.top + (markerRect.top - mirrorRect.top) - textareaEl.scrollTop,
}
const margin = 8
const spaceBelow = window.innerHeight - caretViewport.top - margin
const side: 'top' | 'bottom' = spaceBelow >= caretViewport.top - margin ? 'bottom' : 'top'
if (!caretViewport) return null
return (
<Popover open={true} onOpenChange={() => {}}>
@@ -129,77 +189,18 @@ export function SlashMenu({ mentionMenu, message, onSelectCommand }: SlashMenuPr
collisionPadding={6}
maxHeight={360}
className='pointer-events-auto'
style={{
width: `180px`,
}}
style={{ width: '180px' }}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
onMouseDown={(e) => e.preventDefault()}
>
<PopoverBackButton onClick={() => setOpenSubmenuFor(null)} />
<PopoverScrollArea ref={menuListRef} className='space-y-[2px]'>
{openSubmenuFor === 'Web' ? (
<>
{WEB_COMMANDS.map((cmd, index) => (
<PopoverItem
key={cmd.id}
onClick={() => onSelectCommand(cmd.id)}
data-idx={index}
active={index === submenuActiveIndex}
>
<span className='truncate'>{cmd.label}</span>
</PopoverItem>
))}
</>
) : showAggregatedView ? (
<>
{filteredCommands && filteredCommands.length === 0 ? (
<div className='px-[8px] py-[8px] text-[12px] text-[var(--text-muted)]'>
No commands found
</div>
) : (
filteredCommands?.map((cmd, index) => (
<PopoverItem
key={cmd.id}
onClick={() => onSelectCommand(cmd.id)}
data-idx={index}
active={index === submenuActiveIndex}
>
<span className='truncate'>{cmd.label}</span>
</PopoverItem>
))
)}
</>
) : (
<>
{TOP_LEVEL_COMMANDS.map((cmd, index) => (
<PopoverItem
key={cmd.id}
onClick={() => onSelectCommand(cmd.id)}
data-idx={index}
active={isInFolderNavigationMode && index === mentionActiveIndex}
>
<span className='truncate'>{cmd.label}</span>
</PopoverItem>
))}
<PopoverFolder
id='web'
title='Web'
onOpen={() => setOpenSubmenuFor('Web')}
active={
isInFolderNavigationMode && mentionActiveIndex === TOP_LEVEL_COMMANDS.length
}
data-idx={TOP_LEVEL_COMMANDS.length}
>
{WEB_COMMANDS.map((cmd) => (
<PopoverItem key={cmd.id} onClick={() => onSelectCommand(cmd.id)}>
<span className='truncate'>{cmd.label}</span>
</PopoverItem>
))}
</PopoverFolder>
</>
)}
</PopoverScrollArea>
<PopoverBackButton />
<SlashMenuContent
mentionMenu={mentionMenu}
message={message}
onSelectCommand={onSelectCommand}
onFolderNavChange={onFolderNavChange}
/>
</PopoverContent>
</Popover>
)

View File

@@ -1,42 +1,245 @@
/**
* Constants for user input component
*/
import type { ChatContext } from '@/stores/panel'
/**
* Mention menu options in order (matches visual render order)
* Mention folder types
*/
export const MENTION_OPTIONS = [
'Chats',
'Workflows',
'Knowledge',
'Blocks',
'Workflow Blocks',
'Templates',
'Logs',
'Docs',
export type MentionFolderId =
| 'chats'
| 'workflows'
| 'knowledge'
| 'blocks'
| 'workflow-blocks'
| 'templates'
| 'logs'
/**
* Menu item category types for mention menu (includes folders + docs item)
*/
export type MentionCategory = MentionFolderId | 'docs'
/**
* Configuration interface for folder types
*/
export interface FolderConfig<TItem = any> {
/** Display title in menu */
title: string
/** Data source key in useMentionData return */
dataKey: string
/** Loading state key in useMentionData return */
loadingKey: string
/** Ensure loaded function key in useMentionData return (optional - some folders auto-load) */
ensureLoadedKey?: string
/** Extract label from an item */
getLabel: (item: TItem) => string
/** Extract unique ID from an item */
getId: (item: TItem) => string
/** Empty state message */
emptyMessage: string
/** No match message (when filtering) */
noMatchMessage: string
/** Filter function for matching query */
filterFn: (item: TItem, query: string) => boolean
/** Build the ChatContext object from an item */
buildContext: (item: TItem, workflowId?: string | null) => ChatContext
/** Whether to use insertAtCursor fallback when replaceActiveMentionWith fails */
useInsertFallback?: boolean
}
/**
* Configuration for all folder types in the mention menu
*/
export const FOLDER_CONFIGS: Record<MentionFolderId, FolderConfig> = {
chats: {
title: 'Chats',
dataKey: 'pastChats',
loadingKey: 'isLoadingPastChats',
ensureLoadedKey: 'ensurePastChatsLoaded',
getLabel: (item) => item.title || 'New Chat',
getId: (item) => item.id,
emptyMessage: 'No past chats',
noMatchMessage: 'No matching chats',
filterFn: (item, q) => (item.title || 'New Chat').toLowerCase().includes(q),
buildContext: (item) => ({
kind: 'past_chat',
chatId: item.id,
label: item.title || 'New Chat',
}),
useInsertFallback: false,
},
workflows: {
title: 'All workflows',
dataKey: 'workflows',
loadingKey: 'isLoadingWorkflows',
// No ensureLoadedKey - workflows auto-load from registry store
getLabel: (item) => item.name || 'Untitled Workflow',
getId: (item) => item.id,
emptyMessage: 'No workflows',
noMatchMessage: 'No matching workflows',
filterFn: (item, q) => (item.name || 'Untitled Workflow').toLowerCase().includes(q),
buildContext: (item) => ({
kind: 'workflow',
workflowId: item.id,
label: item.name || 'Untitled Workflow',
}),
useInsertFallback: true,
},
knowledge: {
title: 'Knowledge Bases',
dataKey: 'knowledgeBases',
loadingKey: 'isLoadingKnowledge',
ensureLoadedKey: 'ensureKnowledgeLoaded',
getLabel: (item) => item.name || 'Untitled',
getId: (item) => item.id,
emptyMessage: 'No knowledge bases',
noMatchMessage: 'No matching knowledge bases',
filterFn: (item, q) => (item.name || 'Untitled').toLowerCase().includes(q),
buildContext: (item) => ({
kind: 'knowledge',
knowledgeId: item.id,
label: item.name || 'Untitled',
}),
useInsertFallback: false,
},
blocks: {
title: 'Blocks',
dataKey: 'blocksList',
loadingKey: 'isLoadingBlocks',
ensureLoadedKey: 'ensureBlocksLoaded',
getLabel: (item) => item.name || item.id,
getId: (item) => item.id,
emptyMessage: 'No blocks found',
noMatchMessage: 'No matching blocks',
filterFn: (item, q) => (item.name || item.id).toLowerCase().includes(q),
buildContext: (item) => ({
kind: 'blocks',
blockIds: [item.id],
label: item.name || item.id,
}),
useInsertFallback: false,
},
'workflow-blocks': {
title: 'Workflow Blocks',
dataKey: 'workflowBlocks',
loadingKey: 'isLoadingWorkflowBlocks',
// No ensureLoadedKey - workflow blocks auto-sync from store
getLabel: (item) => item.name || item.id,
getId: (item) => item.id,
emptyMessage: 'No blocks in this workflow',
noMatchMessage: 'No matching blocks',
filterFn: (item, q) => (item.name || item.id).toLowerCase().includes(q),
buildContext: (item, workflowId) => ({
kind: 'workflow_block',
workflowId: workflowId || '',
blockId: item.id,
label: item.name || item.id,
}),
useInsertFallback: true,
},
templates: {
title: 'Templates',
dataKey: 'templatesList',
loadingKey: 'isLoadingTemplates',
ensureLoadedKey: 'ensureTemplatesLoaded',
getLabel: (item) => item.name || 'Untitled Template',
getId: (item) => item.id,
emptyMessage: 'No templates found',
noMatchMessage: 'No matching templates',
filterFn: (item, q) => (item.name || 'Untitled Template').toLowerCase().includes(q),
buildContext: (item) => ({
kind: 'templates',
templateId: item.id,
label: item.name || 'Untitled Template',
}),
useInsertFallback: false,
},
logs: {
title: 'Logs',
dataKey: 'logsList',
loadingKey: 'isLoadingLogs',
ensureLoadedKey: 'ensureLogsLoaded',
getLabel: (item) => item.workflowName,
getId: (item) => item.id,
emptyMessage: 'No executions found',
noMatchMessage: 'No matching executions',
filterFn: (item, q) =>
[item.workflowName, item.trigger || ''].join(' ').toLowerCase().includes(q),
buildContext: (item) => ({
kind: 'logs',
executionId: item.executionId || item.id,
label: item.workflowName,
}),
useInsertFallback: false,
},
}
/**
* Order of folders in the mention menu
*/
export const FOLDER_ORDER: MentionFolderId[] = [
'chats',
'workflows',
'knowledge',
'blocks',
'workflow-blocks',
'templates',
'logs',
]
/**
* Docs item configuration (special case - not a folder)
*/
export const DOCS_CONFIG = {
getLabel: () => 'Docs',
buildContext: (): ChatContext => ({ kind: 'docs', label: 'Docs' }),
} as const
/**
* Total number of items in root menu (folders + docs)
*/
export const ROOT_MENU_ITEM_COUNT = FOLDER_ORDER.length + 1
/**
* Slash command configuration
*/
export interface SlashCommand {
id: string
label: string
}
export const TOP_LEVEL_COMMANDS: readonly SlashCommand[] = [
{ id: 'fast', label: 'Fast' },
{ id: 'research', label: 'Research' },
{ id: 'superagent', label: 'Actions' },
] as const
export const WEB_COMMANDS: readonly SlashCommand[] = [
{ id: 'search', label: 'Search' },
{ id: 'read', label: 'Read' },
{ id: 'scrape', label: 'Scrape' },
{ id: 'crawl', label: 'Crawl' },
] as const
export const ALL_SLASH_COMMANDS: readonly SlashCommand[] = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS]
export const ALL_COMMAND_IDS = ALL_SLASH_COMMANDS.map((cmd) => cmd.id)
/**
* Get display label for a command ID
*/
export function getCommandDisplayLabel(commandId: string): string {
const command = ALL_SLASH_COMMANDS.find((cmd) => cmd.id === commandId)
return command?.label || commandId.charAt(0).toUpperCase() + commandId.slice(1)
}
/**
* Model configuration options
*/
export const MODEL_OPTIONS = [
{ value: 'claude-4.5-opus', label: 'Claude 4.5 Opus' },
{ value: 'claude-4.5-sonnet', label: 'Claude 4.5 Sonnet' },
// { value: 'claude-4-sonnet', label: 'Claude 4 Sonnet' },
{ value: 'claude-4.5-haiku', label: 'Claude 4.5 Haiku' },
// { value: 'claude-4.1-opus', label: 'Claude 4.1 Opus' },
{ value: 'gpt-5.1-codex', label: 'GPT 5.1 Codex' },
// { value: 'gpt-5-codex', label: 'GPT 5 Codex' },
{ value: 'gpt-5.1-medium', label: 'GPT 5.1 Medium' },
// { value: 'gpt-5-fast', label: 'GPT 5 Fast' },
// { value: 'gpt-5', label: 'GPT 5' },
// { value: 'gpt-5.1-fast', label: 'GPT 5.1 Fast' },
// { value: 'gpt-5.1', label: 'GPT 5.1' },
// { value: 'gpt-5.1-high', label: 'GPT 5.1 High' },
// { value: 'gpt-5-high', label: 'GPT 5 High' },
// { value: 'gpt-4o', label: 'GPT 4o' },
// { value: 'gpt-4.1', label: 'GPT 4.1' },
// { value: 'o3', label: 'o3' },
{ value: 'gemini-3-pro', label: 'Gemini 3 Pro' },
] as const
@@ -49,3 +252,18 @@ export const NEAR_TOP_THRESHOLD = 300
* Scroll tolerance for mention menu positioning (in pixels)
*/
export const SCROLL_TOLERANCE = 8
/**
* Shared CSS classes for menu state text (loading, empty states)
*/
export const MENU_STATE_TEXT_CLASSES = 'px-[8px] py-[8px] text-[12px] text-[var(--text-muted)]'
/**
* Calculates the next index for circular navigation (wraps around at bounds)
*/
export function getNextIndex(current: number, direction: 'up' | 'down', maxIndex: number): number {
if (direction === 'down') {
return current >= maxIndex ? 0 : current + 1
}
return current <= 0 ? maxIndex : current - 1
}

View File

@@ -1,3 +1,4 @@
export { useCaretViewport } from './use-caret-viewport'
export { useContextManagement } from './use-context-management'
export { useFileAttachments } from './use-file-attachments'
export { useMentionData } from './use-mention-data'

View File

@@ -0,0 +1,77 @@
import { useMemo } from 'react'
interface CaretViewportPosition {
left: number
top: number
}
interface UseCaretViewportResult {
caretViewport: CaretViewportPosition | null
side: 'top' | 'bottom'
}
interface UseCaretViewportProps {
textareaRef: React.RefObject<HTMLTextAreaElement | null>
message: string
caretPos: number
}
/**
* Calculates the viewport position of the caret in a textarea using the mirror div technique.
* This hook memoizes the calculation to prevent unnecessary DOM manipulation on every render.
*/
export function useCaretViewport({
textareaRef,
message,
caretPos,
}: UseCaretViewportProps): UseCaretViewportResult {
return useMemo(() => {
const textareaEl = textareaRef.current
if (!textareaEl) {
return { caretViewport: null, side: 'bottom' as const }
}
const textareaRect = textareaEl.getBoundingClientRect()
const style = window.getComputedStyle(textareaEl)
const mirrorDiv = document.createElement('div')
mirrorDiv.style.position = 'absolute'
mirrorDiv.style.visibility = 'hidden'
mirrorDiv.style.whiteSpace = 'pre-wrap'
mirrorDiv.style.overflowWrap = 'break-word'
mirrorDiv.style.font = style.font
mirrorDiv.style.padding = style.padding
mirrorDiv.style.border = style.border
mirrorDiv.style.width = style.width
mirrorDiv.style.lineHeight = style.lineHeight
mirrorDiv.style.boxSizing = style.boxSizing
mirrorDiv.style.letterSpacing = style.letterSpacing
mirrorDiv.style.textTransform = style.textTransform
mirrorDiv.style.textIndent = style.textIndent
mirrorDiv.style.textAlign = style.textAlign
mirrorDiv.textContent = message.substring(0, caretPos)
const caretMarker = document.createElement('span')
caretMarker.style.display = 'inline-block'
caretMarker.style.width = '0px'
caretMarker.style.padding = '0'
caretMarker.style.border = '0'
mirrorDiv.appendChild(caretMarker)
document.body.appendChild(mirrorDiv)
const markerRect = caretMarker.getBoundingClientRect()
const mirrorRect = mirrorDiv.getBoundingClientRect()
document.body.removeChild(mirrorDiv)
const caretViewport = {
left: textareaRect.left + (markerRect.left - mirrorRect.left) - textareaEl.scrollLeft,
top: textareaRect.top + (markerRect.top - mirrorRect.top) - textareaEl.scrollTop,
}
const margin = 8
const spaceBelow = window.innerHeight - caretViewport.top - margin
const side: 'top' | 'bottom' = spaceBelow >= caretViewport.top - margin ? 'bottom' : 'top'
return { caretViewport, side }
}, [textareaRef, message, caretPos])
}

View File

@@ -1,4 +1,8 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import {
filterOutContext,
isContextAlreadySelected,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
import type { ChatContext } from '@/stores/panel'
interface UseContextManagementProps {
@@ -35,53 +39,7 @@ export function useContextManagement({ message, initialContexts }: UseContextMan
*/
const addContext = useCallback((context: ChatContext) => {
setSelectedContexts((prev) => {
// CRITICAL: Check label collision FIRST
// The token system uses @label format, so we cannot have duplicate labels
// regardless of kind or ID differences
const exists = prev.some((c) => {
// Primary check: label collision
// This prevents duplicate @Label tokens which would break the overlay
if (c.label && context.label && c.label === context.label) {
return true
}
// Secondary check: exact duplicate by ID fields based on kind
// This prevents the same entity from being added twice even with different labels
if (c.kind === context.kind) {
if (c.kind === 'past_chat' && 'chatId' in context && 'chatId' in c) {
return c.chatId === (context as any).chatId
}
if (c.kind === 'workflow' && 'workflowId' in context && 'workflowId' in c) {
return c.workflowId === (context as any).workflowId
}
if (c.kind === 'blocks' && 'blockId' in context && 'blockId' in c) {
return c.blockId === (context as any).blockId
}
if (c.kind === 'workflow_block' && 'blockId' in context && 'blockId' in c) {
return (
c.workflowId === (context as any).workflowId && c.blockId === (context as any).blockId
)
}
if (c.kind === 'knowledge' && 'knowledgeId' in context && 'knowledgeId' in c) {
return c.knowledgeId === (context as any).knowledgeId
}
if (c.kind === 'templates' && 'templateId' in context && 'templateId' in c) {
return c.templateId === (context as any).templateId
}
if (c.kind === 'logs' && 'executionId' in context && 'executionId' in c) {
return c.executionId === (context as any).executionId
}
if (c.kind === 'docs') {
return true // Only one docs context allowed
}
if (c.kind === 'slash_command' && 'command' in context && 'command' in c) {
return c.command === (context as any).command
}
}
return false
})
if (exists) return prev
if (isContextAlreadySelected(context, prev)) return prev
return [...prev, context]
})
}, [])
@@ -92,38 +50,7 @@ export function useContextManagement({ message, initialContexts }: UseContextMan
* @param contextToRemove - Context to remove
*/
const removeContext = useCallback((contextToRemove: ChatContext) => {
setSelectedContexts((prev) =>
prev.filter((c) => {
// Match by kind and specific ID fields
if (c.kind !== contextToRemove.kind) return true
switch (c.kind) {
case 'past_chat':
return (c as any).chatId !== (contextToRemove as any).chatId
case 'workflow':
return (c as any).workflowId !== (contextToRemove as any).workflowId
case 'blocks':
return (c as any).blockId !== (contextToRemove as any).blockId
case 'workflow_block':
return (
(c as any).workflowId !== (contextToRemove as any).workflowId ||
(c as any).blockId !== (contextToRemove as any).blockId
)
case 'knowledge':
return (c as any).knowledgeId !== (contextToRemove as any).knowledgeId
case 'templates':
return (c as any).templateId !== (contextToRemove as any).templateId
case 'logs':
return (c as any).executionId !== (contextToRemove as any).executionId
case 'docs':
return false // Remove docs (only one docs context)
case 'slash_command':
return (c as any).command !== (contextToRemove as any).command
default:
return c.label !== contextToRemove.label
}
})
)
setSelectedContexts((prev) => filterOutContext(prev, contextToRemove))
}, [])
/**

View File

@@ -83,6 +83,36 @@ interface UseMentionDataProps {
workspaceId: string
}
/**
* Return type for useMentionData hook
*/
export interface MentionDataReturn {
// Data arrays
pastChats: PastChat[]
workflows: WorkflowItem[]
knowledgeBases: KnowledgeItem[]
blocksList: BlockItem[]
workflowBlocks: WorkflowBlockItem[]
templatesList: TemplateItem[]
logsList: LogItem[]
// Loading states
isLoadingPastChats: boolean
isLoadingWorkflows: boolean
isLoadingKnowledge: boolean
isLoadingBlocks: boolean
isLoadingWorkflowBlocks: boolean
isLoadingTemplates: boolean
isLoadingLogs: boolean
// Ensure loaded functions
ensurePastChatsLoaded: () => Promise<void>
ensureKnowledgeLoaded: () => Promise<void>
ensureBlocksLoaded: () => Promise<void>
ensureTemplatesLoaded: () => Promise<void>
ensureLogsLoaded: () => Promise<void>
}
/**
* Custom hook to fetch and manage data for mention suggestions
* Loads data from APIs for chats, workflows, knowledge bases, blocks, templates, and logs
@@ -90,7 +120,7 @@ interface UseMentionDataProps {
* @param props - Configuration including workflow and workspace IDs
* @returns Mention data state and loading operations
*/
export function useMentionData(props: UseMentionDataProps) {
export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
const { workflowId, workspaceId } = props
const { config, isBlockAllowed } = usePermissionConfig()
@@ -104,7 +134,6 @@ export function useMentionData(props: UseMentionDataProps) {
const [blocksList, setBlocksList] = useState<BlockItem[]>([])
const [isLoadingBlocks, setIsLoadingBlocks] = useState(false)
// Reset blocks list when permission config changes
useEffect(() => {
setBlocksList([])
}, [config.allowedIntegrations])
@@ -118,12 +147,10 @@ export function useMentionData(props: UseMentionDataProps) {
const [workflowBlocks, setWorkflowBlocks] = useState<WorkflowBlockItem[]>([])
const [isLoadingWorkflowBlocks, setIsLoadingWorkflowBlocks] = useState(false)
// Only subscribe to block keys to avoid re-rendering on position updates
const blockKeys = useWorkflowStore(
useShallow(useCallback((state) => Object.keys(state.blocks), []))
)
// Use workflow registry as source of truth for workflows
const registryWorkflows = useWorkflowRegistry((state) => state.workflows)
const hydrationPhase = useWorkflowRegistry((state) => state.hydration.phase)
const isLoadingWorkflows =
@@ -131,7 +158,6 @@ export function useMentionData(props: UseMentionDataProps) {
hydrationPhase === 'metadata-loading' ||
hydrationPhase === 'state-loading'
// Convert registry workflows to mention format, filtered by workspace and sorted
const workflows: WorkflowItem[] = Object.values(registryWorkflows)
.filter((w) => w.workspaceId === workspaceId)
.sort((a, b) => {
@@ -219,14 +245,6 @@ export function useMentionData(props: UseMentionDataProps) {
}
}, [isLoadingPastChats, pastChats.length, workflowId])
/**
* Ensures workflows are loaded (now using registry store)
*/
const ensureWorkflowsLoaded = useCallback(() => {
// Workflows are now automatically loaded from the registry store
// No manual fetching needed
}, [])
/**
* Ensures knowledge bases are loaded
*/
@@ -348,18 +366,6 @@ export function useMentionData(props: UseMentionDataProps) {
}
}, [isLoadingLogs, logsList.length, workspaceId])
/**
* Ensures workflow blocks are loaded (synced from store)
*/
const ensureWorkflowBlocksLoaded = useCallback(async () => {
if (!workflowId) return
logger.debug('ensureWorkflowBlocksLoaded called', {
workflowId,
storeBlocksCount: blockKeys.length,
workflowBlocksCount: workflowBlocks.length,
})
}, [workflowId, blockKeys.length, workflowBlocks.length])
return {
// State
pastChats,
@@ -379,11 +385,9 @@ export function useMentionData(props: UseMentionDataProps) {
// Operations
ensurePastChatsLoaded,
ensureWorkflowsLoaded,
ensureKnowledgeLoaded,
ensureBlocksLoaded,
ensureTemplatesLoaded,
ensureLogsLoaded,
ensureWorkflowBlocksLoaded,
}
}

View File

@@ -1,5 +1,12 @@
import { useCallback } from 'react'
import { useCallback, useMemo } from 'react'
import type { MentionFolderNav } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components'
import {
DOCS_CONFIG,
FOLDER_CONFIGS,
type FolderConfig,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
import type { useMentionMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu'
import { isContextAlreadySelected } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
import type { ChatContext } from '@/stores/panel'
interface UseMentionInsertHandlersProps {
@@ -11,12 +18,12 @@ interface UseMentionInsertHandlersProps {
selectedContexts: ChatContext[]
/** Callback to update selected contexts */
onContextAdd: (context: ChatContext) => void
/** Folder navigation state exposed from MentionMenu via callback */
mentionFolderNav?: MentionFolderNav | null
}
/**
* Custom hook to provide insert handlers for different mention types.
* Consolidates the logic for inserting mentions and updating selected contexts.
* Prevents duplicate mentions from being inserted.
*
* @param props - Configuration object
* @returns Insert handler functions for each mention type
@@ -26,6 +33,7 @@ export function useMentionInsertHandlers({
workflowId,
selectedContexts,
onContextAdd,
mentionFolderNav,
}: UseMentionInsertHandlersProps) {
const {
replaceActiveMentionWith,
@@ -36,342 +44,94 @@ export function useMentionInsertHandlers({
} = mentionMenu
/**
* Checks if a context already exists in selected contexts
* CRITICAL: Prioritizes label checking to prevent token system breakage
*
* @param context - Context to check
* @returns True if context already exists or label is already used
* Closes all menus and resets state
*/
const isContextAlreadySelected = useCallback(
(context: ChatContext): boolean => {
return selectedContexts.some((c) => {
// CRITICAL: Check label collision FIRST
// The token system uses @label format, so we cannot have duplicate labels
// regardless of kind or ID differences
if (c.label && context.label && c.label === context.label) {
return true
const closeMenus = useCallback(() => {
setShowMentionMenu(false)
if (mentionFolderNav?.isInFolder) {
mentionFolderNav.closeFolder()
}
setOpenSubmenuFor(null)
}, [setShowMentionMenu, setOpenSubmenuFor, mentionFolderNav])
const createInsertHandler = useCallback(
<TItem>(config: FolderConfig<TItem>) => {
return (item: TItem) => {
const label = config.getLabel(item)
const context = config.buildContext(item, workflowId)
if (isContextAlreadySelected(context, selectedContexts)) {
resetActiveMentionQuery()
closeMenus()
return
}
// Secondary check: exact duplicate by ID fields
if (c.kind === context.kind) {
if (c.kind === 'past_chat' && 'chatId' in context && 'chatId' in c) {
return c.chatId === (context as any).chatId
}
if (c.kind === 'workflow' && 'workflowId' in context && 'workflowId' in c) {
return c.workflowId === (context as any).workflowId
}
if (c.kind === 'blocks' && 'blockId' in context && 'blockId' in c) {
return c.blockId === (context as any).blockId
}
if (c.kind === 'workflow_block' && 'blockId' in context && 'blockId' in c) {
return (
c.workflowId === (context as any).workflowId && c.blockId === (context as any).blockId
)
}
if (c.kind === 'knowledge' && 'knowledgeId' in context && 'knowledgeId' in c) {
return c.knowledgeId === (context as any).knowledgeId
}
if (c.kind === 'templates' && 'templateId' in context && 'templateId' in c) {
return c.templateId === (context as any).templateId
}
if (c.kind === 'logs' && 'executionId' in context && 'executionId' in c) {
return c.executionId === (context as any).executionId
}
if (c.kind === 'docs') {
return true
if (config.useInsertFallback) {
if (!replaceActiveMentionWith(label)) {
insertAtCursor(` @${label} `)
}
} else {
replaceActiveMentionWith(label)
}
return false
})
},
[selectedContexts]
)
/**
* Inserts a past chat mention
*
* @param chat - Chat object to mention
*/
const insertPastChatMention = useCallback(
(chat: { id: string; title: string | null }) => {
const label = chat.title || 'New Chat'
const context = { kind: 'past_chat', chatId: chat.id, label } as ChatContext
// Prevent duplicate insertion
if (isContextAlreadySelected(context)) {
// Clear the partial mention text (e.g., "@Unti") before closing
resetActiveMentionQuery()
setShowMentionMenu(false)
setOpenSubmenuFor(null)
return
onContextAdd(context)
closeMenus()
}
replaceActiveMentionWith(label)
onContextAdd(context)
setShowMentionMenu(false)
setOpenSubmenuFor(null)
},
[
replaceActiveMentionWith,
onContextAdd,
setShowMentionMenu,
setOpenSubmenuFor,
isContextAlreadySelected,
resetActiveMentionQuery,
]
)
/**
* Inserts a workflow mention
*
* @param wf - Workflow object to mention
*/
const insertWorkflowMention = useCallback(
(wf: { id: string; name: string }) => {
const label = wf.name || 'Untitled Workflow'
const context = { kind: 'workflow', workflowId: wf.id, label } as ChatContext
// Prevent duplicate insertion
if (isContextAlreadySelected(context)) {
// Clear the partial mention text before closing
resetActiveMentionQuery()
setShowMentionMenu(false)
setOpenSubmenuFor(null)
return
}
if (!replaceActiveMentionWith(label)) insertAtCursor(` @${label} `)
onContextAdd(context)
setShowMentionMenu(false)
setOpenSubmenuFor(null)
},
[
replaceActiveMentionWith,
insertAtCursor,
onContextAdd,
setShowMentionMenu,
setOpenSubmenuFor,
isContextAlreadySelected,
resetActiveMentionQuery,
]
)
/**
* Inserts a knowledge base mention
*
* @param kb - Knowledge base object to mention
*/
const insertKnowledgeMention = useCallback(
(kb: { id: string; name: string }) => {
const label = kb.name || 'Untitled'
const context = { kind: 'knowledge', knowledgeId: kb.id, label } as any
// Prevent duplicate insertion
if (isContextAlreadySelected(context)) {
// Clear the partial mention text before closing
resetActiveMentionQuery()
setShowMentionMenu(false)
setOpenSubmenuFor(null)
return
}
replaceActiveMentionWith(label)
onContextAdd(context)
setShowMentionMenu(false)
setOpenSubmenuFor(null)
},
[
replaceActiveMentionWith,
onContextAdd,
setShowMentionMenu,
setOpenSubmenuFor,
isContextAlreadySelected,
resetActiveMentionQuery,
]
)
/**
* Inserts a block mention
*
* @param blk - Block object to mention
*/
const insertBlockMention = useCallback(
(blk: { id: string; name: string }) => {
const label = blk.name || blk.id
const context = { kind: 'blocks', blockId: blk.id, label } as any
// Prevent duplicate insertion
if (isContextAlreadySelected(context)) {
// Clear the partial mention text before closing
resetActiveMentionQuery()
setShowMentionMenu(false)
setOpenSubmenuFor(null)
return
}
replaceActiveMentionWith(label)
onContextAdd(context)
setShowMentionMenu(false)
setOpenSubmenuFor(null)
},
[
replaceActiveMentionWith,
onContextAdd,
setShowMentionMenu,
setOpenSubmenuFor,
isContextAlreadySelected,
resetActiveMentionQuery,
]
)
/**
* Inserts a workflow block mention
*
* @param blk - Workflow block object to mention
*/
const insertWorkflowBlockMention = useCallback(
(blk: { id: string; name: string }) => {
const label = blk.name
const context = {
kind: 'workflow_block',
workflowId: workflowId as string,
blockId: blk.id,
label,
} as any
// Prevent duplicate insertion
if (isContextAlreadySelected(context)) {
// Clear the partial mention text before closing
resetActiveMentionQuery()
setShowMentionMenu(false)
setOpenSubmenuFor(null)
return
}
if (!replaceActiveMentionWith(label)) insertAtCursor(` @${label} `)
onContextAdd(context)
setShowMentionMenu(false)
setOpenSubmenuFor(null)
},
[
replaceActiveMentionWith,
insertAtCursor,
workflowId,
onContextAdd,
setShowMentionMenu,
setOpenSubmenuFor,
isContextAlreadySelected,
resetActiveMentionQuery,
]
)
/**
* Inserts a template mention
*
* @param tpl - Template object to mention
*/
const insertTemplateMention = useCallback(
(tpl: { id: string; name: string }) => {
const label = tpl.name || 'Untitled Template'
const context = { kind: 'templates', templateId: tpl.id, label } as any
// Prevent duplicate insertion
if (isContextAlreadySelected(context)) {
// Clear the partial mention text before closing
resetActiveMentionQuery()
setShowMentionMenu(false)
setOpenSubmenuFor(null)
return
}
replaceActiveMentionWith(label)
onContextAdd(context)
setShowMentionMenu(false)
setOpenSubmenuFor(null)
},
[
selectedContexts,
replaceActiveMentionWith,
insertAtCursor,
onContextAdd,
setShowMentionMenu,
setOpenSubmenuFor,
isContextAlreadySelected,
resetActiveMentionQuery,
closeMenus,
]
)
/**
* Inserts a log mention
*
* @param log - Log object to mention
*/
const insertLogMention = useCallback(
(log: { id: string; executionId?: string; workflowName: string }) => {
const label = log.workflowName
const context = { kind: 'logs' as const, executionId: log.executionId, label }
// Prevent duplicate insertion
if (isContextAlreadySelected(context)) {
// Clear the partial mention text before closing
resetActiveMentionQuery()
setShowMentionMenu(false)
setOpenSubmenuFor(null)
return
}
replaceActiveMentionWith(label)
onContextAdd(context)
setShowMentionMenu(false)
setOpenSubmenuFor(null)
},
[
replaceActiveMentionWith,
onContextAdd,
setShowMentionMenu,
setOpenSubmenuFor,
isContextAlreadySelected,
resetActiveMentionQuery,
]
)
/**
* Inserts a docs mention
* Special handler for Docs (no item parameter, uses DOCS_CONFIG)
*/
const insertDocsMention = useCallback(() => {
const label = 'Docs'
const context = { kind: 'docs', label } as any
const label = DOCS_CONFIG.getLabel()
const context = DOCS_CONFIG.buildContext()
// Prevent duplicate insertion
if (isContextAlreadySelected(context)) {
// Clear the partial mention text before closing
if (isContextAlreadySelected(context, selectedContexts)) {
resetActiveMentionQuery()
setShowMentionMenu(false)
setOpenSubmenuFor(null)
closeMenus()
return
}
if (!replaceActiveMentionWith(label)) insertAtCursor(` @${label} `)
// Docs uses fallback insertion
if (!replaceActiveMentionWith(label)) {
insertAtCursor(` @${label} `)
}
onContextAdd(context)
setShowMentionMenu(false)
setOpenSubmenuFor(null)
closeMenus()
}, [
selectedContexts,
replaceActiveMentionWith,
insertAtCursor,
onContextAdd,
setShowMentionMenu,
setOpenSubmenuFor,
isContextAlreadySelected,
resetActiveMentionQuery,
closeMenus,
])
return {
insertPastChatMention,
insertWorkflowMention,
insertKnowledgeMention,
insertBlockMention,
insertWorkflowBlockMention,
insertTemplateMention,
insertLogMention,
insertDocsMention,
}
const handlers = useMemo(
() => ({
insertPastChatMention: createInsertHandler(FOLDER_CONFIGS.chats),
insertWorkflowMention: createInsertHandler(FOLDER_CONFIGS.workflows),
insertKnowledgeMention: createInsertHandler(FOLDER_CONFIGS.knowledge),
insertBlockMention: createInsertHandler(FOLDER_CONFIGS.blocks),
insertWorkflowBlockMention: createInsertHandler(FOLDER_CONFIGS['workflow-blocks']),
insertTemplateMention: createInsertHandler(FOLDER_CONFIGS.templates),
insertLogMention: createInsertHandler(FOLDER_CONFIGS.logs),
insertDocsMention,
}),
[createInsertHandler, insertDocsMention]
)
return handlers
}

View File

@@ -1,56 +1,19 @@
import { type KeyboardEvent, useCallback } from 'react'
import type { useMentionData } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data'
import type { useMentionMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu'
import { MENTION_OPTIONS } from '../constants'
/**
* Chat item for mention insertion
*/
interface ChatItem {
id: string
title: string | null
}
/**
* Workflow item for mention insertion
*/
interface WorkflowItem {
id: string
name: string
}
/**
* Knowledge base item for mention insertion
*/
interface KnowledgeItem {
id: string
name: string
}
/**
* Block item for mention insertion
*/
interface BlockItem {
id: string
name: string
}
/**
* Template item for mention insertion
*/
interface TemplateItem {
id: string
name: string
}
/**
* Log item for mention insertion
*/
interface LogItem {
id: string
executionId?: string
workflowName: string
}
import { type KeyboardEvent, useCallback, useMemo } from 'react'
import type { MentionFolderNav } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components'
import {
FOLDER_CONFIGS,
FOLDER_ORDER,
type MentionFolderId,
ROOT_MENU_ITEM_COUNT,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
import type {
useMentionData,
useMentionMenu,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks'
import {
getFolderData as getFolderDataUtil,
getFolderEnsureLoaded as getFolderEnsureLoadedUtil,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
interface UseMentionKeyboardProps {
/** Mention menu hook instance */
@@ -59,37 +22,34 @@ interface UseMentionKeyboardProps {
mentionData: ReturnType<typeof useMentionData>
/** Callback to insert specific mention types */
insertHandlers: {
insertPastChatMention: (chat: ChatItem) => void
insertWorkflowMention: (wf: WorkflowItem) => void
insertKnowledgeMention: (kb: KnowledgeItem) => void
insertBlockMention: (blk: BlockItem) => void
insertWorkflowBlockMention: (blk: BlockItem) => void
insertTemplateMention: (tpl: TemplateItem) => void
insertLogMention: (log: LogItem) => void
insertPastChatMention: (chat: any) => void
insertWorkflowMention: (wf: any) => void
insertKnowledgeMention: (kb: any) => void
insertBlockMention: (blk: any) => void
insertWorkflowBlockMention: (blk: any) => void
insertTemplateMention: (tpl: any) => void
insertLogMention: (log: any) => void
insertDocsMention: () => void
}
/** Folder navigation state exposed from MentionMenu via callback */
mentionFolderNav: MentionFolderNav | null
}
/**
* Custom hook to handle keyboard navigation in the mention menu.
* Manages Arrow Up/Down/Left/Right and Enter key navigation through menus and submenus.
*
* @param props - Configuration object
* @returns Keyboard handler for mention menu
*/
export function useMentionKeyboard({
mentionMenu,
mentionData,
insertHandlers,
mentionFolderNav,
}: UseMentionKeyboardProps) {
const {
showMentionMenu,
openSubmenuFor,
mentionActiveIndex,
submenuActiveIndex,
setMentionActiveIndex,
setSubmenuActiveIndex,
setOpenSubmenuFor,
setSubmenuQueryStart,
getCaretPos,
getActiveMentionQueryAtPosition,
@@ -98,65 +58,101 @@ export function useMentionKeyboard({
scrollActiveItemIntoView,
} = mentionMenu
const {
pastChats,
workflows,
knowledgeBases,
blocksList,
workflowBlocks,
templatesList,
logsList,
ensurePastChatsLoaded,
ensureWorkflowsLoaded,
ensureKnowledgeLoaded,
ensureBlocksLoaded,
ensureWorkflowBlocksLoaded,
ensureTemplatesLoaded,
ensureLogsLoaded,
} = mentionData
const currentFolder = mentionFolderNav?.currentFolder ?? null
const isInFolder = mentionFolderNav?.isInFolder ?? false
const {
insertPastChatMention,
insertWorkflowMention,
insertKnowledgeMention,
insertBlockMention,
insertWorkflowBlockMention,
insertTemplateMention,
insertLogMention,
insertDocsMention,
} = insertHandlers
/**
* Map of folder IDs to insert handlers
*/
const insertHandlerMap = useMemo(
(): Record<MentionFolderId, (item: any) => void> => ({
chats: insertHandlers.insertPastChatMention,
workflows: insertHandlers.insertWorkflowMention,
knowledge: insertHandlers.insertKnowledgeMention,
blocks: insertHandlers.insertBlockMention,
'workflow-blocks': insertHandlers.insertWorkflowBlockMention,
templates: insertHandlers.insertTemplateMention,
logs: insertHandlers.insertLogMention,
}),
[insertHandlers]
)
/**
* Get data array for a folder from mentionData
*/
const getFolderData = useCallback(
(folderId: MentionFolderId) => getFolderDataUtil(mentionData, folderId),
[mentionData]
)
/**
* Filter items for a folder based on query using config's filterFn
*/
const filterFolderItems = useCallback(
(folderId: MentionFolderId, query: string): any[] => {
const config = FOLDER_CONFIGS[folderId]
const items = getFolderData(folderId)
if (!query) return items
const q = query.toLowerCase()
return items.filter((item) => config.filterFn(item, q))
},
[getFolderData]
)
/**
* Ensure data is loaded for a folder
*/
const ensureFolderLoaded = useCallback(
(folderId: MentionFolderId): void => {
const ensureFn = getFolderEnsureLoadedUtil(mentionData, folderId)
if (ensureFn) void ensureFn()
},
[mentionData]
)
/**
* Build aggregated list matching the portal's ordering
*/
const buildAggregatedList = useCallback(
(query: string) => {
(query: string): Array<{ type: MentionFolderId | 'docs'; value: any }> => {
const q = query.toLowerCase()
return [
...pastChats
.filter((c) => (c.title || 'New Chat').toLowerCase().includes(q))
.map((c) => ({ type: 'Chats' as const, value: c })),
...workflows
.filter((w) => (w.name || 'Untitled Workflow').toLowerCase().includes(q))
.map((w) => ({ type: 'Workflows' as const, value: w })),
...knowledgeBases
.filter((k) => (k.name || 'Untitled').toLowerCase().includes(q))
.map((k) => ({ type: 'Knowledge' as const, value: k })),
...blocksList
.filter((b) => (b.name || b.id).toLowerCase().includes(q))
.map((b) => ({ type: 'Blocks' as const, value: b })),
...workflowBlocks
.filter((b) => (b.name || b.id).toLowerCase().includes(q))
.map((b) => ({ type: 'Workflow Blocks' as const, value: b })),
...templatesList
.filter((t) => (t.name || 'Untitled Template').toLowerCase().includes(q))
.map((t) => ({ type: 'Templates' as const, value: t })),
...logsList
.filter((l) => (l.workflowName || 'Untitled Workflow').toLowerCase().includes(q))
.map((l) => ({ type: 'Logs' as const, value: l })),
]
const result: Array<{ type: MentionFolderId | 'docs'; value: any }> = []
for (const folderId of FOLDER_ORDER) {
const filtered = filterFolderItems(folderId, q)
filtered.forEach((item) => {
result.push({ type: folderId, value: item })
})
}
if ('docs'.includes(q)) {
result.push({ type: 'docs', value: null })
}
return result
},
[pastChats, workflows, knowledgeBases, blocksList, workflowBlocks, templatesList, logsList]
[filterFolderItems]
)
/**
* Generic navigation helper for navigating through items
*/
const navigateItems = useCallback(
(
direction: 'up' | 'down',
itemCount: number,
setIndex: (fn: (prev: number) => number) => void
) => {
setIndex((prev) => {
const last = Math.max(0, itemCount - 1)
if (itemCount === 0) return 0
const next =
direction === 'down' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1
requestAnimationFrame(() => scrollActiveItemIntoView(next))
return next
})
},
[scrollActiveItemIntoView]
)
/**
@@ -169,143 +165,36 @@ export function useMentionKeyboard({
e.preventDefault()
const caretPos = getCaretPos()
const active = getActiveMentionQueryAtPosition(caretPos)
const mainQ = (!openSubmenuFor ? active?.query || '' : '').toLowerCase()
const mainQ = (!isInFolder ? active?.query || '' : '').toLowerCase()
const direction = e.key === 'ArrowDown' ? 'down' : 'up'
// When there's a query, we show aggregated filtered view (no folders)
const showAggregatedView = mainQ.length > 0
const aggregatedList = showAggregatedView ? buildAggregatedList(mainQ) : []
// When showing aggregated filtered view, navigate through the aggregated list
if (showAggregatedView && !openSubmenuFor) {
setSubmenuActiveIndex((prev) => {
const last = Math.max(0, aggregatedList.length - 1)
if (aggregatedList.length === 0) return 0
const next =
e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1
requestAnimationFrame(() => scrollActiveItemIntoView(next))
return next
})
if (showAggregatedView && !isInFolder) {
const aggregatedList = buildAggregatedList(mainQ)
navigateItems(direction, aggregatedList.length, setSubmenuActiveIndex)
return true
}
// Handle submenu navigation
if (openSubmenuFor === 'Chats') {
if (currentFolder && FOLDER_CONFIGS[currentFolder as MentionFolderId]) {
const q = getSubmenuQuery().toLowerCase()
const filtered = pastChats.filter((c) => (c.title || 'New Chat').toLowerCase().includes(q))
setSubmenuActiveIndex((prev) => {
const last = Math.max(0, filtered.length - 1)
if (filtered.length === 0) return 0
const next =
e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1
requestAnimationFrame(() => scrollActiveItemIntoView(next))
return next
})
} else if (openSubmenuFor === 'Workflows') {
const q = getSubmenuQuery().toLowerCase()
const filtered = workflows.filter((w) =>
(w.name || 'Untitled Workflow').toLowerCase().includes(q)
)
setSubmenuActiveIndex((prev) => {
const last = Math.max(0, filtered.length - 1)
if (filtered.length === 0) return 0
const next =
e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1
requestAnimationFrame(() => scrollActiveItemIntoView(next))
return next
})
} else if (openSubmenuFor === 'Knowledge') {
const q = getSubmenuQuery().toLowerCase()
const filtered = knowledgeBases.filter((k) =>
(k.name || 'Untitled').toLowerCase().includes(q)
)
setSubmenuActiveIndex((prev) => {
const last = Math.max(0, filtered.length - 1)
if (filtered.length === 0) return 0
const next =
e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1
requestAnimationFrame(() => scrollActiveItemIntoView(next))
return next
})
} else if (openSubmenuFor === 'Blocks') {
const q = getSubmenuQuery().toLowerCase()
const filtered = blocksList.filter((b) => (b.name || b.id).toLowerCase().includes(q))
setSubmenuActiveIndex((prev) => {
const last = Math.max(0, filtered.length - 1)
if (filtered.length === 0) return 0
const next =
e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1
requestAnimationFrame(() => scrollActiveItemIntoView(next))
return next
})
} else if (openSubmenuFor === 'Workflow Blocks') {
const q = getSubmenuQuery().toLowerCase()
const filtered = workflowBlocks.filter((b) => (b.name || b.id).toLowerCase().includes(q))
setSubmenuActiveIndex((prev) => {
const last = Math.max(0, filtered.length - 1)
if (filtered.length === 0) return 0
const next =
e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1
requestAnimationFrame(() => scrollActiveItemIntoView(next))
return next
})
} else if (openSubmenuFor === 'Templates') {
const q = getSubmenuQuery().toLowerCase()
const filtered = templatesList.filter((t) =>
(t.name || 'Untitled Template').toLowerCase().includes(q)
)
setSubmenuActiveIndex((prev) => {
const last = Math.max(0, filtered.length - 1)
if (filtered.length === 0) return 0
const next =
e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1
requestAnimationFrame(() => scrollActiveItemIntoView(next))
return next
})
} else if (openSubmenuFor === 'Logs') {
const q = getSubmenuQuery().toLowerCase()
const filtered = logsList.filter((l) =>
[l.workflowName, l.trigger || ''].join(' ').toLowerCase().includes(q)
)
setSubmenuActiveIndex((prev) => {
const last = Math.max(0, filtered.length - 1)
if (filtered.length === 0) return 0
const next =
e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1
requestAnimationFrame(() => scrollActiveItemIntoView(next))
return next
})
} else {
// Navigate through folder options when no query
const filteredMain = MENTION_OPTIONS.filter((o) => o.toLowerCase().includes(mainQ))
setMentionActiveIndex((prev) => {
const last = Math.max(0, filteredMain.length - 1)
if (filteredMain.length === 0) return 0
const next =
e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1
requestAnimationFrame(() => scrollActiveItemIntoView(next))
return next
})
const filtered = filterFolderItems(currentFolder as MentionFolderId, q)
navigateItems(direction, filtered.length, setSubmenuActiveIndex)
return true
}
navigateItems(direction, ROOT_MENU_ITEM_COUNT, setMentionActiveIndex)
return true
},
[
showMentionMenu,
openSubmenuFor,
mentionActiveIndex,
submenuActiveIndex,
isInFolder,
currentFolder,
buildAggregatedList,
pastChats,
workflows,
knowledgeBases,
blocksList,
workflowBlocks,
templatesList,
logsList,
filterFolderItems,
navigateItems,
getCaretPos,
getActiveMentionQueryAtPosition,
getSubmenuQuery,
scrollActiveItemIntoView,
setMentionActiveIndex,
setSubmenuActiveIndex,
]
@@ -316,65 +205,30 @@ export function useMentionKeyboard({
*/
const handleArrowRight = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
if (!showMentionMenu || e.key !== 'ArrowRight') return false
if (!showMentionMenu || e.key !== 'ArrowRight' || !mentionFolderNav) return false
const caretPos = getCaretPos()
const active = getActiveMentionQueryAtPosition(caretPos)
const mainQ = (active?.query || '').toLowerCase()
const showAggregatedView = mainQ.length > 0
// Don't handle arrow right in aggregated view (user is filtering, not navigating folders)
if (showAggregatedView) return false
if (mainQ.length > 0) return false
e.preventDefault()
const filteredMain = MENTION_OPTIONS.filter((o) => o.toLowerCase().includes(mainQ))
const selected = filteredMain[mentionActiveIndex]
if (selected === 'Chats') {
const isDocsSelected = mentionActiveIndex === FOLDER_ORDER.length
if (isDocsSelected) {
resetActiveMentionQuery()
setOpenSubmenuFor('Chats')
setSubmenuActiveIndex(0)
insertHandlers.insertDocsMention()
return true
}
const selectedFolderId = FOLDER_ORDER[mentionActiveIndex]
if (selectedFolderId) {
const config = FOLDER_CONFIGS[selectedFolderId]
resetActiveMentionQuery()
mentionFolderNav.openFolder(selectedFolderId, config.title)
setSubmenuQueryStart(getCaretPos())
void ensurePastChatsLoaded()
} else if (selected === 'Workflows') {
resetActiveMentionQuery()
setOpenSubmenuFor('Workflows')
setSubmenuActiveIndex(0)
setSubmenuQueryStart(getCaretPos())
void ensureWorkflowsLoaded()
} else if (selected === 'Knowledge') {
resetActiveMentionQuery()
setOpenSubmenuFor('Knowledge')
setSubmenuActiveIndex(0)
setSubmenuQueryStart(getCaretPos())
void ensureKnowledgeLoaded()
} else if (selected === 'Blocks') {
resetActiveMentionQuery()
setOpenSubmenuFor('Blocks')
setSubmenuActiveIndex(0)
setSubmenuQueryStart(getCaretPos())
void ensureBlocksLoaded()
} else if (selected === 'Workflow Blocks') {
resetActiveMentionQuery()
setOpenSubmenuFor('Workflow Blocks')
setSubmenuActiveIndex(0)
setSubmenuQueryStart(getCaretPos())
void ensureWorkflowBlocksLoaded()
} else if (selected === 'Docs') {
resetActiveMentionQuery()
insertDocsMention()
} else if (selected === 'Templates') {
resetActiveMentionQuery()
setOpenSubmenuFor('Templates')
setSubmenuActiveIndex(0)
setSubmenuQueryStart(getCaretPos())
void ensureTemplatesLoaded()
} else if (selected === 'Logs') {
resetActiveMentionQuery()
setOpenSubmenuFor('Logs')
setSubmenuActiveIndex(0)
setSubmenuQueryStart(getCaretPos())
void ensureLogsLoaded()
ensureFolderLoaded(selectedFolderId)
}
return true
@@ -382,21 +236,13 @@ export function useMentionKeyboard({
[
showMentionMenu,
mentionActiveIndex,
openSubmenuFor,
mentionFolderNav,
getCaretPos,
getActiveMentionQueryAtPosition,
resetActiveMentionQuery,
setOpenSubmenuFor,
setSubmenuActiveIndex,
setSubmenuQueryStart,
ensurePastChatsLoaded,
ensureWorkflowsLoaded,
ensureKnowledgeLoaded,
ensureBlocksLoaded,
ensureWorkflowBlocksLoaded,
ensureTemplatesLoaded,
ensureLogsLoaded,
insertDocsMention,
ensureFolderLoaded,
insertHandlers,
]
)
@@ -407,16 +253,16 @@ export function useMentionKeyboard({
(e: KeyboardEvent<HTMLTextAreaElement>) => {
if (!showMentionMenu || e.key !== 'ArrowLeft') return false
if (openSubmenuFor) {
if (isInFolder && mentionFolderNav) {
e.preventDefault()
setOpenSubmenuFor(null)
mentionFolderNav.closeFolder()
setSubmenuQueryStart(null)
return true
}
return false
},
[showMentionMenu, openSubmenuFor, setOpenSubmenuFor, setSubmenuQueryStart]
[showMentionMenu, isInFolder, mentionFolderNav, setSubmenuQueryStart]
)
/**
@@ -429,179 +275,74 @@ export function useMentionKeyboard({
e.preventDefault()
const caretPos = getCaretPos()
const active = getActiveMentionQueryAtPosition(caretPos)
const mainQ = (active?.query || '').toLowerCase()
const mainQ = (!isInFolder ? active?.query || '' : '').toLowerCase()
const showAggregatedView = mainQ.length > 0
const filteredMain = MENTION_OPTIONS.filter((o) => o.toLowerCase().includes(mainQ))
const selected = filteredMain[mentionActiveIndex]
// Handle selection in aggregated filtered view
if (showAggregatedView && !openSubmenuFor) {
if (showAggregatedView && !isInFolder) {
const aggregated = buildAggregatedList(mainQ)
const idx = Math.max(0, Math.min(submenuActiveIndex, aggregated.length - 1))
const chosen = aggregated[idx]
if (chosen) {
if (chosen.type === 'Chats') insertPastChatMention(chosen.value as ChatItem)
else if (chosen.type === 'Workflows') insertWorkflowMention(chosen.value as WorkflowItem)
else if (chosen.type === 'Knowledge')
insertKnowledgeMention(chosen.value as KnowledgeItem)
else if (chosen.type === 'Workflow Blocks')
insertWorkflowBlockMention(chosen.value as BlockItem)
else if (chosen.type === 'Blocks') insertBlockMention(chosen.value as BlockItem)
else if (chosen.type === 'Templates') insertTemplateMention(chosen.value as TemplateItem)
else if (chosen.type === 'Logs') insertLogMention(chosen.value as LogItem)
if (chosen.type === 'docs') {
insertHandlers.insertDocsMention()
} else {
const handler = insertHandlerMap[chosen.type]
handler(chosen.value)
}
}
return true
}
// Handle folder navigation when no query
if (!openSubmenuFor && selected === 'Chats') {
resetActiveMentionQuery()
setOpenSubmenuFor('Chats')
setSubmenuActiveIndex(0)
setSubmenuQueryStart(getCaretPos())
void ensurePastChatsLoaded()
} else if (openSubmenuFor === 'Chats') {
if (isInFolder && currentFolder && FOLDER_CONFIGS[currentFolder as MentionFolderId]) {
const folderId = currentFolder as MentionFolderId
const q = getSubmenuQuery().toLowerCase()
const filtered = pastChats.filter((c) => (c.title || 'New Chat').toLowerCase().includes(q))
const filtered = filterFolderItems(folderId, q)
if (filtered.length > 0) {
const chosen = filtered[Math.max(0, Math.min(submenuActiveIndex, filtered.length - 1))]
insertPastChatMention(chosen)
const handler = insertHandlerMap[folderId]
handler(chosen)
setSubmenuQueryStart(null)
}
} else if (!openSubmenuFor && selected === 'Workflows') {
return true
}
const isDocsSelected = mentionActiveIndex === FOLDER_ORDER.length
if (isDocsSelected) {
resetActiveMentionQuery()
setOpenSubmenuFor('Workflows')
insertHandlers.insertDocsMention()
return true
}
const selectedFolderId = FOLDER_ORDER[mentionActiveIndex]
if (selectedFolderId && mentionFolderNav) {
const config = FOLDER_CONFIGS[selectedFolderId]
resetActiveMentionQuery()
mentionFolderNav.openFolder(selectedFolderId, config.title)
setSubmenuActiveIndex(0)
setSubmenuQueryStart(getCaretPos())
void ensureWorkflowsLoaded()
} else if (openSubmenuFor === 'Workflows') {
const q = getSubmenuQuery().toLowerCase()
const filtered = workflows.filter((w) =>
(w.name || 'Untitled Workflow').toLowerCase().includes(q)
)
if (filtered.length > 0) {
const chosen = filtered[Math.max(0, Math.min(submenuActiveIndex, filtered.length - 1))]
insertWorkflowMention(chosen)
setSubmenuQueryStart(null)
}
} else if (!openSubmenuFor && selected === 'Knowledge') {
resetActiveMentionQuery()
setOpenSubmenuFor('Knowledge')
setSubmenuActiveIndex(0)
setSubmenuQueryStart(getCaretPos())
void ensureKnowledgeLoaded()
} else if (openSubmenuFor === 'Knowledge') {
const q = getSubmenuQuery().toLowerCase()
const filtered = knowledgeBases.filter((k) =>
(k.name || 'Untitled').toLowerCase().includes(q)
)
if (filtered.length > 0) {
const chosen = filtered[Math.max(0, Math.min(submenuActiveIndex, filtered.length - 1))]
insertKnowledgeMention(chosen)
setSubmenuQueryStart(null)
}
} else if (!openSubmenuFor && selected === 'Blocks') {
resetActiveMentionQuery()
setOpenSubmenuFor('Blocks')
setSubmenuActiveIndex(0)
setSubmenuQueryStart(getCaretPos())
void ensureBlocksLoaded()
} else if (openSubmenuFor === 'Blocks') {
const q = getSubmenuQuery().toLowerCase()
const filtered = blocksList.filter((b) => (b.name || b.id).toLowerCase().includes(q))
if (filtered.length > 0) {
const chosen = filtered[Math.max(0, Math.min(submenuActiveIndex, filtered.length - 1))]
insertBlockMention(chosen)
setSubmenuQueryStart(null)
}
} else if (!openSubmenuFor && selected === 'Workflow Blocks') {
resetActiveMentionQuery()
setOpenSubmenuFor('Workflow Blocks')
setSubmenuActiveIndex(0)
setSubmenuQueryStart(getCaretPos())
void ensureWorkflowBlocksLoaded()
} else if (openSubmenuFor === 'Workflow Blocks') {
const q = getSubmenuQuery().toLowerCase()
const filtered = workflowBlocks.filter((b) => (b.name || b.id).toLowerCase().includes(q))
if (filtered.length > 0) {
const chosen = filtered[Math.max(0, Math.min(submenuActiveIndex, filtered.length - 1))]
insertWorkflowBlockMention(chosen)
setSubmenuQueryStart(null)
}
} else if (!openSubmenuFor && selected === 'Docs') {
resetActiveMentionQuery()
insertDocsMention()
} else if (!openSubmenuFor && selected === 'Templates') {
resetActiveMentionQuery()
setOpenSubmenuFor('Templates')
setSubmenuActiveIndex(0)
setSubmenuQueryStart(getCaretPos())
void ensureTemplatesLoaded()
} else if (!openSubmenuFor && selected === 'Logs') {
resetActiveMentionQuery()
setOpenSubmenuFor('Logs')
setSubmenuActiveIndex(0)
setSubmenuQueryStart(getCaretPos())
void ensureLogsLoaded()
} else if (openSubmenuFor === 'Templates') {
const q = getSubmenuQuery().toLowerCase()
const filtered = templatesList.filter((t) =>
(t.name || 'Untitled Template').toLowerCase().includes(q)
)
if (filtered.length > 0) {
const chosen = filtered[Math.max(0, Math.min(submenuActiveIndex, filtered.length - 1))]
insertTemplateMention(chosen)
setSubmenuQueryStart(null)
}
} else if (openSubmenuFor === 'Logs') {
const q = getSubmenuQuery().toLowerCase()
const filtered = logsList.filter((l) =>
[l.workflowName, l.trigger || ''].join(' ').toLowerCase().includes(q)
)
if (filtered.length > 0) {
const chosen = filtered[Math.max(0, Math.min(submenuActiveIndex, filtered.length - 1))]
insertLogMention(chosen)
setSubmenuQueryStart(null)
}
ensureFolderLoaded(selectedFolderId)
}
return true
},
[
showMentionMenu,
openSubmenuFor,
isInFolder,
currentFolder,
mentionActiveIndex,
submenuActiveIndex,
mentionFolderNav,
buildAggregatedList,
pastChats,
workflows,
knowledgeBases,
blocksList,
workflowBlocks,
templatesList,
logsList,
filterFolderItems,
insertHandlerMap,
getCaretPos,
getActiveMentionQueryAtPosition,
getSubmenuQuery,
resetActiveMentionQuery,
setOpenSubmenuFor,
setSubmenuActiveIndex,
setSubmenuQueryStart,
ensurePastChatsLoaded,
ensureWorkflowsLoaded,
ensureKnowledgeLoaded,
ensureBlocksLoaded,
ensureWorkflowBlocksLoaded,
ensureTemplatesLoaded,
ensureLogsLoaded,
insertPastChatMention,
insertWorkflowMention,
insertKnowledgeMention,
insertBlockMention,
insertWorkflowBlockMention,
insertTemplateMention,
insertLogMention,
insertDocsMention,
ensureFolderLoaded,
insertHandlers,
]
)

View File

@@ -1,9 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { SCROLL_TOLERANCE } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
import type { ChatContext } from '@/stores/panel'
import { SCROLL_TOLERANCE } from '../constants'
const logger = createLogger('useMentionMenu')
interface UseMentionMenuProps {
/** Current message text */

View File

@@ -49,7 +49,6 @@ export function useTextareaAutoResize({
const styles = window.getComputedStyle(textarea)
// Copy all text rendering properties exactly (but NOT color - overlay needs visible text)
overlay.style.font = styles.font
overlay.style.fontSize = styles.fontSize
overlay.style.fontFamily = styles.fontFamily
@@ -66,7 +65,6 @@ export function useTextareaAutoResize({
overlay.style.textTransform = styles.textTransform
overlay.style.textIndent = styles.textIndent
// Copy box model properties exactly to ensure identical text flow
overlay.style.padding = styles.padding
overlay.style.paddingTop = styles.paddingTop
overlay.style.paddingRight = styles.paddingRight
@@ -80,7 +78,6 @@ export function useTextareaAutoResize({
overlay.style.border = styles.border
overlay.style.borderWidth = styles.borderWidth
// Copy text wrapping and breaking properties
overlay.style.whiteSpace = styles.whiteSpace
overlay.style.wordBreak = styles.wordBreak
overlay.style.wordWrap = styles.wordWrap
@@ -91,20 +88,17 @@ export function useTextareaAutoResize({
overlay.style.direction = styles.direction
overlay.style.hyphens = (styles as any).hyphens ?? ''
// Critical: Match dimensions exactly
const textareaWidth = textarea.clientWidth
const textareaHeight = textarea.clientHeight
overlay.style.width = `${textareaWidth}px`
overlay.style.height = `${textareaHeight}px`
// Match max-height behavior
const computedMaxHeight = styles.maxHeight
if (computedMaxHeight && computedMaxHeight !== 'none') {
overlay.style.maxHeight = computedMaxHeight
}
// Ensure scroll positions are perfectly synced
overlay.scrollTop = textarea.scrollTop
overlay.scrollLeft = textarea.scrollLeft
})
@@ -119,25 +113,20 @@ export function useTextareaAutoResize({
const overlay = overlayRef.current
if (!textarea || !overlay) return
// Store current cursor position to determine if user is typing at the end
const cursorPos = textarea.selectionStart ?? 0
const isAtEnd = cursorPos === message.length
const wasScrolledToBottom =
textarea.scrollHeight - textarea.scrollTop - textarea.clientHeight < 5
// Reset height to auto to get proper scrollHeight
textarea.style.height = 'auto'
overlay.style.height = 'auto'
// Force a reflow to ensure accurate scrollHeight
void textarea.offsetHeight
void overlay.offsetHeight
// Get the scroll height (this includes all content, including trailing newlines)
const scrollHeight = textarea.scrollHeight
const nextHeight = Math.min(scrollHeight, MAX_TEXTAREA_HEIGHT)
// Apply height to BOTH elements simultaneously
const heightString = `${nextHeight}px`
const overflowString = scrollHeight > MAX_TEXTAREA_HEIGHT ? 'auto' : 'hidden'
@@ -146,22 +135,18 @@ export function useTextareaAutoResize({
overlay.style.height = heightString
overlay.style.overflowY = overflowString
// Force another reflow after height change
void textarea.offsetHeight
void overlay.offsetHeight
// Maintain scroll behavior: if user was at bottom or typing at end, keep them at bottom
if ((isAtEnd || wasScrolledToBottom) && scrollHeight > nextHeight) {
const scrollValue = scrollHeight
textarea.scrollTop = scrollValue
overlay.scrollTop = scrollValue
} else {
// Otherwise, sync scroll positions
overlay.scrollTop = textarea.scrollTop
overlay.scrollLeft = textarea.scrollLeft
}
// Sync all other styles after height change
syncOverlayStyles.current()
}, [message, selectedContexts, textareaRef])
@@ -192,19 +177,15 @@ export function useTextareaAutoResize({
const overlay = overlayRef.current
if (!textarea || !overlay || !containerRef || typeof window === 'undefined') return
// Initial sync
syncOverlayStyles.current()
// Observe the CONTAINER - when pills wrap, container height changes
if (typeof ResizeObserver !== 'undefined' && !containerResizeObserverRef.current) {
containerResizeObserverRef.current = new ResizeObserver(() => {
// Container size changed (pills wrapped) - sync immediately
syncOverlayStyles.current()
})
containerResizeObserverRef.current.observe(containerRef)
}
// ALSO observe the textarea for its own size changes
if (typeof ResizeObserver !== 'undefined' && !textareaResizeObserverRef.current) {
textareaResizeObserverRef.current = new ResizeObserver(() => {
syncOverlayStyles.current()
@@ -212,7 +193,6 @@ export function useTextareaAutoResize({
textareaResizeObserverRef.current.observe(textarea)
}
// Setup MutationObserver to detect style changes
const mutationObserver = new MutationObserver(() => {
syncOverlayStyles.current()
})
@@ -221,11 +201,9 @@ export function useTextareaAutoResize({
attributeFilter: ['style', 'class'],
})
// Listen to window resize events (for browser window resizing)
const handleResize = () => syncOverlayStyles.current()
window.addEventListener('resize', handleResize)
// Cleanup
return () => {
mutationObserver.disconnect()
window.removeEventListener('resize', handleResize)

View File

@@ -18,12 +18,21 @@ import { cn } from '@/lib/core/utils/cn'
import {
AttachedFilesDisplay,
ContextPills,
type MentionFolderNav,
MentionMenu,
ModelSelector,
ModeSelector,
type SlashFolderNav,
SlashMenu,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components'
import { NEAR_TOP_THRESHOLD } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
import {
ALL_COMMAND_IDS,
getCommandDisplayLabel,
getNextIndex,
NEAR_TOP_THRESHOLD,
TOP_LEVEL_COMMANDS,
WEB_COMMANDS,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
import {
useContextManagement,
useFileAttachments,
@@ -40,24 +49,6 @@ import { useCopilotStore } from '@/stores/panel'
const logger = createLogger('CopilotUserInput')
const TOP_LEVEL_COMMANDS = ['fast', 'research', 'superagent'] as const
const WEB_COMMANDS = ['search', 'read', 'scrape', 'crawl'] as const
const ALL_COMMANDS = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS]
const COMMAND_DISPLAY_LABELS: Record<string, string> = {
superagent: 'Actions',
}
/**
* Calculates the next index for circular navigation (wraps around at bounds)
*/
function getNextIndex(current: number, direction: 'up' | 'down', maxIndex: number): number {
if (direction === 'down') {
return current >= maxIndex ? 0 : current + 1
}
return current <= 0 ? maxIndex : current - 1
}
interface UserInputProps {
onSubmit: (
message: string,
@@ -144,6 +135,8 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null)
const [inputContainerRef, setInputContainerRef] = useState<HTMLDivElement | null>(null)
const [showSlashMenu, setShowSlashMenu] = useState(false)
const [slashFolderNav, setSlashFolderNav] = useState<SlashFolderNav | null>(null)
const [mentionFolderNav, setMentionFolderNav] = useState<MentionFolderNav | null>(null)
const message = controlledValue !== undefined ? controlledValue : internalMessage
const setMessage =
@@ -198,12 +191,14 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
workflowId: workflowId || null,
selectedContexts: contextManagement.selectedContexts,
onContextAdd: contextManagement.addContext,
mentionFolderNav,
})
const mentionKeyboard = useMentionKeyboard({
mentionMenu,
mentionData,
insertHandlers,
mentionFolderNav,
})
useImperativeHandle(
@@ -222,13 +217,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
[mentionMenu.textareaRef]
)
useEffect(() => {
if (workflowId) {
void mentionData.ensureWorkflowsLoaded()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workflowId])
useEffect(() => {
const checkPosition = () => {
if (containerRef) {
@@ -264,7 +252,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
}, [mentionMenu.showMentionMenu, containerRef])
useEffect(() => {
if (!mentionMenu.showMentionMenu || mentionMenu.openSubmenuFor) {
if (!mentionMenu.showMentionMenu || mentionFolderNav?.isInFolder) {
return
}
@@ -275,8 +263,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
if (q && q.length > 0) {
void mentionData.ensurePastChatsLoaded()
void mentionData.ensureWorkflowsLoaded()
void mentionData.ensureWorkflowBlocksLoaded()
// workflows and workflow-blocks auto-load from stores
void mentionData.ensureKnowledgeLoaded()
void mentionData.ensureBlocksLoaded()
void mentionData.ensureTemplatesLoaded()
@@ -286,15 +273,15 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(0))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mentionMenu.showMentionMenu, mentionMenu.openSubmenuFor, message])
}, [mentionMenu.showMentionMenu, mentionFolderNav?.isInFolder, message])
useEffect(() => {
if (mentionMenu.openSubmenuFor) {
if (mentionFolderNav?.isInFolder) {
mentionMenu.setSubmenuActiveIndex(0)
requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(0))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mentionMenu.openSubmenuFor])
}, [mentionFolderNav?.isInFolder])
const handleSubmit = useCallback(
async (overrideMessage?: string, options: { preserveInput?: boolean } = {}) => {
@@ -372,8 +359,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
const handleSlashCommandSelect = useCallback(
(command: string) => {
const displayLabel =
COMMAND_DISPLAY_LABELS[command] || command.charAt(0).toUpperCase() + command.slice(1)
const displayLabel = getCommandDisplayLabel(command)
mentionMenu.replaceActiveSlashWith(displayLabel)
contextManagement.addContext({
kind: 'slash_command',
@@ -391,9 +377,11 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Escape' && (mentionMenu.showMentionMenu || showSlashMenu)) {
e.preventDefault()
if (mentionMenu.openSubmenuFor) {
mentionMenu.setOpenSubmenuFor(null)
if (mentionFolderNav?.isInFolder) {
mentionFolderNav.closeFolder()
mentionMenu.setSubmenuQueryStart(null)
} else if (slashFolderNav?.isInFolder) {
slashFolderNav.closeFolder()
} else {
mentionMenu.closeMentionMenu()
setShowSlashMenu(false)
@@ -407,18 +395,19 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
const query = activeSlash?.query.trim().toLowerCase() || ''
const showAggregatedView = query.length > 0
const direction = e.key === 'ArrowDown' ? 'down' : 'up'
const isInFolder = slashFolderNav?.isInFolder ?? false
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault()
if (mentionMenu.openSubmenuFor === 'Web') {
if (isInFolder) {
mentionMenu.setSubmenuActiveIndex((prev) => {
const next = getNextIndex(prev, direction, WEB_COMMANDS.length - 1)
requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(next))
return next
})
} else if (showAggregatedView) {
const filtered = ALL_COMMANDS.filter((cmd) => cmd.includes(query))
const filtered = ALL_COMMAND_IDS.filter((cmd) => cmd.includes(query))
mentionMenu.setSubmenuActiveIndex((prev) => {
if (filtered.length === 0) return 0
const next = getNextIndex(prev, direction, filtered.length - 1)
@@ -437,10 +426,9 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
if (e.key === 'ArrowRight') {
e.preventDefault()
if (!showAggregatedView && !mentionMenu.openSubmenuFor) {
if (!showAggregatedView && !isInFolder) {
if (mentionMenu.mentionActiveIndex === TOP_LEVEL_COMMANDS.length) {
mentionMenu.setOpenSubmenuFor('Web')
mentionMenu.setSubmenuActiveIndex(0)
slashFolderNav?.openWebFolder()
}
}
return
@@ -448,8 +436,8 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
if (e.key === 'ArrowLeft') {
e.preventDefault()
if (mentionMenu.openSubmenuFor) {
mentionMenu.setOpenSubmenuFor(null)
if (isInFolder) {
slashFolderNav?.closeFolder()
}
return
}
@@ -466,13 +454,14 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
const activeSlash = mentionMenu.getActiveSlashQueryAtPosition(caretPos, message)
const query = activeSlash?.query.trim().toLowerCase() || ''
const showAggregatedView = query.length > 0
const isInFolder = slashFolderNav?.isInFolder ?? false
if (mentionMenu.openSubmenuFor === 'Web') {
if (isInFolder) {
const selectedCommand =
WEB_COMMANDS[mentionMenu.submenuActiveIndex] || WEB_COMMANDS[0]
WEB_COMMANDS[mentionMenu.submenuActiveIndex]?.id || WEB_COMMANDS[0].id
handleSlashCommandSelect(selectedCommand)
} else if (showAggregatedView) {
const filtered = ALL_COMMANDS.filter((cmd) => cmd.includes(query))
const filtered = ALL_COMMAND_IDS.filter((cmd) => cmd.includes(query))
if (filtered.length > 0) {
const selectedCommand = filtered[mentionMenu.submenuActiveIndex] || filtered[0]
handleSlashCommandSelect(selectedCommand)
@@ -480,10 +469,9 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
} else {
const selectedIndex = mentionMenu.mentionActiveIndex
if (selectedIndex < TOP_LEVEL_COMMANDS.length) {
handleSlashCommandSelect(TOP_LEVEL_COMMANDS[selectedIndex])
handleSlashCommandSelect(TOP_LEVEL_COMMANDS[selectedIndex].id)
} else if (selectedIndex === TOP_LEVEL_COMMANDS.length) {
mentionMenu.setOpenSubmenuFor('Web')
mentionMenu.setSubmenuActiveIndex(0)
slashFolderNav?.openWebFolder()
}
}
return
@@ -568,6 +556,8 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
message,
mentionTokensWithContext,
showSlashMenu,
slashFolderNav,
mentionFolderNav,
]
)
@@ -586,7 +576,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
setShowSlashMenu(false)
mentionMenu.setShowMentionMenu(true)
mentionMenu.setInAggregated(false)
if (mentionMenu.openSubmenuFor) {
if (mentionFolderNav?.isInFolder) {
mentionMenu.setSubmenuActiveIndex(0)
} else {
mentionMenu.setMentionActiveIndex(0)
@@ -605,7 +595,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
setShowSlashMenu(false)
}
},
[setMessage, mentionMenu, disableMentions]
[setMessage, mentionMenu, disableMentions, mentionFolderNav]
)
const handleSelectAdjust = useCallback(() => {
@@ -838,6 +828,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
mentionData={mentionData}
message={message}
insertHandlers={insertHandlers}
onFolderNavChange={setMentionFolderNav}
/>,
document.body
)}
@@ -850,6 +841,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
mentionMenu={mentionMenu}
message={message}
onSelectCommand={handleSlashCommandSelect}
onFolderNavChange={setSlashFolderNav}
/>,
document.body
)}

View File

@@ -0,0 +1,149 @@
import {
FOLDER_CONFIGS,
type MentionFolderId,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
import type { MentionDataReturn } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data'
import type { ChatContext } from '@/stores/panel'
/**
* Gets the data array for a folder ID from mentionData.
* Uses FOLDER_CONFIGS as the source of truth for key mapping.
* Returns any[] since item types vary by folder and are used with dynamic config.filterFn
*/
export function getFolderData(mentionData: MentionDataReturn, folderId: MentionFolderId): any[] {
const config = FOLDER_CONFIGS[folderId]
return (mentionData[config.dataKey as keyof MentionDataReturn] as any[]) || []
}
/**
* Gets the loading state for a folder ID from mentionData.
* Uses FOLDER_CONFIGS as the source of truth for key mapping.
*/
export function getFolderLoading(
mentionData: MentionDataReturn,
folderId: MentionFolderId
): boolean {
const config = FOLDER_CONFIGS[folderId]
return mentionData[config.loadingKey as keyof MentionDataReturn] as boolean
}
/**
* Gets the ensure loaded function for a folder ID from mentionData.
* Uses FOLDER_CONFIGS as the source of truth for key mapping.
*/
export function getFolderEnsureLoaded(
mentionData: MentionDataReturn,
folderId: MentionFolderId
): (() => Promise<void>) | undefined {
const config = FOLDER_CONFIGS[folderId]
if (!config.ensureLoadedKey) return undefined
return mentionData[config.ensureLoadedKey as keyof MentionDataReturn] as
| (() => Promise<void>)
| undefined
}
/**
* Extract specific ChatContext types for type-safe narrowing
*/
type PastChatContext = Extract<ChatContext, { kind: 'past_chat' }>
type WorkflowContext = Extract<ChatContext, { kind: 'workflow' }>
type CurrentWorkflowContext = Extract<ChatContext, { kind: 'current_workflow' }>
type BlocksContext = Extract<ChatContext, { kind: 'blocks' }>
type WorkflowBlockContext = Extract<ChatContext, { kind: 'workflow_block' }>
type KnowledgeContext = Extract<ChatContext, { kind: 'knowledge' }>
type TemplatesContext = Extract<ChatContext, { kind: 'templates' }>
type LogsContext = Extract<ChatContext, { kind: 'logs' }>
type SlashCommandContext = Extract<ChatContext, { kind: 'slash_command' }>
/**
* Checks if two contexts of the same kind are equal by their ID fields.
* Assumes c.kind === context.kind (must be checked before calling).
*/
export function areContextsEqual(c: ChatContext, context: ChatContext): boolean {
switch (c.kind) {
case 'past_chat': {
const ctx = context as PastChatContext
return c.chatId === ctx.chatId
}
case 'workflow': {
const ctx = context as WorkflowContext
return c.workflowId === ctx.workflowId
}
case 'current_workflow': {
const ctx = context as CurrentWorkflowContext
return c.workflowId === ctx.workflowId
}
case 'blocks': {
const ctx = context as BlocksContext
const existingIds = c.blockIds || []
const newIds = ctx.blockIds || []
return existingIds.some((id) => newIds.includes(id))
}
case 'workflow_block': {
const ctx = context as WorkflowBlockContext
return c.workflowId === ctx.workflowId && c.blockId === ctx.blockId
}
case 'knowledge': {
const ctx = context as KnowledgeContext
return c.knowledgeId === ctx.knowledgeId
}
case 'templates': {
const ctx = context as TemplatesContext
return c.templateId === ctx.templateId
}
case 'logs': {
const ctx = context as LogsContext
return c.executionId === ctx.executionId
}
case 'docs':
return true // Only one docs context allowed
case 'slash_command': {
const ctx = context as SlashCommandContext
return c.command === ctx.command
}
default:
return false
}
}
/**
* Removes a context from a list, returning a new filtered list.
*/
export function filterOutContext(
contexts: ChatContext[],
contextToRemove: ChatContext
): ChatContext[] {
return contexts.filter((c) => {
if (c.kind !== contextToRemove.kind) return true
return !areContextsEqual(c, contextToRemove)
})
}
/**
* Checks if a context already exists in selected contexts.
*
* The token system uses @label format, so we cannot have duplicate labels
* regardless of kind or ID differences.
*
* @param context - Context to check
* @param selectedContexts - Currently selected contexts
* @returns True if context already exists or label is already used
*/
export function isContextAlreadySelected(
context: ChatContext,
selectedContexts: ChatContext[]
): boolean {
return selectedContexts.some((c) => {
// CRITICAL: Check label collision FIRST
// The token system uses @label format, so we cannot have duplicate labels
// regardless of kind or ID differences
if (c.label && context.label && c.label === context.label) {
return true
}
// Secondary check: exact duplicate by ID fields
if (c.kind !== context.kind) return false
return areContextsEqual(c, context)
})
}

View File

@@ -20,7 +20,13 @@ import {
extractFieldsFromSchema,
parseResponseFormatSafely,
} from '@/lib/core/utils/response-format'
import { getBlockOutputPaths, getBlockOutputType } from '@/lib/workflows/blocks/block-outputs'
import {
getBlockOutputPaths,
getBlockOutputType,
getOutputPathsFromSchema,
getToolOutputPaths,
getToolOutputType,
} from '@/lib/workflows/blocks/block-outputs'
import { TRIGGER_TYPES } from '@/lib/workflows/triggers/triggers'
import { KeyboardNavigationHandler } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/components/keyboard-navigation-handler'
import type {
@@ -38,7 +44,6 @@ import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { normalizeName } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { BlockState } from '@/stores/workflows/workflow/types'
import { getTool } from '@/tools/utils'
const logger = createLogger('TagDropdown')
@@ -68,6 +73,12 @@ interface TagDropdownProps {
inputRef?: React.RefObject<HTMLTextAreaElement | HTMLInputElement>
}
interface TagComputationResult {
tags: string[]
variableInfoMap: Record<string, { type: string; id: string }>
blockTagGroups: BlockTagGroup[]
}
/**
* Checks if the tag trigger (`<`) should show the tag dropdown.
*
@@ -218,161 +229,6 @@ const getOutputTypeForPath = (
return 'any'
}
/**
* Recursively generates all output paths from an outputs schema.
*
* @remarks
* Traverses nested objects and arrays to build dot-separated paths
* for all leaf values in the schema.
*
* @param outputs - The outputs schema object
* @param prefix - Current path prefix for recursion
* @returns Array of dot-separated paths to all output fields
*/
const generateOutputPaths = (outputs: Record<string, any>, prefix = ''): string[] => {
const paths: string[] = []
for (const [key, value] of Object.entries(outputs)) {
const currentPath = prefix ? `${prefix}.${key}` : key
if (typeof value === 'string') {
paths.push(currentPath)
} else if (typeof value === 'object' && value !== null) {
if ('type' in value && typeof value.type === 'string') {
const hasNestedProperties =
((value.type === 'object' || value.type === 'json') && value.properties) ||
(value.type === 'array' && value.items?.properties) ||
(value.type === 'array' &&
value.items &&
typeof value.items === 'object' &&
!('type' in value.items))
if (!hasNestedProperties) {
paths.push(currentPath)
}
if ((value.type === 'object' || value.type === 'json') && value.properties) {
paths.push(...generateOutputPaths(value.properties, currentPath))
} else if (value.type === 'array' && value.items?.properties) {
paths.push(...generateOutputPaths(value.items.properties, currentPath))
} else if (
value.type === 'array' &&
value.items &&
typeof value.items === 'object' &&
!('type' in value.items)
) {
paths.push(...generateOutputPaths(value.items, currentPath))
}
} else {
const subPaths = generateOutputPaths(value, currentPath)
paths.push(...subPaths)
}
} else {
paths.push(currentPath)
}
}
return paths
}
/**
* Recursively generates all output paths with their types from an outputs schema.
*
* @remarks
* Similar to generateOutputPaths but also captures the type information
* for each path, useful for displaying type hints in the UI.
*
* @param outputs - The outputs schema object
* @param prefix - Current path prefix for recursion
* @returns Array of objects containing path and type for each output field
*/
const generateOutputPathsWithTypes = (
outputs: Record<string, any>,
prefix = ''
): Array<{ path: string; type: string }> => {
const paths: Array<{ path: string; type: string }> = []
for (const [key, value] of Object.entries(outputs)) {
const currentPath = prefix ? `${prefix}.${key}` : key
if (typeof value === 'string') {
paths.push({ path: currentPath, type: value })
} else if (typeof value === 'object' && value !== null) {
if ('type' in value && typeof value.type === 'string') {
if (value.type === 'array' && value.items?.properties) {
paths.push({ path: currentPath, type: 'array' })
const subPaths = generateOutputPathsWithTypes(value.items.properties, currentPath)
paths.push(...subPaths)
} else if ((value.type === 'object' || value.type === 'json') && value.properties) {
paths.push({ path: currentPath, type: value.type })
const subPaths = generateOutputPathsWithTypes(value.properties, currentPath)
paths.push(...subPaths)
} else {
paths.push({ path: currentPath, type: value.type })
}
} else {
const subPaths = generateOutputPathsWithTypes(value, currentPath)
paths.push(...subPaths)
}
} else {
paths.push({ path: currentPath, type: 'any' })
}
}
return paths
}
/**
* Generates output paths for a tool-based block.
*
* @param blockConfig - The block configuration containing tools config
* @param operation - The selected operation for the tool
* @returns Array of output paths for the tool, or empty array on error
*/
const generateToolOutputPaths = (blockConfig: BlockConfig, operation: string): string[] => {
if (!blockConfig?.tools?.config?.tool) return []
try {
const toolId = blockConfig.tools.config.tool({ operation })
if (!toolId) return []
const toolConfig = getTool(toolId)
if (!toolConfig?.outputs) return []
return generateOutputPaths(toolConfig.outputs)
} catch (error) {
logger.warn('Failed to get tool outputs for operation', { operation, error })
return []
}
}
/**
* Gets the output type for a specific path in a tool's outputs.
*
* @param blockConfig - The block configuration containing tools config
* @param operation - The selected operation for the tool
* @param path - The dot-separated path to the output field
* @returns The type of the output field, or 'any' if not found
*/
const getToolOutputType = (blockConfig: BlockConfig, operation: string, path: string): string => {
if (!blockConfig?.tools?.config?.tool) return 'any'
try {
const toolId = blockConfig.tools.config.tool({ operation })
if (!toolId) return 'any'
const toolConfig = getTool(toolId)
if (!toolConfig?.outputs) return 'any'
const pathsWithTypes = generateOutputPathsWithTypes(toolConfig.outputs)
const matchingPath = pathsWithTypes.find((p) => p.path === path)
return matchingPath?.type || 'any'
} catch (error) {
logger.warn('Failed to get tool output type for path', { path, error })
return 'any'
}
}
/**
* Calculates the viewport position of the caret in a textarea/input.
*
@@ -601,14 +457,16 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
[inputValue, cursorPosition]
)
const emptyVariableInfoMap: Record<string, { type: string; id: string }> = {}
/**
* Computes tags, variable info, and block tag groups
*/
const { tags, variableInfoMap, blockTagGroups } = useMemo(() => {
const { tags, variableInfoMap, blockTagGroups } = useMemo<TagComputationResult>(() => {
if (activeSourceBlockId) {
const sourceBlock = blocks[activeSourceBlockId]
if (!sourceBlock) {
return { tags: [], variableInfoMap: {}, blockTagGroups: [] }
return { tags: [], variableInfoMap: emptyVariableInfoMap, blockTagGroups: [] }
}
const blockConfig = getBlock(sourceBlock.type)
@@ -619,7 +477,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
const blockName = sourceBlock.name || sourceBlock.type
const normalizedBlockName = normalizeName(blockName)
const outputPaths = generateOutputPaths(mockConfig.outputs)
const outputPaths = getOutputPathsFromSchema(mockConfig.outputs)
const blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
const blockTagGroups: BlockTagGroup[] = [
@@ -632,9 +490,9 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
},
]
return { tags: blockTags, variableInfoMap: {}, blockTagGroups }
return { tags: blockTags, variableInfoMap: emptyVariableInfoMap, blockTagGroups }
}
return { tags: [], variableInfoMap: {}, blockTagGroups: [] }
return { tags: [], variableInfoMap: emptyVariableInfoMap, blockTagGroups: [] }
}
const blockName = sourceBlock.name || sourceBlock.type
@@ -777,7 +635,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
const operationValue =
mergedSubBlocks?.operation?.value ?? getSubBlockValue(activeSourceBlockId, 'operation')
const toolOutputPaths = operationValue
? generateToolOutputPaths(blockConfig, operationValue)
? getToolOutputPaths(blockConfig, operationValue)
: []
if (toolOutputPaths.length > 0) {
@@ -810,12 +668,12 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
},
]
return { tags: blockTags, variableInfoMap: {}, blockTagGroups }
return { tags: blockTags, variableInfoMap: emptyVariableInfoMap, blockTagGroups }
}
const hasInvalidBlocks = Object.values(blocks).some((block) => !block || !block.type)
if (hasInvalidBlocks) {
return { tags: [], variableInfoMap: {}, blockTagGroups: [] }
return { tags: [], variableInfoMap: emptyVariableInfoMap, blockTagGroups: [] }
}
const starterBlock = Object.values(blocks).find((block) => block.type === 'starter')
@@ -981,7 +839,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
const blockName = accessibleBlock.name || accessibleBlock.type
const normalizedBlockName = normalizeName(blockName)
const outputPaths = generateOutputPaths(mockConfig.outputs)
const outputPaths = getOutputPathsFromSchema(mockConfig.outputs)
let blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
blockTags = ensureRootTag(blockTags, normalizedBlockName)
@@ -1109,7 +967,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
const operationValue =
mergedSubBlocks?.operation?.value ?? getSubBlockValue(accessibleBlockId, 'operation')
const toolOutputPaths = operationValue
? generateToolOutputPaths(blockConfig, operationValue)
? getToolOutputPaths(blockConfig, operationValue)
: []
if (toolOutputPaths.length > 0) {
@@ -1183,7 +1041,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
const filteredTags = useMemo(() => {
if (!searchTerm) return tags
return tags.filter((tag) => tag.toLowerCase().includes(searchTerm))
return tags.filter((tag: string) => tag.toLowerCase().includes(searchTerm))
}, [tags, searchTerm])
const { variableTags, filteredBlockTagGroups } = useMemo(() => {

View File

@@ -36,6 +36,7 @@ import {
Tooltip,
} from '@/components/emcn'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { formatTimeWithSeconds } from '@/lib/core/utils/formatting'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
import {
@@ -82,18 +83,6 @@ const COLUMN_WIDTHS = {
OUTPUT_PANEL: 'w-[400px]',
} as const
/**
* Color palette for run IDs - matching code syntax highlighting colors
*/
const RUN_ID_COLORS = [
{ text: '#4ADE80' }, // Green
{ text: '#F472B6' }, // Pink
{ text: '#60C5FF' }, // Blue
{ text: '#FF8533' }, // Orange
{ text: '#C084FC' }, // Purple
{ text: '#FCD34D' }, // Yellow
] as const
/**
* Shared styling constants
*/
@@ -183,22 +172,6 @@ const ToggleButton = ({
</Button>
)
/**
* Formats timestamp to H:MM:SS AM/PM TZ format
*/
const formatTimestamp = (timestamp: string): string => {
const date = new Date(timestamp)
const fullString = date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
hour12: true,
timeZoneName: 'short',
})
// Format: "5:54:55 PM PST" - return as is
return fullString
}
/**
* Truncates execution ID for display as run ID
*/
@@ -208,16 +181,25 @@ const formatRunId = (executionId?: string): string => {
}
/**
* Gets color for a run ID based on its index in the execution ID order map
* Run ID colors
*/
const getRunIdColor = (
executionId: string | undefined,
executionIdOrderMap: Map<string, number>
) => {
const RUN_ID_COLORS = [
'#4ADE80', // Green
'#F472B6', // Pink
'#60C5FF', // Blue
'#FF8533', // Orange
'#C084FC', // Purple
'#EAB308', // Yellow
'#2DD4BF', // Teal
'#FB7185', // Rose
] as const
/**
* Gets color for a run ID from the precomputed color map.
*/
const getRunIdColor = (executionId: string | undefined, colorMap: Map<string, string>) => {
if (!executionId) return null
const colorIndex = executionIdOrderMap.get(executionId)
if (colorIndex === undefined) return null
return RUN_ID_COLORS[colorIndex % RUN_ID_COLORS.length]
return colorMap.get(executionId) ?? null
}
/**
@@ -464,25 +446,52 @@ export function Terminal() {
}, [allWorkflowEntries])
/**
* Create stable execution ID to color index mapping based on order of first appearance.
* Once an execution ID is assigned a color index, it keeps that index.
* Uses all workflow entries to maintain consistent colors regardless of active filters.
* Track color offset - increments when old executions are trimmed
* so remaining executions keep their colors.
*/
const executionIdOrderMap = useMemo(() => {
const orderMap = new Map<string, number>()
let colorIndex = 0
const colorStateRef = useRef<{ executionIds: string[]; offset: number }>({
executionIds: [],
offset: 0,
})
// Process entries in reverse order (oldest first) since entries array is newest-first
// Use allWorkflowEntries to ensure colors remain consistent when filters change
/**
* Compute colors for each execution ID using sequential assignment.
* Colors cycle through RUN_ID_COLORS based on position + offset.
* When old executions are trimmed, offset increments to preserve colors.
*/
const executionColorMap = useMemo(() => {
const currentIds: string[] = []
const seen = new Set<string>()
for (let i = allWorkflowEntries.length - 1; i >= 0; i--) {
const entry = allWorkflowEntries[i]
if (entry.executionId && !orderMap.has(entry.executionId)) {
orderMap.set(entry.executionId, colorIndex)
colorIndex++
const execId = allWorkflowEntries[i].executionId
if (execId && !seen.has(execId)) {
currentIds.push(execId)
seen.add(execId)
}
}
return orderMap
const { executionIds: prevIds, offset: prevOffset } = colorStateRef.current
let newOffset = prevOffset
if (prevIds.length > 0 && currentIds.length > 0) {
const currentOldest = currentIds[0]
if (prevIds[0] !== currentOldest) {
const trimmedCount = prevIds.indexOf(currentOldest)
if (trimmedCount > 0) {
newOffset = (prevOffset + trimmedCount) % RUN_ID_COLORS.length
}
}
}
const colorMap = new Map<string, string>()
for (let i = 0; i < currentIds.length; i++) {
const colorIndex = (newOffset + i) % RUN_ID_COLORS.length
colorMap.set(currentIds[i], RUN_ID_COLORS[colorIndex])
}
colorStateRef.current = { executionIds: currentIds, offset: newOffset }
return colorMap
}, [allWorkflowEntries])
/**
@@ -1128,7 +1137,7 @@ export function Terminal() {
<PopoverScrollArea style={{ maxHeight: '140px' }}>
{uniqueRunIds.map((runId, index) => {
const isSelected = filters.runIds.has(runId)
const runIdColor = getRunIdColor(runId, executionIdOrderMap)
const runIdColor = getRunIdColor(runId, executionColorMap)
return (
<PopoverItem
@@ -1139,7 +1148,7 @@ export function Terminal() {
>
<span
className='flex-1 font-mono text-[12px]'
style={{ color: runIdColor?.text || '#D2D2D2' }}
style={{ color: runIdColor || '#D2D2D2' }}
>
{formatRunId(runId)}
</span>
@@ -1335,7 +1344,7 @@ export function Terminal() {
const statusInfo = getStatusInfo(entry.success, entry.error)
const isSelected = selectedEntry?.id === entry.id
const BlockIcon = getBlockIcon(entry.blockType)
const runIdColor = getRunIdColor(entry.executionId, executionIdOrderMap)
const runIdColor = getRunIdColor(entry.executionId, executionColorMap)
return (
<div
@@ -1385,7 +1394,7 @@ export function Terminal() {
COLUMN_BASE_CLASS,
'truncate font-medium font-mono text-[12px]'
)}
style={{ color: runIdColor?.text || '#D2D2D2' }}
style={{ color: runIdColor || '#D2D2D2' }}
>
{formatRunId(entry.executionId)}
</span>
@@ -1411,7 +1420,7 @@ export function Terminal() {
ROW_TEXT_CLASS
)}
>
{formatTimestamp(entry.timestamp)}
{formatTimeWithSeconds(new Date(entry.timestamp))}
</span>
</div>
)

View File

@@ -172,7 +172,7 @@ async function executeWebhookJobInternal(
const workflowVariables = (wfRows[0]?.variables as Record<string, any>) || {}
// Merge subblock states (matching workflow-execution pattern)
const mergedStates = mergeSubblockState(blocks, {})
const mergedStates = mergeSubblockState(blocks)
// Create serialized workflow
const serializer = new Serializer()

View File

@@ -0,0 +1,292 @@
import { LangsmithIcon } from '@/components/icons'
import { AuthMode, type BlockConfig } from '@/blocks/types'
import type { LangsmithResponse } from '@/tools/langsmith/types'
export const LangsmithBlock: BlockConfig<LangsmithResponse> = {
type: 'langsmith',
name: 'LangSmith',
description: 'Forward workflow runs to LangSmith for observability',
longDescription:
'Send run data to LangSmith to trace executions, attach metadata, and monitor workflow performance.',
docsLink: 'https://docs.sim.ai/tools/langsmith',
category: 'tools',
bgColor: '#181C1E',
icon: LangsmithIcon,
authMode: AuthMode.ApiKey,
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [
{ label: 'Create Run', id: 'create_run' },
{ label: 'Create Runs Batch', id: 'create_runs_batch' },
],
value: () => 'create_run',
},
{
id: 'apiKey',
title: 'API Key',
type: 'short-input',
placeholder: 'Enter your LangSmith API key',
password: true,
required: true,
},
{
id: 'id',
title: 'Run ID',
type: 'short-input',
placeholder: 'Auto-generated if blank',
condition: { field: 'operation', value: 'create_run' },
},
{
id: 'name',
title: 'Name',
type: 'short-input',
placeholder: 'Run name',
required: { field: 'operation', value: 'create_run' },
condition: { field: 'operation', value: 'create_run' },
},
{
id: 'run_type',
title: 'Run Type',
type: 'dropdown',
options: [
{ label: 'Chain', id: 'chain' },
{ label: 'Tool', id: 'tool' },
{ label: 'LLM', id: 'llm' },
{ label: 'Retriever', id: 'retriever' },
{ label: 'Embedding', id: 'embedding' },
{ label: 'Prompt', id: 'prompt' },
{ label: 'Parser', id: 'parser' },
],
value: () => 'chain',
required: { field: 'operation', value: 'create_run' },
condition: { field: 'operation', value: 'create_run' },
},
{
id: 'start_time',
title: 'Start Time',
type: 'short-input',
placeholder: '2025-01-01T12:00:00Z',
condition: { field: 'operation', value: 'create_run' },
value: () => new Date().toISOString(),
},
{
id: 'end_time',
title: 'End Time',
type: 'short-input',
placeholder: '2025-01-01T12:00:30Z',
condition: { field: 'operation', value: 'create_run' },
mode: 'advanced',
},
{
id: 'inputs',
title: 'Inputs',
type: 'code',
placeholder: '{"input":"value"}',
condition: { field: 'operation', value: 'create_run' },
mode: 'advanced',
},
{
id: 'outputs',
title: 'Outputs',
type: 'code',
placeholder: '{"output":"value"}',
condition: { field: 'operation', value: 'create_run' },
mode: 'advanced',
},
{
id: 'extra',
title: 'Metadata',
type: 'code',
placeholder: '{"ls_model":"gpt-4"}',
condition: { field: 'operation', value: 'create_run' },
mode: 'advanced',
},
{
id: 'tags',
title: 'Tags',
type: 'code',
placeholder: '["production","workflow"]',
condition: { field: 'operation', value: 'create_run' },
mode: 'advanced',
},
{
id: 'parent_run_id',
title: 'Parent Run ID',
type: 'short-input',
placeholder: 'Parent run identifier',
condition: { field: 'operation', value: 'create_run' },
mode: 'advanced',
},
{
id: 'trace_id',
title: 'Trace ID',
type: 'short-input',
placeholder: 'Auto-generated if blank',
condition: { field: 'operation', value: 'create_run' },
mode: 'advanced',
},
{
id: 'session_id',
title: 'Session ID',
type: 'short-input',
placeholder: 'Session identifier',
condition: { field: 'operation', value: 'create_run' },
mode: 'advanced',
},
{
id: 'session_name',
title: 'Session Name',
type: 'short-input',
placeholder: 'Session name',
condition: { field: 'operation', value: 'create_run' },
mode: 'advanced',
},
{
id: 'status',
title: 'Status',
type: 'short-input',
placeholder: 'success',
condition: { field: 'operation', value: 'create_run' },
mode: 'advanced',
},
{
id: 'error',
title: 'Error',
type: 'long-input',
placeholder: 'Error message',
condition: { field: 'operation', value: 'create_run' },
mode: 'advanced',
},
{
id: 'dotted_order',
title: 'Dotted Order',
type: 'short-input',
placeholder: 'Defaults to <YYYYMMDDTHHMMSSffffff>Z<id>',
condition: { field: 'operation', value: 'create_run' },
mode: 'advanced',
},
{
id: 'events',
title: 'Events',
type: 'code',
placeholder: '[{"event":"token","value":1}]',
condition: { field: 'operation', value: 'create_run' },
mode: 'advanced',
},
{
id: 'post',
title: 'Post Runs',
type: 'code',
placeholder: '[{"id":"...","name":"...","run_type":"chain","start_time":"..."}]',
condition: { field: 'operation', value: 'create_runs_batch' },
},
{
id: 'patch',
title: 'Patch Runs',
type: 'code',
placeholder: '[{"id":"...","name":"...","run_type":"chain","start_time":"..."}]',
condition: { field: 'operation', value: 'create_runs_batch' },
mode: 'advanced',
},
],
tools: {
access: ['langsmith_create_run', 'langsmith_create_runs_batch'],
config: {
tool: (params) => {
switch (params.operation) {
case 'create_runs_batch':
return 'langsmith_create_runs_batch'
case 'create_run':
default:
return 'langsmith_create_run'
}
},
params: (params) => {
const parseJsonValue = (value: unknown, label: string) => {
if (value === undefined || value === null || value === '') {
return undefined
}
if (typeof value === 'string') {
try {
return JSON.parse(value)
} catch (error) {
throw new Error(
`Invalid JSON for ${label}: ${error instanceof Error ? error.message : String(error)}`
)
}
}
return value
}
if (params.operation === 'create_runs_batch') {
const post = parseJsonValue(params.post, 'post runs')
const patch = parseJsonValue(params.patch, 'patch runs')
if (!post && !patch) {
throw new Error('Provide at least one of post or patch runs')
}
return {
apiKey: params.apiKey,
post,
patch,
}
}
return {
apiKey: params.apiKey,
id: params.id,
name: params.name,
run_type: params.run_type,
start_time: params.start_time,
end_time: params.end_time,
inputs: parseJsonValue(params.inputs, 'inputs'),
outputs: parseJsonValue(params.outputs, 'outputs'),
extra: parseJsonValue(params.extra, 'metadata'),
tags: parseJsonValue(params.tags, 'tags'),
parent_run_id: params.parent_run_id,
trace_id: params.trace_id,
session_id: params.session_id,
session_name: params.session_name,
status: params.status,
error: params.error,
dotted_order: params.dotted_order,
events: parseJsonValue(params.events, 'events'),
}
},
},
},
inputs: {
operation: { type: 'string', description: 'Operation to perform' },
apiKey: { type: 'string', description: 'LangSmith API key' },
id: { type: 'string', description: 'Run identifier' },
name: { type: 'string', description: 'Run name' },
run_type: { type: 'string', description: 'Run type' },
start_time: { type: 'string', description: 'Run start time (ISO)' },
end_time: { type: 'string', description: 'Run end time (ISO)' },
inputs: { type: 'json', description: 'Run inputs payload' },
outputs: { type: 'json', description: 'Run outputs payload' },
extra: { type: 'json', description: 'Additional metadata (extra)' },
tags: { type: 'json', description: 'Tags array' },
parent_run_id: { type: 'string', description: 'Parent run ID' },
trace_id: { type: 'string', description: 'Trace ID' },
session_id: { type: 'string', description: 'Session ID' },
session_name: { type: 'string', description: 'Session name' },
status: { type: 'string', description: 'Run status' },
error: { type: 'string', description: 'Error message' },
dotted_order: { type: 'string', description: 'Dotted order string' },
events: { type: 'json', description: 'Events array' },
post: { type: 'json', description: 'Runs to ingest in batch' },
patch: { type: 'json', description: 'Runs to update in batch' },
},
outputs: {
accepted: { type: 'boolean', description: 'Whether ingestion was accepted' },
runId: { type: 'string', description: 'Run ID for single run' },
runIds: { type: 'array', description: 'Run IDs for batch ingest' },
message: { type: 'string', description: 'LangSmith response message' },
messages: { type: 'array', description: 'Per-run response messages' },
},
}

View File

@@ -61,6 +61,7 @@ import { JiraBlock } from '@/blocks/blocks/jira'
import { JiraServiceManagementBlock } from '@/blocks/blocks/jira_service_management'
import { KalshiBlock } from '@/blocks/blocks/kalshi'
import { KnowledgeBlock } from '@/blocks/blocks/knowledge'
import { LangsmithBlock } from '@/blocks/blocks/langsmith'
import { LemlistBlock } from '@/blocks/blocks/lemlist'
import { LinearBlock } from '@/blocks/blocks/linear'
import { LinkedInBlock } from '@/blocks/blocks/linkedin'
@@ -217,6 +218,7 @@ export const registry: Record<string, BlockConfig> = {
jira_service_management: JiraServiceManagementBlock,
kalshi: KalshiBlock,
knowledge: KnowledgeBlock,
langsmith: LangsmithBlock,
lemlist: LemlistBlock,
linear: LinearBlock,
linkedin: LinkedInBlock,

File diff suppressed because one or more lines are too long

View File

@@ -7,7 +7,6 @@
export function getTimezoneAbbreviation(timezone: string, date: Date = new Date()): string {
if (timezone === 'UTC') return 'UTC'
// Common timezone mappings
const timezoneMap: Record<string, { standard: string; daylight: string }> = {
'America/Los_Angeles': { standard: 'PST', daylight: 'PDT' },
'America/Denver': { standard: 'MST', daylight: 'MDT' },
@@ -20,30 +19,22 @@ export function getTimezoneAbbreviation(timezone: string, date: Date = new Date(
'Asia/Singapore': { standard: 'SGT', daylight: 'SGT' }, // Singapore doesn't use DST
}
// If we have a mapping for this timezone
if (timezone in timezoneMap) {
// January 1 is guaranteed to be standard time in northern hemisphere
// July 1 is guaranteed to be daylight time in northern hemisphere (if observed)
const januaryDate = new Date(date.getFullYear(), 0, 1)
const julyDate = new Date(date.getFullYear(), 6, 1)
// Get offset in January (standard time)
const januaryFormatter = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
timeZoneName: 'short',
})
// Get offset in July (likely daylight time)
const julyFormatter = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
timeZoneName: 'short',
})
// If offsets are different, timezone observes DST
const isDSTObserved = januaryFormatter.format(januaryDate) !== julyFormatter.format(julyDate)
// If DST is observed, check if current date is in DST by comparing its offset
// with January's offset (standard time)
if (isDSTObserved) {
const currentFormatter = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
@@ -54,11 +45,9 @@ export function getTimezoneAbbreviation(timezone: string, date: Date = new Date(
return isDST ? timezoneMap[timezone].daylight : timezoneMap[timezone].standard
}
// If DST is not observed, always use standard
return timezoneMap[timezone].standard
}
// For unknown timezones, use full IANA name
return timezone
}
@@ -79,7 +68,6 @@ export function formatDateTime(date: Date, timezone?: string): string {
timeZone: timezone || undefined,
})
// If timezone is provided, add a friendly timezone abbreviation
if (timezone) {
const tzAbbr = getTimezoneAbbreviation(timezone, date)
return `${formattedDate} ${tzAbbr}`
@@ -114,6 +102,40 @@ export function formatTime(date: Date): string {
})
}
/**
* Format a time with seconds and timezone
* @param date - The date to format
* @param includeTimezone - Whether to include the timezone abbreviation
* @returns A formatted time string in the format "h:mm:ss AM/PM TZ"
*/
export function formatTimeWithSeconds(date: Date, includeTimezone = true): string {
return date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
hour12: true,
timeZoneName: includeTimezone ? 'short' : undefined,
})
}
/**
* Format an ISO timestamp into a compact format for UI display
* @param iso - ISO timestamp string
* @returns A formatted string in "MM-DD HH:mm" format
*/
export function formatCompactTimestamp(iso: string): string {
try {
const d = new Date(iso)
const mm = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
const hh = String(d.getHours()).padStart(2, '0')
const min = String(d.getMinutes()).padStart(2, '0')
return `${mm}-${dd} ${hh}:${min}`
} catch {
return iso
}
}
/**
* Format a duration in milliseconds to a human-readable format
* @param durationMs - The duration in milliseconds

View File

@@ -1,3 +1,4 @@
import { createLogger } from '@sim/logger'
import { normalizeInputFormatValue } from '@/lib/workflows/input-format-utils'
import {
classifyStartBlockType,
@@ -12,8 +13,11 @@ import {
} from '@/lib/workflows/types'
import { getBlock } from '@/blocks'
import type { BlockConfig, OutputCondition, OutputFieldDefinition } from '@/blocks/types'
import { getTool } from '@/tools/utils'
import { getTrigger, isTriggerValid } from '@/triggers'
const logger = createLogger('BlockOutputs')
type OutputDefinition = Record<string, OutputFieldDefinition>
interface SubBlockWithValue {
@@ -435,3 +439,167 @@ export function getBlockOutputType(
const value = traverseOutputPath(outputs, pathParts)
return extractType(value)
}
/**
* Recursively generates all output paths from an outputs schema.
*
* @param outputs - The outputs schema object
* @param prefix - Current path prefix for recursion
* @returns Array of dot-separated paths to all output fields
*/
function generateOutputPaths(outputs: Record<string, any>, prefix = ''): string[] {
const paths: string[] = []
for (const [key, value] of Object.entries(outputs)) {
const currentPath = prefix ? `${prefix}.${key}` : key
if (typeof value === 'string') {
paths.push(currentPath)
} else if (typeof value === 'object' && value !== null) {
if ('type' in value && typeof value.type === 'string') {
const hasNestedProperties =
((value.type === 'object' || value.type === 'json') && value.properties) ||
(value.type === 'array' && value.items?.properties) ||
(value.type === 'array' &&
value.items &&
typeof value.items === 'object' &&
!('type' in value.items))
if (!hasNestedProperties) {
paths.push(currentPath)
}
if ((value.type === 'object' || value.type === 'json') && value.properties) {
paths.push(...generateOutputPaths(value.properties, currentPath))
} else if (value.type === 'array' && value.items?.properties) {
paths.push(...generateOutputPaths(value.items.properties, currentPath))
} else if (
value.type === 'array' &&
value.items &&
typeof value.items === 'object' &&
!('type' in value.items)
) {
paths.push(...generateOutputPaths(value.items, currentPath))
}
} else {
const subPaths = generateOutputPaths(value, currentPath)
paths.push(...subPaths)
}
} else {
paths.push(currentPath)
}
}
return paths
}
/**
* Recursively generates all output paths with their types from an outputs schema.
*
* @param outputs - The outputs schema object
* @param prefix - Current path prefix for recursion
* @returns Array of objects containing path and type for each output field
*/
function generateOutputPathsWithTypes(
outputs: Record<string, any>,
prefix = ''
): Array<{ path: string; type: string }> {
const paths: Array<{ path: string; type: string }> = []
for (const [key, value] of Object.entries(outputs)) {
const currentPath = prefix ? `${prefix}.${key}` : key
if (typeof value === 'string') {
paths.push({ path: currentPath, type: value })
} else if (typeof value === 'object' && value !== null) {
if ('type' in value && typeof value.type === 'string') {
if (value.type === 'array' && value.items?.properties) {
paths.push({ path: currentPath, type: 'array' })
const subPaths = generateOutputPathsWithTypes(value.items.properties, currentPath)
paths.push(...subPaths)
} else if ((value.type === 'object' || value.type === 'json') && value.properties) {
paths.push({ path: currentPath, type: value.type })
const subPaths = generateOutputPathsWithTypes(value.properties, currentPath)
paths.push(...subPaths)
} else {
paths.push({ path: currentPath, type: value.type })
}
} else {
const subPaths = generateOutputPathsWithTypes(value, currentPath)
paths.push(...subPaths)
}
} else {
paths.push({ path: currentPath, type: 'any' })
}
}
return paths
}
/**
* Gets the tool outputs for a block operation.
*
* @param blockConfig - The block configuration containing tools config
* @param operation - The selected operation for the tool
* @returns Outputs schema for the tool, or empty object on error
*/
export function getToolOutputs(blockConfig: BlockConfig, operation: string): Record<string, any> {
if (!blockConfig?.tools?.config?.tool) return {}
try {
const toolId = blockConfig.tools.config.tool({ operation })
if (!toolId) return {}
const toolConfig = getTool(toolId)
if (!toolConfig?.outputs) return {}
return toolConfig.outputs
} catch (error) {
logger.warn('Failed to get tool outputs for operation', { operation, error })
return {}
}
}
/**
* Generates output paths for a tool-based block.
*
* @param blockConfig - The block configuration containing tools config
* @param operation - The selected operation for the tool
* @returns Array of output paths for the tool, or empty array on error
*/
export function getToolOutputPaths(blockConfig: BlockConfig, operation: string): string[] {
const outputs = getToolOutputs(blockConfig, operation)
if (!outputs || Object.keys(outputs).length === 0) return []
return generateOutputPaths(outputs)
}
/**
* Generates output paths from a schema definition.
*
* @param outputs - The outputs schema object
* @returns Array of dot-separated paths to all output fields
*/
export function getOutputPathsFromSchema(outputs: Record<string, any>): string[] {
return generateOutputPaths(outputs)
}
/**
* Gets the output type for a specific path in a tool's outputs.
*
* @param blockConfig - The block configuration containing tools config
* @param operation - The selected operation for the tool
* @param path - The dot-separated path to the output field
* @returns The type of the output field, or 'any' if not found
*/
export function getToolOutputType(
blockConfig: BlockConfig,
operation: string,
path: string
): string {
const outputs = getToolOutputs(blockConfig, operation)
if (!outputs || Object.keys(outputs).length === 0) return 'any'
const pathsWithTypes = generateOutputPathsWithTypes(outputs)
const matchingPath = pathsWithTypes.find((p) => p.path === path)
return matchingPath?.type || 'any'
}

View File

@@ -0,0 +1,80 @@
import type { BlockState, SubBlockState } from '@/stores/workflows/workflow/types'
export const DEFAULT_SUBBLOCK_TYPE = 'short-input'
/**
* Merges subblock values into the provided subblock structures.
* Falls back to a default subblock shape when a value has no structure.
* @param subBlocks - Existing subblock definitions from the workflow
* @param values - Stored subblock values keyed by subblock id
* @returns Merged subblock structures with updated values
*/
export function mergeSubBlockValues(
subBlocks: Record<string, unknown> | undefined,
values: Record<string, unknown> | undefined
): Record<string, unknown> {
const merged = { ...(subBlocks || {}) } as Record<string, any>
if (!values) return merged
Object.entries(values).forEach(([subBlockId, value]) => {
if (merged[subBlockId] && typeof merged[subBlockId] === 'object') {
merged[subBlockId] = {
...(merged[subBlockId] as Record<string, unknown>),
value,
}
return
}
merged[subBlockId] = {
id: subBlockId,
type: DEFAULT_SUBBLOCK_TYPE,
value,
}
})
return merged
}
/**
* Merges workflow block states with explicit subblock values while maintaining block structure.
* Values that are null or undefined do not override existing subblock values.
* @param blocks - Block configurations from workflow state
* @param subBlockValues - Subblock values keyed by blockId -> subBlockId -> value
* @param blockId - Optional specific block ID to merge (merges all if not provided)
* @returns Merged block states with updated subblocks
*/
export function mergeSubblockStateWithValues(
blocks: Record<string, BlockState>,
subBlockValues: Record<string, Record<string, unknown>> = {},
blockId?: string
): Record<string, BlockState> {
const blocksToProcess = blockId ? { [blockId]: blocks[blockId] } : blocks
return Object.entries(blocksToProcess).reduce(
(acc, [id, block]) => {
if (!block) {
return acc
}
const blockSubBlocks = block.subBlocks || {}
const blockValues = subBlockValues[id] || {}
const filteredValues = Object.fromEntries(
Object.entries(blockValues).filter(([, value]) => value !== null && value !== undefined)
)
const mergedSubBlocks = mergeSubBlockValues(blockSubBlocks, filteredValues) as Record<
string,
SubBlockState
>
acc[id] = {
...block,
subBlocks: mergedSubBlocks,
}
return acc
},
{} as Record<string, BlockState>
)
}

View File

@@ -7,6 +7,7 @@ import postgres from 'postgres'
import { env } from '@/lib/core/config/env'
import { cleanupExternalWebhook } from '@/lib/webhooks/provider-subscriptions'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { mergeSubBlockValues } from '@/lib/workflows/subblocks'
import {
BLOCK_OPERATIONS,
BLOCKS_OPERATIONS,
@@ -455,7 +456,7 @@ async function handleBlocksOperationTx(
}
case BLOCKS_OPERATIONS.BATCH_ADD_BLOCKS: {
const { blocks, edges, loops, parallels } = payload
const { blocks, edges, loops, parallels, subBlockValues } = payload
logger.info(`Batch adding blocks to workflow ${workflowId}`, {
blockCount: blocks?.length || 0,
@@ -465,22 +466,30 @@ async function handleBlocksOperationTx(
})
if (blocks && blocks.length > 0) {
const blockValues = blocks.map((block: Record<string, unknown>) => ({
id: block.id as string,
workflowId,
type: block.type as string,
name: block.name as string,
positionX: (block.position as { x: number; y: number }).x,
positionY: (block.position as { x: number; y: number }).y,
data: (block.data as Record<string, unknown>) || {},
subBlocks: (block.subBlocks as Record<string, unknown>) || {},
outputs: (block.outputs as Record<string, unknown>) || {},
enabled: (block.enabled as boolean) ?? true,
horizontalHandles: (block.horizontalHandles as boolean) ?? true,
advancedMode: (block.advancedMode as boolean) ?? false,
triggerMode: (block.triggerMode as boolean) ?? false,
height: (block.height as number) || 0,
}))
const blockValues = blocks.map((block: Record<string, unknown>) => {
const blockId = block.id as string
const mergedSubBlocks = mergeSubBlockValues(
block.subBlocks as Record<string, unknown>,
subBlockValues?.[blockId]
)
return {
id: blockId,
workflowId,
type: block.type as string,
name: block.name as string,
positionX: (block.position as { x: number; y: number }).x,
positionY: (block.position as { x: number; y: number }).y,
data: (block.data as Record<string, unknown>) || {},
subBlocks: mergedSubBlocks,
outputs: (block.outputs as Record<string, unknown>) || {},
enabled: (block.enabled as boolean) ?? true,
horizontalHandles: (block.horizontalHandles as boolean) ?? true,
advancedMode: (block.advancedMode as boolean) ?? false,
triggerMode: (block.triggerMode as boolean) ?? false,
height: (block.height as number) || 0,
}
})
await tx.insert(workflowBlocks).values(blockValues)

View File

@@ -422,8 +422,7 @@ function abortAllInProgressTools(set: any, get: () => CopilotStore) {
* Loads messages from DB for UI rendering.
* Messages are stored exactly as they render, so we just need to:
* 1. Register client tool instances for any tool calls
* 2. Clear any streaming flags (messages loaded from DB are never actively streaming)
* 3. Return the messages
* 2. Return the messages as-is
*/
function normalizeMessagesForUI(messages: CopilotMessage[]): CopilotMessage[] {
try {
@@ -439,57 +438,23 @@ function normalizeMessagesForUI(messages: CopilotMessage[]): CopilotMessage[] {
}
}
// Register client tool instances and clear streaming flags for all tool calls
// Register client tool instances for all tool calls so they can be looked up
for (const message of messages) {
// Clear from contentBlocks (current format)
if (message.contentBlocks) {
for (const block of message.contentBlocks as any[]) {
if (block?.type === 'tool_call' && block.toolCall) {
registerToolCallInstances(block.toolCall)
clearStreamingFlags(block.toolCall)
}
}
}
// Clear from toolCalls array (legacy format)
if (message.toolCalls) {
for (const toolCall of message.toolCalls) {
registerToolCallInstances(toolCall)
clearStreamingFlags(toolCall)
}
}
}
// Return messages - they're already in the correct format for rendering
// Return messages as-is - they're already in the correct format for rendering
return messages
} catch {
return messages
}
}
/**
* Recursively clears streaming flags from a tool call and its nested subagent tool calls.
* This ensures messages loaded from DB don't appear to be streaming.
*/
function clearStreamingFlags(toolCall: any): void {
if (!toolCall) return
// Always set subAgentStreaming to false - messages loaded from DB are never streaming
toolCall.subAgentStreaming = false
// Clear nested subagent tool calls
if (Array.isArray(toolCall.subAgentBlocks)) {
for (const block of toolCall.subAgentBlocks) {
if (block?.type === 'subagent_tool_call' && block.toolCall) {
clearStreamingFlags(block.toolCall)
}
}
}
if (Array.isArray(toolCall.subAgentToolCalls)) {
for (const subTc of toolCall.subAgentToolCalls) {
clearStreamingFlags(subTc)
}
}
}
/**
* Recursively registers client tool instances for a tool call and its nested subagent tool calls.
*/

View File

@@ -15,7 +15,7 @@ const logger = createLogger('TerminalConsoleStore')
* Maximum number of console entries to keep per workflow.
* Keeps the stored data size reasonable and improves performance.
*/
const MAX_ENTRIES_PER_WORKFLOW = 500
const MAX_ENTRIES_PER_WORKFLOW = 1000
const updateBlockOutput = (
existingOutput: NormalizedBlockOutput | undefined,
@@ -96,13 +96,57 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
}
const newEntries = [newEntry, ...state.entries]
const workflowCounts = new Map<string, number>()
const trimmedEntries = newEntries.filter((entry) => {
const count = workflowCounts.get(entry.workflowId) || 0
if (count >= MAX_ENTRIES_PER_WORKFLOW) return false
workflowCounts.set(entry.workflowId, count + 1)
return true
const executionsToRemove = new Set<string>()
const workflowGroups = new Map<string, ConsoleEntry[]>()
for (const e of newEntries) {
const group = workflowGroups.get(e.workflowId) || []
group.push(e)
workflowGroups.set(e.workflowId, group)
}
for (const [workflowId, entries] of workflowGroups) {
if (entries.length <= MAX_ENTRIES_PER_WORKFLOW) continue
const execOrder: string[] = []
const seen = new Set<string>()
for (const e of entries) {
const execId = e.executionId ?? e.id
if (!seen.has(execId)) {
execOrder.push(execId)
seen.add(execId)
}
}
const counts = new Map<string, number>()
for (const e of entries) {
const execId = e.executionId ?? e.id
counts.set(execId, (counts.get(execId) || 0) + 1)
}
let total = 0
const toKeep = new Set<string>()
for (const execId of execOrder) {
const c = counts.get(execId) || 0
if (total + c <= MAX_ENTRIES_PER_WORKFLOW) {
toKeep.add(execId)
total += c
}
}
for (const execId of execOrder) {
if (!toKeep.has(execId)) {
executionsToRemove.add(`${workflowId}:${execId}`)
}
}
}
const trimmedEntries = newEntries.filter((e) => {
const key = `${e.workflowId}:${e.executionId ?? e.id}`
return !executionsToRemove.has(key)
})
return { entries: trimmedEntries }
})

View File

@@ -8,7 +8,8 @@
* or React hooks, making it safe for use in Next.js API routes.
*/
import type { BlockState, SubBlockState } from '@/stores/workflows/workflow/types'
import { mergeSubblockStateWithValues } from '@/lib/workflows/subblocks'
import type { BlockState } from '@/stores/workflows/workflow/types'
/**
* Server-safe version of mergeSubblockState for API routes
@@ -26,72 +27,7 @@ export function mergeSubblockState(
subBlockValues: Record<string, Record<string, any>> = {},
blockId?: string
): Record<string, BlockState> {
const blocksToProcess = blockId ? { [blockId]: blocks[blockId] } : blocks
return Object.entries(blocksToProcess).reduce(
(acc, [id, block]) => {
// Skip if block is undefined
if (!block) {
return acc
}
// Initialize subBlocks if not present
const blockSubBlocks = block.subBlocks || {}
// Get stored values for this block
const blockValues = subBlockValues[id] || {}
// Create a deep copy of the block's subBlocks to maintain structure
const mergedSubBlocks = Object.entries(blockSubBlocks).reduce(
(subAcc, [subBlockId, subBlock]) => {
// Skip if subBlock is undefined
if (!subBlock) {
return subAcc
}
// Get the stored value for this subblock
const storedValue = blockValues[subBlockId]
// Create a new subblock object with the same structure but updated value
subAcc[subBlockId] = {
...subBlock,
value: storedValue !== undefined && storedValue !== null ? storedValue : subBlock.value,
}
return subAcc
},
{} as Record<string, SubBlockState>
)
// Return the full block state with updated subBlocks
acc[id] = {
...block,
subBlocks: mergedSubBlocks,
}
// Add any values that exist in the provided values but aren't in the block structure
// This handles cases where block config has been updated but values still exist
Object.entries(blockValues).forEach(([subBlockId, value]) => {
if (!mergedSubBlocks[subBlockId] && value !== null && value !== undefined) {
// Create a minimal subblock structure
mergedSubBlocks[subBlockId] = {
id: subBlockId,
type: 'short-input', // Default type that's safe to use
value: value,
}
}
})
// Update the block with the final merged subBlocks (including orphaned values)
acc[id] = {
...block,
subBlocks: mergedSubBlocks,
}
return acc
},
{} as Record<string, BlockState>
)
return mergeSubblockStateWithValues(blocks, subBlockValues, blockId)
}
/**

View File

@@ -1,20 +1,7 @@
import type { Edge } from 'reactflow'
import { v4 as uuidv4 } from 'uuid'
export function filterNewEdges(edgesToAdd: Edge[], currentEdges: Edge[]): Edge[] {
return edgesToAdd.filter((edge) => {
if (edge.source === edge.target) return false
return !currentEdges.some(
(e) =>
e.source === edge.source &&
e.sourceHandle === edge.sourceHandle &&
e.target === edge.target &&
e.targetHandle === edge.targetHandle
)
})
}
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { mergeSubBlockValues, mergeSubblockStateWithValues } from '@/lib/workflows/subblocks'
import { getBlock } from '@/blocks'
import { normalizeName } from '@/executor/constants'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
@@ -32,6 +19,19 @@ const WEBHOOK_SUBBLOCK_FIELDS = ['webhookId', 'triggerPath']
export { normalizeName }
export function filterNewEdges(edgesToAdd: Edge[], currentEdges: Edge[]): Edge[] {
return edgesToAdd.filter((edge) => {
if (edge.source === edge.target) return false
return !currentEdges.some(
(e) =>
e.source === edge.source &&
e.sourceHandle === edge.sourceHandle &&
e.target === edge.target &&
e.targetHandle === edge.targetHandle
)
})
}
export interface RegeneratedState {
blocks: Record<string, BlockState>
edges: Edge[]
@@ -201,27 +201,20 @@ export function prepareDuplicateBlockState(options: PrepareDuplicateBlockStateOp
Object.entries(subBlockValues).filter(([key]) => !WEBHOOK_SUBBLOCK_FIELDS.includes(key))
)
const mergedSubBlocks: Record<string, SubBlockState> = sourceBlock.subBlocks
const baseSubBlocks: Record<string, SubBlockState> = sourceBlock.subBlocks
? JSON.parse(JSON.stringify(sourceBlock.subBlocks))
: {}
WEBHOOK_SUBBLOCK_FIELDS.forEach((field) => {
if (field in mergedSubBlocks) {
delete mergedSubBlocks[field]
if (field in baseSubBlocks) {
delete baseSubBlocks[field]
}
})
Object.entries(filteredSubBlockValues).forEach(([subblockId, value]) => {
if (mergedSubBlocks[subblockId]) {
mergedSubBlocks[subblockId].value = value as SubBlockState['value']
} else {
mergedSubBlocks[subblockId] = {
id: subblockId,
type: 'short-input',
value: value as SubBlockState['value'],
}
}
})
const mergedSubBlocks = mergeSubBlockValues(baseSubBlocks, filteredSubBlockValues) as Record<
string,
SubBlockState
>
const block: BlockState = {
id: newId,
@@ -256,11 +249,16 @@ export function mergeSubblockState(
workflowId?: string,
blockId?: string
): Record<string, BlockState> {
const blocksToProcess = blockId ? { [blockId]: blocks[blockId] } : blocks
const subBlockStore = useSubBlockStore.getState()
const workflowSubblockValues = workflowId ? subBlockStore.workflowValues[workflowId] || {} : {}
if (workflowId) {
return mergeSubblockStateWithValues(blocks, workflowSubblockValues, blockId)
}
const blocksToProcess = blockId ? { [blockId]: blocks[blockId] } : blocks
return Object.entries(blocksToProcess).reduce(
(acc, [id, block]) => {
if (!block) {
@@ -339,9 +337,15 @@ export async function mergeSubblockStateAsync(
workflowId?: string,
blockId?: string
): Promise<Record<string, BlockState>> {
const blocksToProcess = blockId ? { [blockId]: blocks[blockId] } : blocks
const subBlockStore = useSubBlockStore.getState()
if (workflowId) {
const workflowValues = subBlockStore.workflowValues[workflowId] || {}
return mergeSubblockStateWithValues(blocks, workflowValues, blockId)
}
const blocksToProcess = blockId ? { [blockId]: blocks[blockId] } : blocks
// Process blocks in parallel for better performance
const processedBlockEntries = await Promise.all(
Object.entries(blocksToProcess).map(async ([id, block]) => {
@@ -358,16 +362,7 @@ export async function mergeSubblockStateAsync(
return null
}
let storedValue = null
if (workflowId) {
const workflowValues = subBlockStore.workflowValues[workflowId]
if (workflowValues?.[id]) {
storedValue = workflowValues[id][subBlockId]
}
} else {
storedValue = subBlockStore.getValue(id, subBlockId)
}
const storedValue = subBlockStore.getValue(id, subBlockId)
return [
subBlockId,
@@ -386,23 +381,6 @@ export async function mergeSubblockStateAsync(
subBlockEntries.filter((entry): entry is readonly [string, SubBlockState] => entry !== null)
) as Record<string, SubBlockState>
// Add any values that exist in the store but aren't in the block structure
// This handles cases where block config has been updated but values still exist
// IMPORTANT: This includes runtime subblock IDs like webhookId, triggerPath, etc.
if (workflowId) {
const workflowValues = subBlockStore.workflowValues[workflowId]
const blockValues = workflowValues?.[id] || {}
Object.entries(blockValues).forEach(([subBlockId, value]) => {
if (!mergedSubBlocks[subBlockId] && value !== null && value !== undefined) {
mergedSubBlocks[subBlockId] = {
id: subBlockId,
type: 'short-input',
value: value as SubBlockState['value'],
}
}
})
}
// Return the full block state with updated subBlocks (including orphaned values)
return [
id,

View File

@@ -639,7 +639,8 @@ export const useWorkflowStore = create<WorkflowStore>()(
const newName = getUniqueBlockName(block.name, get().blocks)
const mergedBlock = mergeSubblockState(get().blocks, id)[id]
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
const mergedBlock = mergeSubblockState(get().blocks, activeWorkflowId || undefined, id)[id]
const newSubBlocks = Object.entries(mergedBlock.subBlocks).reduce(
(acc, [subId, subBlock]) => ({
@@ -668,7 +669,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
parallels: get().generateParallelBlocks(),
}
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (activeWorkflowId) {
const subBlockValues =
useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[id] || {}

View File

@@ -0,0 +1,188 @@
import type { LangsmithCreateRunParams, LangsmithCreateRunResponse } from '@/tools/langsmith/types'
import { normalizeLangsmithRunPayload } from '@/tools/langsmith/utils'
import type { ToolConfig } from '@/tools/types'
export const langsmithCreateRunTool: ToolConfig<
LangsmithCreateRunParams,
LangsmithCreateRunResponse
> = {
id: 'langsmith_create_run',
name: 'LangSmith Create Run',
description: 'Forward a single run to LangSmith for ingestion.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'LangSmith API key',
},
id: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Unique run identifier',
},
name: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Run name',
},
run_type: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Run type (tool, chain, llm, retriever, embedding, prompt, parser)',
},
start_time: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Run start time in ISO-8601 format',
},
end_time: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Run end time in ISO-8601 format',
},
inputs: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'Inputs payload',
},
outputs: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'Outputs payload',
},
extra: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'Additional metadata (extra)',
},
tags: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'Array of tag strings',
},
parent_run_id: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Parent run ID',
},
trace_id: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Trace ID',
},
session_id: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Session ID',
},
session_name: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Session name',
},
status: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Run status',
},
error: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Error details',
},
dotted_order: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Dotted order string',
},
events: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'Structured events array',
},
},
request: {
url: () => 'https://api.smith.langchain.com/runs',
method: 'POST',
headers: (params) => ({
'X-Api-Key': params.apiKey,
'Content-Type': 'application/json',
}),
body: (params) => {
const { payload } = normalizeLangsmithRunPayload(params)
const normalizedPayload: Record<string, unknown> = {
...payload,
name: payload.name?.trim(),
inputs: params.inputs,
outputs: params.outputs,
extra: params.extra,
tags: params.tags,
status: params.status,
error: params.error,
events: params.events,
}
return Object.fromEntries(
Object.entries(normalizedPayload).filter(([, value]) => value !== undefined)
)
},
},
transformResponse: async (response, params) => {
const runId = params ? normalizeLangsmithRunPayload(params).runId : null
const data = (await response.json()) as Record<string, unknown>
const directMessage =
typeof (data as { message?: unknown }).message === 'string'
? (data as { message: string }).message
: null
const nestedPayload =
runId && typeof data[runId] === 'object' && data[runId] !== null
? (data[runId] as Record<string, unknown>)
: null
const nestedMessage =
nestedPayload && typeof nestedPayload.message === 'string' ? nestedPayload.message : null
return {
success: true,
output: {
accepted: true,
runId: runId ?? null,
message: directMessage ?? nestedMessage ?? null,
},
}
},
outputs: {
accepted: {
type: 'boolean',
description: 'Whether the run was accepted for ingestion',
},
runId: {
type: 'string',
description: 'Run identifier provided in the request',
optional: true,
},
message: {
type: 'string',
description: 'Response message from LangSmith',
optional: true,
},
},
}

View File

@@ -0,0 +1,112 @@
import type {
LangsmithCreateRunsBatchParams,
LangsmithCreateRunsBatchResponse,
LangsmithRunPayload,
} from '@/tools/langsmith/types'
import { normalizeLangsmithRunPayload } from '@/tools/langsmith/utils'
import type { ToolConfig } from '@/tools/types'
export const langsmithCreateRunsBatchTool: ToolConfig<
LangsmithCreateRunsBatchParams,
LangsmithCreateRunsBatchResponse
> = {
id: 'langsmith_create_runs_batch',
name: 'LangSmith Create Runs Batch',
description: 'Forward multiple runs to LangSmith in a single batch.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'LangSmith API key',
},
post: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'Array of new runs to ingest',
},
patch: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'Array of runs to update/patch',
},
},
request: {
url: () => 'https://api.smith.langchain.com/runs/batch',
method: 'POST',
headers: (params) => ({
'X-Api-Key': params.apiKey,
'Content-Type': 'application/json',
}),
body: (params) => {
const payload: Record<string, unknown> = {
post: params.post
? params.post.map((run) => normalizeLangsmithRunPayload(run).payload)
: undefined,
patch: params.patch
? params.patch.map((run) => normalizeLangsmithRunPayload(run).payload)
: undefined,
}
return Object.fromEntries(Object.entries(payload).filter(([, value]) => value !== undefined))
},
},
transformResponse: async (response, params) => {
const data = (await response.json()) as Record<string, unknown>
const directMessage =
typeof (data as { message?: unknown }).message === 'string'
? (data as { message: string }).message
: null
const messages = Object.values(data)
.map((value) => {
if (typeof value !== 'object' || value === null) {
return null
}
const messageValue = (value as Record<string, unknown>).message
return typeof messageValue === 'string' ? messageValue : null
})
.filter((value): value is string => Boolean(value))
const collectRunIds = (runs?: LangsmithRunPayload[]) =>
runs?.map((run) => normalizeLangsmithRunPayload(run).runId) ?? []
return {
success: true,
output: {
accepted: true,
runIds: [...collectRunIds(params?.post), ...collectRunIds(params?.patch)],
message: directMessage ?? null,
messages: messages.length ? messages : undefined,
},
}
},
outputs: {
accepted: {
type: 'boolean',
description: 'Whether the batch was accepted for ingestion',
},
runIds: {
type: 'array',
description: 'Run identifiers provided in the request',
items: {
type: 'string',
},
},
message: {
type: 'string',
description: 'Response message from LangSmith',
optional: true,
},
messages: {
type: 'array',
description: 'Per-run response messages, when provided',
optional: true,
items: {
type: 'string',
},
},
},
}

View File

@@ -0,0 +1,2 @@
export { langsmithCreateRunTool } from '@/tools/langsmith/create_run'
export { langsmithCreateRunsBatchTool } from '@/tools/langsmith/create_runs_batch'

View File

@@ -0,0 +1,59 @@
import type { ToolResponse } from '@/tools/types'
export type LangsmithRunType =
| 'tool'
| 'chain'
| 'llm'
| 'retriever'
| 'embedding'
| 'prompt'
| 'parser'
export interface LangsmithRunPayload {
id?: string
name: string
run_type: LangsmithRunType
start_time?: string
end_time?: string
inputs?: Record<string, unknown>
outputs?: Record<string, unknown>
extra?: Record<string, unknown>
tags?: string[]
parent_run_id?: string
trace_id?: string
session_id?: string
session_name?: string
status?: string
error?: string
dotted_order?: string
events?: Record<string, unknown>[]
}
export interface LangsmithCreateRunParams extends LangsmithRunPayload {
apiKey: string
}
export interface LangsmithCreateRunsBatchParams {
apiKey: string
post?: LangsmithRunPayload[]
patch?: LangsmithRunPayload[]
}
export interface LangsmithCreateRunResponse extends ToolResponse {
output: {
accepted: boolean
runId: string | null
message: string | null
}
}
export interface LangsmithCreateRunsBatchResponse extends ToolResponse {
output: {
accepted: boolean
runIds: string[]
message: string | null
messages?: string[]
}
}
export type LangsmithResponse = LangsmithCreateRunResponse | LangsmithCreateRunsBatchResponse

View File

@@ -0,0 +1,38 @@
import type { LangsmithRunPayload } from '@/tools/langsmith/types'
interface NormalizedRunPayload {
payload: LangsmithRunPayload
runId: string
}
const toCompactTimestamp = (startTime?: string): string => {
const parsed = startTime ? new Date(startTime) : new Date()
const date = Number.isNaN(parsed.getTime()) ? new Date() : parsed
const pad = (value: number, length: number) => value.toString().padStart(length, '0')
const year = date.getUTCFullYear()
const month = pad(date.getUTCMonth() + 1, 2)
const day = pad(date.getUTCDate(), 2)
const hours = pad(date.getUTCHours(), 2)
const minutes = pad(date.getUTCMinutes(), 2)
const seconds = pad(date.getUTCSeconds(), 2)
const micros = pad(date.getUTCMilliseconds() * 1000, 6)
return `${year}${month}${day}T${hours}${minutes}${seconds}${micros}`
}
export const normalizeLangsmithRunPayload = (run: LangsmithRunPayload): NormalizedRunPayload => {
const runId = run.id ?? crypto.randomUUID()
const traceId = run.trace_id ?? runId
const startTime = run.start_time ?? new Date().toISOString()
const dottedOrder = run.dotted_order ?? `${toCompactTimestamp(startTime)}Z${runId}`
return {
runId,
payload: {
...run,
id: runId,
trace_id: traceId,
start_time: startTime,
dotted_order: dottedOrder,
},
}
}

View File

@@ -653,6 +653,7 @@ import {
knowledgeSearchTool,
knowledgeUploadChunkTool,
} from '@/tools/knowledge'
import { langsmithCreateRunsBatchTool, langsmithCreateRunTool } from '@/tools/langsmith'
import { lemlistGetActivitiesTool, lemlistGetLeadTool, lemlistSendEmailTool } from '@/tools/lemlist'
import {
linearAddLabelToIssueTool,
@@ -2442,6 +2443,8 @@ export const tools: Record<string, ToolConfig> = {
linear_update_project_status: linearUpdateProjectStatusTool,
linear_delete_project_status: linearDeleteProjectStatusTool,
linear_list_project_statuses: linearListProjectStatusesTool,
langsmith_create_run: langsmithCreateRunTool,
langsmith_create_runs_batch: langsmithCreateRunsBatchTool,
lemlist_get_activities: lemlistGetActivitiesTool,
lemlist_get_lead: lemlistGetLeadTool,
lemlist_send_email: lemlistSendEmailTool,