mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-15 01:47:59 -05:00
Compare commits
2 Commits
fix/copilo
...
fix/copilo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ab9b91445 | ||
|
|
a36bdd8729 |
27
.github/workflows/i18n.yml
vendored
27
.github/workflows/i18n.yml
vendored
@@ -1,10 +1,11 @@
|
||||
name: 'Auto-translate Documentation'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run every Sunday at midnight UTC
|
||||
- cron: '0 0 * * 0'
|
||||
workflow_dispatch: # Allow manual triggers
|
||||
push:
|
||||
branches: [ staging ]
|
||||
paths:
|
||||
- 'apps/docs/content/docs/en/**'
|
||||
- 'apps/docs/i18n.json'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -19,7 +20,6 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: staging
|
||||
token: ${{ secrets.GH_PAT }}
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -68,11 +68,12 @@ jobs:
|
||||
title: "feat(i18n): update translations"
|
||||
body: |
|
||||
## Summary
|
||||
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
|
||||
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 }}
|
||||
**Workflow**: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
|
||||
## Type of Change
|
||||
@@ -106,7 +107,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/weekly-${{ github.run_id }}
|
||||
branch: auto-translate/staging-merge-${{ github.run_id }}
|
||||
base: staging
|
||||
labels: |
|
||||
i18n
|
||||
@@ -144,8 +145,6 @@ 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
|
||||
@@ -154,7 +153,7 @@ jobs:
|
||||
run: |
|
||||
cd apps/docs
|
||||
echo "## Translation Status Report" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Weekly scheduled translation run**" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Triggered by merge to staging branch**" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
en_count=$(find content/docs/en -name "*.mdx" | wc -l)
|
||||
|
||||
@@ -52,9 +52,6 @@ const ChatMessageSchema = z.object({
|
||||
'gpt-5.1-high',
|
||||
'gpt-5-codex',
|
||||
'gpt-5.1-codex',
|
||||
'gpt-5.2',
|
||||
'gpt-5.2-codex',
|
||||
'gpt-5.2-pro',
|
||||
'gpt-4o',
|
||||
'gpt-4.1',
|
||||
'o3',
|
||||
|
||||
@@ -15,14 +15,11 @@ const DEFAULT_ENABLED_MODELS: Record<string, boolean> = {
|
||||
'gpt-5-medium': false,
|
||||
'gpt-5-high': false,
|
||||
'gpt-5.1-fast': false,
|
||||
'gpt-5.1': false,
|
||||
'gpt-5.1-medium': false,
|
||||
'gpt-5.1': true,
|
||||
'gpt-5.1-medium': true,
|
||||
'gpt-5.1-high': false,
|
||||
'gpt-5-codex': false,
|
||||
'gpt-5.1-codex': false,
|
||||
'gpt-5.2': false,
|
||||
'gpt-5.2-codex': true,
|
||||
'gpt-5.2-pro': true,
|
||||
'gpt-5.1-codex': true,
|
||||
o3: true,
|
||||
'claude-4-sonnet': false,
|
||||
'claude-4.5-haiku': true,
|
||||
|
||||
@@ -2,9 +2,29 @@ import { memo, useEffect, useRef, useState } from 'react'
|
||||
import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
|
||||
|
||||
/**
|
||||
* Character animation delay in milliseconds
|
||||
* Minimum delay between characters (fast catch-up mode)
|
||||
*/
|
||||
const CHARACTER_DELAY = 3
|
||||
const MIN_DELAY = 1
|
||||
|
||||
/**
|
||||
* Maximum delay between characters (when waiting for content)
|
||||
*/
|
||||
const MAX_DELAY = 12
|
||||
|
||||
/**
|
||||
* Default delay when streaming normally
|
||||
*/
|
||||
const DEFAULT_DELAY = 4
|
||||
|
||||
/**
|
||||
* How far behind (in characters) before we speed up
|
||||
*/
|
||||
const CATCH_UP_THRESHOLD = 20
|
||||
|
||||
/**
|
||||
* How close to content before we slow down
|
||||
*/
|
||||
const SLOW_DOWN_THRESHOLD = 5
|
||||
|
||||
/**
|
||||
* StreamingIndicator shows animated dots during message streaming
|
||||
@@ -34,9 +54,39 @@ interface SmoothStreamingTextProps {
|
||||
isStreaming: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates adaptive delay based on how far behind animation is from actual content
|
||||
*
|
||||
* @param displayedLength - Current displayed content length
|
||||
* @param totalLength - Total available content length
|
||||
* @returns Delay in milliseconds
|
||||
*/
|
||||
function calculateAdaptiveDelay(displayedLength: number, totalLength: number): number {
|
||||
const charsRemaining = totalLength - displayedLength
|
||||
|
||||
if (charsRemaining > CATCH_UP_THRESHOLD) {
|
||||
// Far behind - speed up to catch up
|
||||
// Scale from MIN_DELAY to DEFAULT_DELAY based on how far behind
|
||||
const catchUpFactor = Math.min(1, (charsRemaining - CATCH_UP_THRESHOLD) / 50)
|
||||
return MIN_DELAY + (DEFAULT_DELAY - MIN_DELAY) * (1 - catchUpFactor)
|
||||
}
|
||||
|
||||
if (charsRemaining <= SLOW_DOWN_THRESHOLD) {
|
||||
// Close to content edge - slow down to feel natural
|
||||
// The closer we are, the slower we go (up to MAX_DELAY)
|
||||
const slowFactor = 1 - charsRemaining / SLOW_DOWN_THRESHOLD
|
||||
return DEFAULT_DELAY + (MAX_DELAY - DEFAULT_DELAY) * slowFactor
|
||||
}
|
||||
|
||||
// Normal streaming speed
|
||||
return DEFAULT_DELAY
|
||||
}
|
||||
|
||||
/**
|
||||
* SmoothStreamingText component displays text with character-by-character animation
|
||||
* Creates a smooth streaming effect for AI responses
|
||||
* Creates a smooth streaming effect for AI responses with adaptive speed
|
||||
*
|
||||
* Uses adaptive pacing: speeds up when catching up, slows down near content edge
|
||||
*
|
||||
* @param props - Component props
|
||||
* @returns Streaming text with smooth animation
|
||||
@@ -44,11 +94,14 @@ interface SmoothStreamingTextProps {
|
||||
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(() =>
|
||||
isStreaming ? '' : content
|
||||
)
|
||||
const contentRef = useRef(content)
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const rafRef = useRef<number | null>(null)
|
||||
// Initialize index based on streaming state
|
||||
const indexRef = useRef(isStreaming ? 0 : content.length)
|
||||
const lastFrameTimeRef = useRef<number>(0)
|
||||
const isAnimatingRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -61,33 +114,42 @@ export const SmoothStreamingText = memo(
|
||||
}
|
||||
|
||||
if (isStreaming) {
|
||||
if (indexRef.current < content.length) {
|
||||
const animateText = () => {
|
||||
if (indexRef.current < content.length && !isAnimatingRef.current) {
|
||||
isAnimatingRef.current = true
|
||||
lastFrameTimeRef.current = performance.now()
|
||||
|
||||
const animateText = (timestamp: number) => {
|
||||
const currentContent = contentRef.current
|
||||
const currentIndex = indexRef.current
|
||||
const elapsed = timestamp - lastFrameTimeRef.current
|
||||
|
||||
if (currentIndex < currentContent.length) {
|
||||
const newDisplayed = currentContent.slice(0, currentIndex + 1)
|
||||
setDisplayedContent(newDisplayed)
|
||||
indexRef.current = currentIndex + 1
|
||||
timeoutRef.current = setTimeout(animateText, CHARACTER_DELAY)
|
||||
// Calculate adaptive delay based on how far behind we are
|
||||
const delay = calculateAdaptiveDelay(currentIndex, currentContent.length)
|
||||
|
||||
if (elapsed >= delay) {
|
||||
if (currentIndex < currentContent.length) {
|
||||
const newDisplayed = currentContent.slice(0, currentIndex + 1)
|
||||
setDisplayedContent(newDisplayed)
|
||||
indexRef.current = currentIndex + 1
|
||||
lastFrameTimeRef.current = timestamp
|
||||
}
|
||||
}
|
||||
|
||||
if (indexRef.current < currentContent.length) {
|
||||
rafRef.current = requestAnimationFrame(animateText)
|
||||
} else {
|
||||
isAnimatingRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAnimatingRef.current) {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
isAnimatingRef.current = true
|
||||
animateText()
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(animateText)
|
||||
} else if (indexRef.current < content.length && isAnimatingRef.current) {
|
||||
// Animation already running, it will pick up new content automatically
|
||||
}
|
||||
} else {
|
||||
// Streaming ended - show full content immediately
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
}
|
||||
setDisplayedContent(content)
|
||||
indexRef.current = content.length
|
||||
@@ -95,8 +157,8 @@ export const SmoothStreamingText = memo(
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
}
|
||||
isAnimatingRef.current = false
|
||||
}
|
||||
|
||||
@@ -47,7 +47,9 @@ 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(() =>
|
||||
isStreaming ? '' : content
|
||||
)
|
||||
const [showGradient, setShowGradient] = useState(false)
|
||||
const contentRef = useRef(content)
|
||||
const textRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -8,6 +8,7 @@ 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,
|
||||
@@ -1952,12 +1953,7 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
|
||||
}, [params])
|
||||
|
||||
// Skip rendering some internal tools
|
||||
if (
|
||||
toolCall.name === 'checkoff_todo' ||
|
||||
toolCall.name === 'mark_todo_in_progress' ||
|
||||
toolCall.name === 'tool_search_tool_regex'
|
||||
)
|
||||
return null
|
||||
if (toolCall.name === 'checkoff_todo' || toolCall.name === 'mark_todo_in_progress') return null
|
||||
|
||||
// Special rendering for subagent tools - show as thinking text with tool calls at top level
|
||||
const SUBAGENT_TOOLS = [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export { AttachedFilesDisplay } from './attached-files-display/attached-files-display'
|
||||
export { ContextPills } from './context-pills/context-pills'
|
||||
export { type MentionFolderNav, MentionMenu } from './mention-menu/mention-menu'
|
||||
export { MentionMenu } from './mention-menu/mention-menu'
|
||||
export { ModeSelector } from './mode-selector/mode-selector'
|
||||
export { ModelSelector } from './model-selector/model-selector'
|
||||
export { type SlashFolderNav, SlashMenu } from './slash-menu/slash-menu'
|
||||
export { SlashMenu } from './slash-menu/slash-menu'
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
'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>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
@@ -9,43 +9,47 @@ import {
|
||||
PopoverFolder,
|
||||
PopoverItem,
|
||||
PopoverScrollArea,
|
||||
usePopoverContext,
|
||||
} from '@/components/emcn'
|
||||
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'
|
||||
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>
|
||||
)
|
||||
|
||||
interface AggregatedItem {
|
||||
id: string
|
||||
label: string
|
||||
category: MentionCategory
|
||||
category:
|
||||
| 'chats'
|
||||
| 'workflows'
|
||||
| 'knowledge'
|
||||
| 'blocks'
|
||||
| 'workflow-blocks'
|
||||
| 'templates'
|
||||
| 'logs'
|
||||
| 'docs'
|
||||
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>
|
||||
@@ -60,124 +64,170 @@ interface MentionMenuProps {
|
||||
insertLogMention: (log: any) => void
|
||||
insertDocsMention: () => void
|
||||
}
|
||||
onFolderNavChange?: (nav: MentionFolderNav) => void
|
||||
}
|
||||
|
||||
type InsertHandlerMap = Record<MentionFolderId, (item: any) => void>
|
||||
|
||||
function MentionMenuContent({
|
||||
export function MentionMenu({
|
||||
mentionMenu,
|
||||
mentionData,
|
||||
message,
|
||||
insertHandlers,
|
||||
onFolderNavChange,
|
||||
}: MentionMenuProps) {
|
||||
const { currentFolder, openFolder, closeFolder } = usePopoverContext()
|
||||
|
||||
const {
|
||||
mentionMenuRef,
|
||||
menuListRef,
|
||||
getActiveMentionQueryAtPosition,
|
||||
getCaretPos,
|
||||
submenuActiveIndex,
|
||||
mentionActiveIndex,
|
||||
setSubmenuActiveIndex,
|
||||
openSubmenuFor,
|
||||
setOpenSubmenuFor,
|
||||
} = 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])
|
||||
|
||||
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]
|
||||
)
|
||||
|
||||
/**
|
||||
* Collect and filter all available items based on query
|
||||
*/
|
||||
const filteredAggregatedItems = useMemo(() => {
|
||||
if (!currentQuery) return []
|
||||
|
||||
const items: AggregatedItem[] = []
|
||||
const q = currentQuery.toLowerCase()
|
||||
|
||||
for (const folderId of FOLDER_ORDER) {
|
||||
const config = FOLDER_CONFIGS[folderId]
|
||||
const folderData = getFolderData(folderId)
|
||||
// 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,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
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),
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
// 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' }}
|
||||
/>
|
||||
),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if ('docs'.includes(q)) {
|
||||
// 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)) {
|
||||
items.push({
|
||||
id: 'docs',
|
||||
label: 'Docs',
|
||||
@@ -187,114 +237,107 @@ function MentionMenuContent({
|
||||
}
|
||||
|
||||
return items
|
||||
}, [currentQuery, getFolderData])
|
||||
}, [currentQuery, mentionData])
|
||||
|
||||
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]
|
||||
)
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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)
|
||||
// Open state derived directly from mention menu
|
||||
const open = !!mentionMenu.showMentionMenu
|
||||
|
||||
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>
|
||||
)
|
||||
})}
|
||||
// Show filtered aggregated view when there's a query
|
||||
const showAggregatedView = currentQuery.length > 0
|
||||
|
||||
<PopoverItem
|
||||
rootOnly
|
||||
onClick={() => insertHandlers.insertDocsMention()}
|
||||
active={isInFolderNavigationMode && mentionActiveIndex === FOLDER_ORDER.length}
|
||||
data-idx={FOLDER_ORDER.length}
|
||||
>
|
||||
<span>Docs</span>
|
||||
</PopoverItem>
|
||||
</>
|
||||
)}
|
||||
</PopoverScrollArea>
|
||||
)
|
||||
}
|
||||
// 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
|
||||
|
||||
export function MentionMenu({
|
||||
mentionMenu,
|
||||
mentionData,
|
||||
message,
|
||||
insertHandlers,
|
||||
onFolderNavChange,
|
||||
}: MentionMenuProps) {
|
||||
const { mentionMenuRef, textareaRef, getCaretPos } = mentionMenu
|
||||
const isInFolderNavigationMode = !openSubmenuFor && !showAggregatedView
|
||||
|
||||
const textareaEl = mentionMenu.textareaRef.current
|
||||
if (!textareaEl) return null
|
||||
|
||||
const caretPos = getCaretPos()
|
||||
const { caretViewport, side } = useCaretViewport({ textareaRef, message, caretPos })
|
||||
const textareaRect = textareaEl.getBoundingClientRect()
|
||||
const style = window.getComputedStyle(textareaEl)
|
||||
|
||||
if (!caretViewport) return null
|
||||
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'
|
||||
|
||||
return (
|
||||
<Popover open={true} onOpenChange={() => {}}>
|
||||
<Popover open={open} onOpenChange={() => {}}>
|
||||
<PopoverAnchor asChild>
|
||||
<div
|
||||
style={{
|
||||
@@ -314,19 +357,401 @@ 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 />
|
||||
<MentionMenuContent
|
||||
mentionMenu={mentionMenu}
|
||||
mentionData={mentionData}
|
||||
message={message}
|
||||
insertHandlers={insertHandlers}
|
||||
onFolderNavChange={onFolderNavChange}
|
||||
/>
|
||||
<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>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
|
||||
@@ -32,6 +32,13 @@ function getModelIconComponent(modelValue: string) {
|
||||
return <IconComponent className='h-3.5 w-3.5' />
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a model should display the MAX badge
|
||||
*/
|
||||
function isMaxModel(modelValue: string): boolean {
|
||||
return modelValue === 'claude-4.5-sonnet' || modelValue === 'claude-4.5-opus'
|
||||
}
|
||||
|
||||
/**
|
||||
* Model selector dropdown for choosing AI model.
|
||||
* Displays model icon and label.
|
||||
@@ -132,6 +139,11 @@ export function ModelSelector({ selectedModel, isNearTop, onModelSelect }: Model
|
||||
>
|
||||
{getModelIconComponent(option.value)}
|
||||
<span>{option.label}</span>
|
||||
{isMaxModel(option.value) && (
|
||||
<Badge size='sm' className='ml-auto'>
|
||||
MAX
|
||||
</Badge>
|
||||
)}
|
||||
</PopoverItem>
|
||||
))}
|
||||
</PopoverScrollArea>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
@@ -9,57 +9,51 @@ 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'
|
||||
|
||||
export interface SlashFolderNav {
|
||||
isInFolder: boolean
|
||||
openWebFolder: () => void
|
||||
closeFolder: () => void
|
||||
}
|
||||
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]
|
||||
|
||||
interface SlashMenuProps {
|
||||
mentionMenu: ReturnType<typeof useMentionMenu>
|
||||
message: string
|
||||
onSelectCommand: (command: string) => void
|
||||
onFolderNavChange?: (nav: SlashFolderNav) => void
|
||||
}
|
||||
|
||||
function SlashMenuContent({
|
||||
mentionMenu,
|
||||
message,
|
||||
onSelectCommand,
|
||||
onFolderNavChange,
|
||||
}: SlashMenuProps) {
|
||||
const { currentFolder, openFolder, closeFolder } = usePopoverContext()
|
||||
|
||||
export function SlashMenu({ mentionMenu, message, onSelectCommand }: SlashMenuProps) {
|
||||
const {
|
||||
mentionMenuRef,
|
||||
menuListRef,
|
||||
getActiveSlashQueryAtPosition,
|
||||
getCaretPos,
|
||||
submenuActiveIndex,
|
||||
mentionActiveIndex,
|
||||
setSubmenuActiveIndex,
|
||||
openSubmenuFor,
|
||||
setOpenSubmenuFor,
|
||||
} = mentionMenu
|
||||
|
||||
const caretPos = getCaretPos()
|
||||
|
||||
const currentQuery = useMemo(() => {
|
||||
const caretPos = getCaretPos()
|
||||
const active = getActiveSlashQueryAtPosition(caretPos, message)
|
||||
return active?.query.trim().toLowerCase() || ''
|
||||
}, [message, caretPos, getActiveSlashQueryAtPosition])
|
||||
}, [message, getCaretPos, getActiveSlashQueryAtPosition])
|
||||
|
||||
const filteredCommands = useMemo(() => {
|
||||
if (!currentQuery) return null
|
||||
return ALL_SLASH_COMMANDS.filter(
|
||||
return ALL_COMMANDS.filter(
|
||||
(cmd) =>
|
||||
cmd.id.toLowerCase().includes(currentQuery) ||
|
||||
cmd.label.toLowerCase().includes(currentQuery)
|
||||
@@ -67,106 +61,52 @@ function SlashMenuContent({
|
||||
}, [currentQuery])
|
||||
|
||||
const showAggregatedView = currentQuery.length > 0
|
||||
const isInFolder = currentFolder !== null
|
||||
const isInFolderNavigationMode = !isInFolder && !showAggregatedView
|
||||
const isInFolderNavigationMode = !openSubmenuFor && !showAggregatedView
|
||||
|
||||
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 textareaEl = mentionMenu.textareaRef.current
|
||||
if (!textareaEl) return null
|
||||
|
||||
const caretPos = getCaretPos()
|
||||
const textareaRect = textareaEl.getBoundingClientRect()
|
||||
const style = window.getComputedStyle(textareaEl)
|
||||
|
||||
const { caretViewport, side } = useCaretViewport({
|
||||
textareaRef,
|
||||
message,
|
||||
caretPos,
|
||||
})
|
||||
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)
|
||||
|
||||
if (!caretViewport) return null
|
||||
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 (
|
||||
<Popover open={true} onOpenChange={() => {}}>
|
||||
@@ -189,18 +129,77 @@ export function SlashMenu({
|
||||
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 />
|
||||
<SlashMenuContent
|
||||
mentionMenu={mentionMenu}
|
||||
message={message}
|
||||
onSelectCommand={onSelectCommand}
|
||||
onFolderNavChange={onFolderNavChange}
|
||||
/>
|
||||
<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>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
|
||||
@@ -1,245 +1,42 @@
|
||||
import type { ChatContext } from '@/stores/panel'
|
||||
/**
|
||||
* Constants for user input component
|
||||
*/
|
||||
|
||||
/**
|
||||
* Mention folder types
|
||||
* Mention menu options in order (matches visual render order)
|
||||
*/
|
||||
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' },
|
||||
export const MENTION_OPTIONS = [
|
||||
'Chats',
|
||||
'Workflows',
|
||||
'Knowledge',
|
||||
'Blocks',
|
||||
'Workflow Blocks',
|
||||
'Templates',
|
||||
'Logs',
|
||||
'Docs',
|
||||
] 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: 'gpt-5.2-codex', label: 'GPT 5.2 Codex' },
|
||||
{ value: 'gpt-5.2-pro', label: 'GPT 5.2 Pro' },
|
||||
// { 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
|
||||
|
||||
@@ -252,18 +49,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export { useCaretViewport } from './use-caret-viewport'
|
||||
export { useContextManagement } from './use-context-management'
|
||||
export { useFileAttachments } from './use-file-attachments'
|
||||
export { useMentionData } from './use-mention-data'
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
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])
|
||||
}
|
||||
@@ -1,8 +1,4 @@
|
||||
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 {
|
||||
@@ -39,7 +35,53 @@ export function useContextManagement({ message, initialContexts }: UseContextMan
|
||||
*/
|
||||
const addContext = useCallback((context: ChatContext) => {
|
||||
setSelectedContexts((prev) => {
|
||||
if (isContextAlreadySelected(context, prev)) return 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
|
||||
return [...prev, context]
|
||||
})
|
||||
}, [])
|
||||
@@ -50,7 +92,38 @@ export function useContextManagement({ message, initialContexts }: UseContextMan
|
||||
* @param contextToRemove - Context to remove
|
||||
*/
|
||||
const removeContext = useCallback((contextToRemove: ChatContext) => {
|
||||
setSelectedContexts((prev) => filterOutContext(prev, contextToRemove))
|
||||
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
|
||||
}
|
||||
})
|
||||
)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
|
||||
@@ -83,36 +83,6 @@ 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
|
||||
@@ -120,7 +90,7 @@ export interface MentionDataReturn {
|
||||
* @param props - Configuration including workflow and workspace IDs
|
||||
* @returns Mention data state and loading operations
|
||||
*/
|
||||
export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
|
||||
export function useMentionData(props: UseMentionDataProps) {
|
||||
const { workflowId, workspaceId } = props
|
||||
|
||||
const { config, isBlockAllowed } = usePermissionConfig()
|
||||
@@ -134,6 +104,7 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
|
||||
const [blocksList, setBlocksList] = useState<BlockItem[]>([])
|
||||
const [isLoadingBlocks, setIsLoadingBlocks] = useState(false)
|
||||
|
||||
// Reset blocks list when permission config changes
|
||||
useEffect(() => {
|
||||
setBlocksList([])
|
||||
}, [config.allowedIntegrations])
|
||||
@@ -147,10 +118,12 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
|
||||
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 =
|
||||
@@ -158,6 +131,7 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
|
||||
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) => {
|
||||
@@ -245,6 +219,14 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
|
||||
}
|
||||
}, [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
|
||||
*/
|
||||
@@ -366,6 +348,18 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
|
||||
}
|
||||
}, [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,
|
||||
@@ -385,9 +379,11 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
|
||||
|
||||
// Operations
|
||||
ensurePastChatsLoaded,
|
||||
ensureWorkflowsLoaded,
|
||||
ensureKnowledgeLoaded,
|
||||
ensureBlocksLoaded,
|
||||
ensureTemplatesLoaded,
|
||||
ensureLogsLoaded,
|
||||
ensureWorkflowBlocksLoaded,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
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 { useCallback } from 'react'
|
||||
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 {
|
||||
@@ -18,12 +11,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
|
||||
@@ -33,7 +26,6 @@ export function useMentionInsertHandlers({
|
||||
workflowId,
|
||||
selectedContexts,
|
||||
onContextAdd,
|
||||
mentionFolderNav,
|
||||
}: UseMentionInsertHandlersProps) {
|
||||
const {
|
||||
replaceActiveMentionWith,
|
||||
@@ -44,94 +36,342 @@ export function useMentionInsertHandlers({
|
||||
} = mentionMenu
|
||||
|
||||
/**
|
||||
* Closes all menus and resets state
|
||||
* 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
|
||||
*/
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
if (config.useInsertFallback) {
|
||||
if (!replaceActiveMentionWith(label)) {
|
||||
insertAtCursor(` @${label} `)
|
||||
// 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
|
||||
}
|
||||
} else {
|
||||
replaceActiveMentionWith(label)
|
||||
}
|
||||
|
||||
onContextAdd(context)
|
||||
closeMenus()
|
||||
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
|
||||
}
|
||||
|
||||
replaceActiveMentionWith(label)
|
||||
onContextAdd(context)
|
||||
setShowMentionMenu(false)
|
||||
setOpenSubmenuFor(null)
|
||||
},
|
||||
[
|
||||
workflowId,
|
||||
selectedContexts,
|
||||
replaceActiveMentionWith,
|
||||
insertAtCursor,
|
||||
onContextAdd,
|
||||
setShowMentionMenu,
|
||||
setOpenSubmenuFor,
|
||||
isContextAlreadySelected,
|
||||
resetActiveMentionQuery,
|
||||
closeMenus,
|
||||
]
|
||||
)
|
||||
|
||||
/**
|
||||
* Special handler for Docs (no item parameter, uses DOCS_CONFIG)
|
||||
* 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)
|
||||
},
|
||||
[
|
||||
replaceActiveMentionWith,
|
||||
onContextAdd,
|
||||
setShowMentionMenu,
|
||||
setOpenSubmenuFor,
|
||||
isContextAlreadySelected,
|
||||
resetActiveMentionQuery,
|
||||
]
|
||||
)
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
const insertDocsMention = useCallback(() => {
|
||||
const label = DOCS_CONFIG.getLabel()
|
||||
const context = DOCS_CONFIG.buildContext()
|
||||
const label = 'Docs'
|
||||
const context = { kind: 'docs', label } as any
|
||||
|
||||
// Prevent duplicate insertion
|
||||
if (isContextAlreadySelected(context, selectedContexts)) {
|
||||
if (isContextAlreadySelected(context)) {
|
||||
// Clear the partial mention text before closing
|
||||
resetActiveMentionQuery()
|
||||
closeMenus()
|
||||
setShowMentionMenu(false)
|
||||
setOpenSubmenuFor(null)
|
||||
return
|
||||
}
|
||||
|
||||
// Docs uses fallback insertion
|
||||
if (!replaceActiveMentionWith(label)) {
|
||||
insertAtCursor(` @${label} `)
|
||||
}
|
||||
|
||||
if (!replaceActiveMentionWith(label)) insertAtCursor(` @${label} `)
|
||||
onContextAdd(context)
|
||||
closeMenus()
|
||||
setShowMentionMenu(false)
|
||||
setOpenSubmenuFor(null)
|
||||
}, [
|
||||
selectedContexts,
|
||||
replaceActiveMentionWith,
|
||||
insertAtCursor,
|
||||
onContextAdd,
|
||||
setShowMentionMenu,
|
||||
setOpenSubmenuFor,
|
||||
isContextAlreadySelected,
|
||||
resetActiveMentionQuery,
|
||||
closeMenus,
|
||||
])
|
||||
|
||||
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
|
||||
return {
|
||||
insertPastChatMention,
|
||||
insertWorkflowMention,
|
||||
insertKnowledgeMention,
|
||||
insertBlockMention,
|
||||
insertWorkflowBlockMention,
|
||||
insertTemplateMention,
|
||||
insertLogMention,
|
||||
insertDocsMention,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,56 @@
|
||||
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'
|
||||
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
|
||||
}
|
||||
|
||||
interface UseMentionKeyboardProps {
|
||||
/** Mention menu hook instance */
|
||||
@@ -22,34 +59,37 @@ interface UseMentionKeyboardProps {
|
||||
mentionData: ReturnType<typeof useMentionData>
|
||||
/** Callback to insert specific mention types */
|
||||
insertHandlers: {
|
||||
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
|
||||
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
|
||||
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,
|
||||
@@ -58,101 +98,65 @@ export function useMentionKeyboard({
|
||||
scrollActiveItemIntoView,
|
||||
} = mentionMenu
|
||||
|
||||
const currentFolder = mentionFolderNav?.currentFolder ?? null
|
||||
const isInFolder = mentionFolderNav?.isInFolder ?? false
|
||||
const {
|
||||
pastChats,
|
||||
workflows,
|
||||
knowledgeBases,
|
||||
blocksList,
|
||||
workflowBlocks,
|
||||
templatesList,
|
||||
logsList,
|
||||
ensurePastChatsLoaded,
|
||||
ensureWorkflowsLoaded,
|
||||
ensureKnowledgeLoaded,
|
||||
ensureBlocksLoaded,
|
||||
ensureWorkflowBlocksLoaded,
|
||||
ensureTemplatesLoaded,
|
||||
ensureLogsLoaded,
|
||||
} = mentionData
|
||||
|
||||
/**
|
||||
* 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]
|
||||
)
|
||||
const {
|
||||
insertPastChatMention,
|
||||
insertWorkflowMention,
|
||||
insertKnowledgeMention,
|
||||
insertBlockMention,
|
||||
insertWorkflowBlockMention,
|
||||
insertTemplateMention,
|
||||
insertLogMention,
|
||||
insertDocsMention,
|
||||
} = insertHandlers
|
||||
|
||||
/**
|
||||
* Build aggregated list matching the portal's ordering
|
||||
*/
|
||||
const buildAggregatedList = useCallback(
|
||||
(query: string): Array<{ type: MentionFolderId | 'docs'; value: any }> => {
|
||||
(query: string) => {
|
||||
const q = query.toLowerCase()
|
||||
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
|
||||
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 })),
|
||||
]
|
||||
},
|
||||
[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]
|
||||
[pastChats, workflows, knowledgeBases, blocksList, workflowBlocks, templatesList, logsList]
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -165,36 +169,143 @@ export function useMentionKeyboard({
|
||||
e.preventDefault()
|
||||
const caretPos = getCaretPos()
|
||||
const active = getActiveMentionQueryAtPosition(caretPos)
|
||||
const mainQ = (!isInFolder ? active?.query || '' : '').toLowerCase()
|
||||
const direction = e.key === 'ArrowDown' ? 'down' : 'up'
|
||||
const mainQ = (!openSubmenuFor ? active?.query || '' : '').toLowerCase()
|
||||
|
||||
// When there's a query, we show aggregated filtered view (no folders)
|
||||
const showAggregatedView = mainQ.length > 0
|
||||
if (showAggregatedView && !isInFolder) {
|
||||
const aggregatedList = buildAggregatedList(mainQ)
|
||||
navigateItems(direction, aggregatedList.length, setSubmenuActiveIndex)
|
||||
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
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
if (currentFolder && FOLDER_CONFIGS[currentFolder as MentionFolderId]) {
|
||||
// Handle submenu navigation
|
||||
if (openSubmenuFor === 'Chats') {
|
||||
const q = getSubmenuQuery().toLowerCase()
|
||||
const filtered = filterFolderItems(currentFolder as MentionFolderId, q)
|
||||
navigateItems(direction, filtered.length, setSubmenuActiveIndex)
|
||||
return true
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
navigateItems(direction, ROOT_MENU_ITEM_COUNT, setMentionActiveIndex)
|
||||
return true
|
||||
},
|
||||
[
|
||||
showMentionMenu,
|
||||
isInFolder,
|
||||
currentFolder,
|
||||
openSubmenuFor,
|
||||
mentionActiveIndex,
|
||||
submenuActiveIndex,
|
||||
buildAggregatedList,
|
||||
filterFolderItems,
|
||||
navigateItems,
|
||||
pastChats,
|
||||
workflows,
|
||||
knowledgeBases,
|
||||
blocksList,
|
||||
workflowBlocks,
|
||||
templatesList,
|
||||
logsList,
|
||||
getCaretPos,
|
||||
getActiveMentionQueryAtPosition,
|
||||
getSubmenuQuery,
|
||||
scrollActiveItemIntoView,
|
||||
setMentionActiveIndex,
|
||||
setSubmenuActiveIndex,
|
||||
]
|
||||
@@ -205,30 +316,65 @@ export function useMentionKeyboard({
|
||||
*/
|
||||
const handleArrowRight = useCallback(
|
||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (!showMentionMenu || e.key !== 'ArrowRight' || !mentionFolderNav) return false
|
||||
if (!showMentionMenu || e.key !== 'ArrowRight') return false
|
||||
|
||||
const caretPos = getCaretPos()
|
||||
const active = getActiveMentionQueryAtPosition(caretPos)
|
||||
const mainQ = (active?.query || '').toLowerCase()
|
||||
const showAggregatedView = mainQ.length > 0
|
||||
|
||||
if (mainQ.length > 0) return false
|
||||
// Don't handle arrow right in aggregated view (user is filtering, not navigating folders)
|
||||
if (showAggregatedView) return false
|
||||
|
||||
e.preventDefault()
|
||||
const filteredMain = MENTION_OPTIONS.filter((o) => o.toLowerCase().includes(mainQ))
|
||||
const selected = filteredMain[mentionActiveIndex]
|
||||
|
||||
const isDocsSelected = mentionActiveIndex === FOLDER_ORDER.length
|
||||
if (isDocsSelected) {
|
||||
if (selected === 'Chats') {
|
||||
resetActiveMentionQuery()
|
||||
insertHandlers.insertDocsMention()
|
||||
return true
|
||||
}
|
||||
|
||||
const selectedFolderId = FOLDER_ORDER[mentionActiveIndex]
|
||||
if (selectedFolderId) {
|
||||
const config = FOLDER_CONFIGS[selectedFolderId]
|
||||
resetActiveMentionQuery()
|
||||
mentionFolderNav.openFolder(selectedFolderId, config.title)
|
||||
setOpenSubmenuFor('Chats')
|
||||
setSubmenuActiveIndex(0)
|
||||
setSubmenuQueryStart(getCaretPos())
|
||||
ensureFolderLoaded(selectedFolderId)
|
||||
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()
|
||||
}
|
||||
|
||||
return true
|
||||
@@ -236,13 +382,21 @@ export function useMentionKeyboard({
|
||||
[
|
||||
showMentionMenu,
|
||||
mentionActiveIndex,
|
||||
mentionFolderNav,
|
||||
openSubmenuFor,
|
||||
getCaretPos,
|
||||
getActiveMentionQueryAtPosition,
|
||||
resetActiveMentionQuery,
|
||||
setOpenSubmenuFor,
|
||||
setSubmenuActiveIndex,
|
||||
setSubmenuQueryStart,
|
||||
ensureFolderLoaded,
|
||||
insertHandlers,
|
||||
ensurePastChatsLoaded,
|
||||
ensureWorkflowsLoaded,
|
||||
ensureKnowledgeLoaded,
|
||||
ensureBlocksLoaded,
|
||||
ensureWorkflowBlocksLoaded,
|
||||
ensureTemplatesLoaded,
|
||||
ensureLogsLoaded,
|
||||
insertDocsMention,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -253,16 +407,16 @@ export function useMentionKeyboard({
|
||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (!showMentionMenu || e.key !== 'ArrowLeft') return false
|
||||
|
||||
if (isInFolder && mentionFolderNav) {
|
||||
if (openSubmenuFor) {
|
||||
e.preventDefault()
|
||||
mentionFolderNav.closeFolder()
|
||||
setOpenSubmenuFor(null)
|
||||
setSubmenuQueryStart(null)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
[showMentionMenu, isInFolder, mentionFolderNav, setSubmenuQueryStart]
|
||||
[showMentionMenu, openSubmenuFor, setOpenSubmenuFor, setSubmenuQueryStart]
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -275,74 +429,179 @@ export function useMentionKeyboard({
|
||||
e.preventDefault()
|
||||
const caretPos = getCaretPos()
|
||||
const active = getActiveMentionQueryAtPosition(caretPos)
|
||||
const mainQ = (!isInFolder ? active?.query || '' : '').toLowerCase()
|
||||
const mainQ = (active?.query || '').toLowerCase()
|
||||
const showAggregatedView = mainQ.length > 0
|
||||
const filteredMain = MENTION_OPTIONS.filter((o) => o.toLowerCase().includes(mainQ))
|
||||
const selected = filteredMain[mentionActiveIndex]
|
||||
|
||||
if (showAggregatedView && !isInFolder) {
|
||||
// Handle selection in aggregated filtered view
|
||||
if (showAggregatedView && !openSubmenuFor) {
|
||||
const aggregated = buildAggregatedList(mainQ)
|
||||
const idx = Math.max(0, Math.min(submenuActiveIndex, aggregated.length - 1))
|
||||
const chosen = aggregated[idx]
|
||||
if (chosen) {
|
||||
if (chosen.type === 'docs') {
|
||||
insertHandlers.insertDocsMention()
|
||||
} else {
|
||||
const handler = insertHandlerMap[chosen.type]
|
||||
handler(chosen.value)
|
||||
}
|
||||
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)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (isInFolder && currentFolder && FOLDER_CONFIGS[currentFolder as MentionFolderId]) {
|
||||
const folderId = currentFolder as MentionFolderId
|
||||
const q = getSubmenuQuery().toLowerCase()
|
||||
const filtered = filterFolderItems(folderId, q)
|
||||
if (filtered.length > 0) {
|
||||
const chosen = filtered[Math.max(0, Math.min(submenuActiveIndex, filtered.length - 1))]
|
||||
const handler = insertHandlerMap[folderId]
|
||||
handler(chosen)
|
||||
setSubmenuQueryStart(null)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const isDocsSelected = mentionActiveIndex === FOLDER_ORDER.length
|
||||
if (isDocsSelected) {
|
||||
// Handle folder navigation when no query
|
||||
if (!openSubmenuFor && selected === 'Chats') {
|
||||
resetActiveMentionQuery()
|
||||
insertHandlers.insertDocsMention()
|
||||
return true
|
||||
}
|
||||
|
||||
const selectedFolderId = FOLDER_ORDER[mentionActiveIndex]
|
||||
if (selectedFolderId && mentionFolderNav) {
|
||||
const config = FOLDER_CONFIGS[selectedFolderId]
|
||||
resetActiveMentionQuery()
|
||||
mentionFolderNav.openFolder(selectedFolderId, config.title)
|
||||
setOpenSubmenuFor('Chats')
|
||||
setSubmenuActiveIndex(0)
|
||||
setSubmenuQueryStart(getCaretPos())
|
||||
ensureFolderLoaded(selectedFolderId)
|
||||
void ensurePastChatsLoaded()
|
||||
} else if (openSubmenuFor === 'Chats') {
|
||||
const q = getSubmenuQuery().toLowerCase()
|
||||
const filtered = pastChats.filter((c) => (c.title || 'New Chat').toLowerCase().includes(q))
|
||||
if (filtered.length > 0) {
|
||||
const chosen = filtered[Math.max(0, Math.min(submenuActiveIndex, filtered.length - 1))]
|
||||
insertPastChatMention(chosen)
|
||||
setSubmenuQueryStart(null)
|
||||
}
|
||||
} else if (!openSubmenuFor && selected === 'Workflows') {
|
||||
resetActiveMentionQuery()
|
||||
setOpenSubmenuFor('Workflows')
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
[
|
||||
showMentionMenu,
|
||||
isInFolder,
|
||||
currentFolder,
|
||||
openSubmenuFor,
|
||||
mentionActiveIndex,
|
||||
submenuActiveIndex,
|
||||
mentionFolderNav,
|
||||
buildAggregatedList,
|
||||
filterFolderItems,
|
||||
insertHandlerMap,
|
||||
pastChats,
|
||||
workflows,
|
||||
knowledgeBases,
|
||||
blocksList,
|
||||
workflowBlocks,
|
||||
templatesList,
|
||||
logsList,
|
||||
getCaretPos,
|
||||
getActiveMentionQueryAtPosition,
|
||||
getSubmenuQuery,
|
||||
resetActiveMentionQuery,
|
||||
setOpenSubmenuFor,
|
||||
setSubmenuActiveIndex,
|
||||
setSubmenuQueryStart,
|
||||
ensureFolderLoaded,
|
||||
insertHandlers,
|
||||
ensurePastChatsLoaded,
|
||||
ensureWorkflowsLoaded,
|
||||
ensureKnowledgeLoaded,
|
||||
ensureBlocksLoaded,
|
||||
ensureWorkflowBlocksLoaded,
|
||||
ensureTemplatesLoaded,
|
||||
ensureLogsLoaded,
|
||||
insertPastChatMention,
|
||||
insertWorkflowMention,
|
||||
insertKnowledgeMention,
|
||||
insertBlockMention,
|
||||
insertWorkflowBlockMention,
|
||||
insertTemplateMention,
|
||||
insertLogMention,
|
||||
insertDocsMention,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { SCROLL_TOLERANCE } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { ChatContext } from '@/stores/panel'
|
||||
import { SCROLL_TOLERANCE } from '../constants'
|
||||
|
||||
const logger = createLogger('useMentionMenu')
|
||||
|
||||
interface UseMentionMenuProps {
|
||||
/** Current message text */
|
||||
|
||||
@@ -49,6 +49,7 @@ 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
|
||||
@@ -65,6 +66,7 @@ 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
|
||||
@@ -78,6 +80,7 @@ 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
|
||||
@@ -88,17 +91,20 @@ 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
|
||||
})
|
||||
@@ -113,20 +119,25 @@ 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'
|
||||
|
||||
@@ -135,18 +146,22 @@ 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])
|
||||
|
||||
@@ -177,15 +192,19 @@ 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()
|
||||
@@ -193,6 +212,7 @@ export function useTextareaAutoResize({
|
||||
textareaResizeObserverRef.current.observe(textarea)
|
||||
}
|
||||
|
||||
// Setup MutationObserver to detect style changes
|
||||
const mutationObserver = new MutationObserver(() => {
|
||||
syncOverlayStyles.current()
|
||||
})
|
||||
@@ -201,9 +221,11 @@ 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)
|
||||
|
||||
@@ -18,21 +18,12 @@ 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 {
|
||||
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 { NEAR_TOP_THRESHOLD } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
|
||||
import {
|
||||
useContextManagement,
|
||||
useFileAttachments,
|
||||
@@ -49,6 +40,24 @@ 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,
|
||||
@@ -135,8 +144,6 @@ 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 =
|
||||
@@ -191,14 +198,12 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
workflowId: workflowId || null,
|
||||
selectedContexts: contextManagement.selectedContexts,
|
||||
onContextAdd: contextManagement.addContext,
|
||||
mentionFolderNav,
|
||||
})
|
||||
|
||||
const mentionKeyboard = useMentionKeyboard({
|
||||
mentionMenu,
|
||||
mentionData,
|
||||
insertHandlers,
|
||||
mentionFolderNav,
|
||||
})
|
||||
|
||||
useImperativeHandle(
|
||||
@@ -217,6 +222,13 @@ 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) {
|
||||
@@ -252,7 +264,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
}, [mentionMenu.showMentionMenu, containerRef])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mentionMenu.showMentionMenu || mentionFolderNav?.isInFolder) {
|
||||
if (!mentionMenu.showMentionMenu || mentionMenu.openSubmenuFor) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -263,7 +275,8 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
|
||||
if (q && q.length > 0) {
|
||||
void mentionData.ensurePastChatsLoaded()
|
||||
// workflows and workflow-blocks auto-load from stores
|
||||
void mentionData.ensureWorkflowsLoaded()
|
||||
void mentionData.ensureWorkflowBlocksLoaded()
|
||||
void mentionData.ensureKnowledgeLoaded()
|
||||
void mentionData.ensureBlocksLoaded()
|
||||
void mentionData.ensureTemplatesLoaded()
|
||||
@@ -273,15 +286,15 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(0))
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mentionMenu.showMentionMenu, mentionFolderNav?.isInFolder, message])
|
||||
}, [mentionMenu.showMentionMenu, mentionMenu.openSubmenuFor, message])
|
||||
|
||||
useEffect(() => {
|
||||
if (mentionFolderNav?.isInFolder) {
|
||||
if (mentionMenu.openSubmenuFor) {
|
||||
mentionMenu.setSubmenuActiveIndex(0)
|
||||
requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(0))
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mentionFolderNav?.isInFolder])
|
||||
}, [mentionMenu.openSubmenuFor])
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (overrideMessage?: string, options: { preserveInput?: boolean } = {}) => {
|
||||
@@ -359,7 +372,8 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
|
||||
const handleSlashCommandSelect = useCallback(
|
||||
(command: string) => {
|
||||
const displayLabel = getCommandDisplayLabel(command)
|
||||
const displayLabel =
|
||||
COMMAND_DISPLAY_LABELS[command] || command.charAt(0).toUpperCase() + command.slice(1)
|
||||
mentionMenu.replaceActiveSlashWith(displayLabel)
|
||||
contextManagement.addContext({
|
||||
kind: 'slash_command',
|
||||
@@ -377,11 +391,9 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Escape' && (mentionMenu.showMentionMenu || showSlashMenu)) {
|
||||
e.preventDefault()
|
||||
if (mentionFolderNav?.isInFolder) {
|
||||
mentionFolderNav.closeFolder()
|
||||
if (mentionMenu.openSubmenuFor) {
|
||||
mentionMenu.setOpenSubmenuFor(null)
|
||||
mentionMenu.setSubmenuQueryStart(null)
|
||||
} else if (slashFolderNav?.isInFolder) {
|
||||
slashFolderNav.closeFolder()
|
||||
} else {
|
||||
mentionMenu.closeMentionMenu()
|
||||
setShowSlashMenu(false)
|
||||
@@ -395,19 +407,18 @@ 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 (isInFolder) {
|
||||
if (mentionMenu.openSubmenuFor === 'Web') {
|
||||
mentionMenu.setSubmenuActiveIndex((prev) => {
|
||||
const next = getNextIndex(prev, direction, WEB_COMMANDS.length - 1)
|
||||
requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(next))
|
||||
return next
|
||||
})
|
||||
} else if (showAggregatedView) {
|
||||
const filtered = ALL_COMMAND_IDS.filter((cmd) => cmd.includes(query))
|
||||
const filtered = ALL_COMMANDS.filter((cmd) => cmd.includes(query))
|
||||
mentionMenu.setSubmenuActiveIndex((prev) => {
|
||||
if (filtered.length === 0) return 0
|
||||
const next = getNextIndex(prev, direction, filtered.length - 1)
|
||||
@@ -426,9 +437,10 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
|
||||
if (e.key === 'ArrowRight') {
|
||||
e.preventDefault()
|
||||
if (!showAggregatedView && !isInFolder) {
|
||||
if (!showAggregatedView && !mentionMenu.openSubmenuFor) {
|
||||
if (mentionMenu.mentionActiveIndex === TOP_LEVEL_COMMANDS.length) {
|
||||
slashFolderNav?.openWebFolder()
|
||||
mentionMenu.setOpenSubmenuFor('Web')
|
||||
mentionMenu.setSubmenuActiveIndex(0)
|
||||
}
|
||||
}
|
||||
return
|
||||
@@ -436,8 +448,8 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault()
|
||||
if (isInFolder) {
|
||||
slashFolderNav?.closeFolder()
|
||||
if (mentionMenu.openSubmenuFor) {
|
||||
mentionMenu.setOpenSubmenuFor(null)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -454,14 +466,13 @@ 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 (isInFolder) {
|
||||
if (mentionMenu.openSubmenuFor === 'Web') {
|
||||
const selectedCommand =
|
||||
WEB_COMMANDS[mentionMenu.submenuActiveIndex]?.id || WEB_COMMANDS[0].id
|
||||
WEB_COMMANDS[mentionMenu.submenuActiveIndex] || WEB_COMMANDS[0]
|
||||
handleSlashCommandSelect(selectedCommand)
|
||||
} else if (showAggregatedView) {
|
||||
const filtered = ALL_COMMAND_IDS.filter((cmd) => cmd.includes(query))
|
||||
const filtered = ALL_COMMANDS.filter((cmd) => cmd.includes(query))
|
||||
if (filtered.length > 0) {
|
||||
const selectedCommand = filtered[mentionMenu.submenuActiveIndex] || filtered[0]
|
||||
handleSlashCommandSelect(selectedCommand)
|
||||
@@ -469,9 +480,10 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
} else {
|
||||
const selectedIndex = mentionMenu.mentionActiveIndex
|
||||
if (selectedIndex < TOP_LEVEL_COMMANDS.length) {
|
||||
handleSlashCommandSelect(TOP_LEVEL_COMMANDS[selectedIndex].id)
|
||||
handleSlashCommandSelect(TOP_LEVEL_COMMANDS[selectedIndex])
|
||||
} else if (selectedIndex === TOP_LEVEL_COMMANDS.length) {
|
||||
slashFolderNav?.openWebFolder()
|
||||
mentionMenu.setOpenSubmenuFor('Web')
|
||||
mentionMenu.setSubmenuActiveIndex(0)
|
||||
}
|
||||
}
|
||||
return
|
||||
@@ -556,8 +568,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
message,
|
||||
mentionTokensWithContext,
|
||||
showSlashMenu,
|
||||
slashFolderNav,
|
||||
mentionFolderNav,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -576,7 +586,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
setShowSlashMenu(false)
|
||||
mentionMenu.setShowMentionMenu(true)
|
||||
mentionMenu.setInAggregated(false)
|
||||
if (mentionFolderNav?.isInFolder) {
|
||||
if (mentionMenu.openSubmenuFor) {
|
||||
mentionMenu.setSubmenuActiveIndex(0)
|
||||
} else {
|
||||
mentionMenu.setMentionActiveIndex(0)
|
||||
@@ -595,7 +605,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
setShowSlashMenu(false)
|
||||
}
|
||||
},
|
||||
[setMessage, mentionMenu, disableMentions, mentionFolderNav]
|
||||
[setMessage, mentionMenu, disableMentions]
|
||||
)
|
||||
|
||||
const handleSelectAdjust = useCallback(() => {
|
||||
@@ -828,7 +838,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
mentionData={mentionData}
|
||||
message={message}
|
||||
insertHandlers={insertHandlers}
|
||||
onFolderNavChange={setMentionFolderNav}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
@@ -841,7 +850,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
mentionMenu={mentionMenu}
|
||||
message={message}
|
||||
onSelectCommand={handleSlashCommandSelect}
|
||||
onFolderNavChange={setSlashFolderNav}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -36,7 +36,6 @@ 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 {
|
||||
@@ -83,6 +82,18 @@ 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
|
||||
*/
|
||||
@@ -172,6 +183,22 @@ 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
|
||||
*/
|
||||
@@ -181,25 +208,16 @@ const formatRunId = (executionId?: string): string => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Run ID colors
|
||||
* Gets color for a run ID based on its index in the execution ID order map
|
||||
*/
|
||||
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>) => {
|
||||
const getRunIdColor = (
|
||||
executionId: string | undefined,
|
||||
executionIdOrderMap: Map<string, number>
|
||||
) => {
|
||||
if (!executionId) return null
|
||||
return colorMap.get(executionId) ?? null
|
||||
const colorIndex = executionIdOrderMap.get(executionId)
|
||||
if (colorIndex === undefined) return null
|
||||
return RUN_ID_COLORS[colorIndex % RUN_ID_COLORS.length]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -446,52 +464,25 @@ export function Terminal() {
|
||||
}, [allWorkflowEntries])
|
||||
|
||||
/**
|
||||
* Track color offset - increments when old executions are trimmed
|
||||
* so remaining executions keep their colors.
|
||||
* 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.
|
||||
*/
|
||||
const colorStateRef = useRef<{ executionIds: string[]; offset: number }>({
|
||||
executionIds: [],
|
||||
offset: 0,
|
||||
})
|
||||
const executionIdOrderMap = useMemo(() => {
|
||||
const orderMap = new Map<string, number>()
|
||||
let colorIndex = 0
|
||||
|
||||
/**
|
||||
* 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>()
|
||||
// Process entries in reverse order (oldest first) since entries array is newest-first
|
||||
// Use allWorkflowEntries to ensure colors remain consistent when filters change
|
||||
for (let i = allWorkflowEntries.length - 1; i >= 0; i--) {
|
||||
const execId = allWorkflowEntries[i].executionId
|
||||
if (execId && !seen.has(execId)) {
|
||||
currentIds.push(execId)
|
||||
seen.add(execId)
|
||||
const entry = allWorkflowEntries[i]
|
||||
if (entry.executionId && !orderMap.has(entry.executionId)) {
|
||||
orderMap.set(entry.executionId, colorIndex)
|
||||
colorIndex++
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
return orderMap
|
||||
}, [allWorkflowEntries])
|
||||
|
||||
/**
|
||||
@@ -1137,7 +1128,7 @@ export function Terminal() {
|
||||
<PopoverScrollArea style={{ maxHeight: '140px' }}>
|
||||
{uniqueRunIds.map((runId, index) => {
|
||||
const isSelected = filters.runIds.has(runId)
|
||||
const runIdColor = getRunIdColor(runId, executionColorMap)
|
||||
const runIdColor = getRunIdColor(runId, executionIdOrderMap)
|
||||
|
||||
return (
|
||||
<PopoverItem
|
||||
@@ -1148,7 +1139,7 @@ export function Terminal() {
|
||||
>
|
||||
<span
|
||||
className='flex-1 font-mono text-[12px]'
|
||||
style={{ color: runIdColor || '#D2D2D2' }}
|
||||
style={{ color: runIdColor?.text || '#D2D2D2' }}
|
||||
>
|
||||
{formatRunId(runId)}
|
||||
</span>
|
||||
@@ -1344,7 +1335,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, executionColorMap)
|
||||
const runIdColor = getRunIdColor(entry.executionId, executionIdOrderMap)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -1394,7 +1385,7 @@ export function Terminal() {
|
||||
COLUMN_BASE_CLASS,
|
||||
'truncate font-medium font-mono text-[12px]'
|
||||
)}
|
||||
style={{ color: runIdColor || '#D2D2D2' }}
|
||||
style={{ color: runIdColor?.text || '#D2D2D2' }}
|
||||
>
|
||||
{formatRunId(entry.executionId)}
|
||||
</span>
|
||||
@@ -1420,7 +1411,7 @@ export function Terminal() {
|
||||
ROW_TEXT_CLASS
|
||||
)}
|
||||
>
|
||||
{formatTimeWithSeconds(new Date(entry.timestamp))}
|
||||
{formatTimestamp(entry.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -77,9 +77,6 @@ export interface SendMessageRequest {
|
||||
| 'gpt-5.1-high'
|
||||
| 'gpt-5-codex'
|
||||
| 'gpt-5.1-codex'
|
||||
| 'gpt-5.2'
|
||||
| 'gpt-5.2-codex'
|
||||
| 'gpt-5.2-pro'
|
||||
| 'gpt-4o'
|
||||
| 'gpt-4.1'
|
||||
| 'o3'
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
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' },
|
||||
@@ -19,22 +20,30 @@ 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,
|
||||
@@ -45,9 +54,11 @@ 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
|
||||
}
|
||||
|
||||
@@ -68,6 +79,7 @@ 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}`
|
||||
@@ -102,40 +114,6 @@ 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
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,6 @@ 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,
|
||||
@@ -456,7 +455,7 @@ async function handleBlocksOperationTx(
|
||||
}
|
||||
|
||||
case BLOCKS_OPERATIONS.BATCH_ADD_BLOCKS: {
|
||||
const { blocks, edges, loops, parallels, subBlockValues } = payload
|
||||
const { blocks, edges, loops, parallels } = payload
|
||||
|
||||
logger.info(`Batch adding blocks to workflow ${workflowId}`, {
|
||||
blockCount: blocks?.length || 0,
|
||||
@@ -466,30 +465,22 @@ async function handleBlocksOperationTx(
|
||||
})
|
||||
|
||||
if (blocks && blocks.length > 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,
|
||||
}
|
||||
})
|
||||
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,
|
||||
}))
|
||||
|
||||
await tx.insert(workflowBlocks).values(blockValues)
|
||||
|
||||
|
||||
@@ -441,6 +441,7 @@ function normalizeMessagesForUI(messages: CopilotMessage[]): CopilotMessage[] {
|
||||
|
||||
// Register client tool instances and clear streaming flags for all tool calls
|
||||
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) {
|
||||
@@ -449,13 +450,15 @@ function normalizeMessagesForUI(messages: CopilotMessage[]): CopilotMessage[] {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also clear from toolCalls array (legacy format)
|
||||
// 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
|
||||
} catch {
|
||||
return messages
|
||||
|
||||
@@ -106,9 +106,6 @@ export interface CopilotState {
|
||||
| 'gpt-5.1-high'
|
||||
| 'gpt-5-codex'
|
||||
| 'gpt-5.1-codex'
|
||||
| 'gpt-5.2'
|
||||
| 'gpt-5.2-codex'
|
||||
| 'gpt-5.2-pro'
|
||||
| 'gpt-4o'
|
||||
| 'gpt-4.1'
|
||||
| 'o3'
|
||||
|
||||
@@ -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 = 1000
|
||||
const MAX_ENTRIES_PER_WORKFLOW = 500
|
||||
|
||||
const updateBlockOutput = (
|
||||
existingOutput: NormalizedBlockOutput | undefined,
|
||||
@@ -96,57 +96,13 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
|
||||
}
|
||||
|
||||
const newEntries = [newEntry, ...state.entries]
|
||||
|
||||
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)
|
||||
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
|
||||
})
|
||||
|
||||
return { entries: trimmedEntries }
|
||||
})
|
||||
|
||||
|
||||
@@ -8,8 +8,7 @@
|
||||
* or React hooks, making it safe for use in Next.js API routes.
|
||||
*/
|
||||
|
||||
import { mergeSubblockStateWithValues } from '@/lib/workflows/subblocks'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
import type { BlockState, SubBlockState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
/**
|
||||
* Server-safe version of mergeSubblockState for API routes
|
||||
@@ -27,7 +26,72 @@ export function mergeSubblockState(
|
||||
subBlockValues: Record<string, Record<string, any>> = {},
|
||||
blockId?: string
|
||||
): Record<string, BlockState> {
|
||||
return mergeSubblockStateWithValues(blocks, subBlockValues, blockId)
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
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'
|
||||
@@ -19,19 +32,6 @@ 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,20 +201,27 @@ export function prepareDuplicateBlockState(options: PrepareDuplicateBlockStateOp
|
||||
Object.entries(subBlockValues).filter(([key]) => !WEBHOOK_SUBBLOCK_FIELDS.includes(key))
|
||||
)
|
||||
|
||||
const baseSubBlocks: Record<string, SubBlockState> = sourceBlock.subBlocks
|
||||
const mergedSubBlocks: Record<string, SubBlockState> = sourceBlock.subBlocks
|
||||
? JSON.parse(JSON.stringify(sourceBlock.subBlocks))
|
||||
: {}
|
||||
|
||||
WEBHOOK_SUBBLOCK_FIELDS.forEach((field) => {
|
||||
if (field in baseSubBlocks) {
|
||||
delete baseSubBlocks[field]
|
||||
if (field in mergedSubBlocks) {
|
||||
delete mergedSubBlocks[field]
|
||||
}
|
||||
})
|
||||
|
||||
const mergedSubBlocks = mergeSubBlockValues(baseSubBlocks, filteredSubBlockValues) as Record<
|
||||
string,
|
||||
SubBlockState
|
||||
>
|
||||
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 block: BlockState = {
|
||||
id: newId,
|
||||
@@ -249,16 +256,11 @@ 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) {
|
||||
@@ -337,14 +339,8 @@ export async function mergeSubblockStateAsync(
|
||||
workflowId?: string,
|
||||
blockId?: string
|
||||
): Promise<Record<string, BlockState>> {
|
||||
const subBlockStore = useSubBlockStore.getState()
|
||||
|
||||
if (workflowId) {
|
||||
const workflowValues = subBlockStore.workflowValues[workflowId] || {}
|
||||
return mergeSubblockStateWithValues(blocks, workflowValues, blockId)
|
||||
}
|
||||
|
||||
const blocksToProcess = blockId ? { [blockId]: blocks[blockId] } : blocks
|
||||
const subBlockStore = useSubBlockStore.getState()
|
||||
|
||||
// Process blocks in parallel for better performance
|
||||
const processedBlockEntries = await Promise.all(
|
||||
@@ -362,7 +358,16 @@ export async function mergeSubblockStateAsync(
|
||||
return null
|
||||
}
|
||||
|
||||
const storedValue = subBlockStore.getValue(id, subBlockId)
|
||||
let storedValue = null
|
||||
|
||||
if (workflowId) {
|
||||
const workflowValues = subBlockStore.workflowValues[workflowId]
|
||||
if (workflowValues?.[id]) {
|
||||
storedValue = workflowValues[id][subBlockId]
|
||||
}
|
||||
} else {
|
||||
storedValue = subBlockStore.getValue(id, subBlockId)
|
||||
}
|
||||
|
||||
return [
|
||||
subBlockId,
|
||||
@@ -381,6 +386,23 @@ 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,
|
||||
|
||||
@@ -639,8 +639,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
|
||||
const newName = getUniqueBlockName(block.name, get().blocks)
|
||||
|
||||
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
|
||||
const mergedBlock = mergeSubblockState(get().blocks, activeWorkflowId || undefined, id)[id]
|
||||
const mergedBlock = mergeSubblockState(get().blocks, id)[id]
|
||||
|
||||
const newSubBlocks = Object.entries(mergedBlock.subBlocks).reduce(
|
||||
(acc, [subId, subBlock]) => ({
|
||||
@@ -669,6 +668,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
parallels: get().generateParallelBlocks(),
|
||||
}
|
||||
|
||||
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
|
||||
if (activeWorkflowId) {
|
||||
const subBlockValues =
|
||||
useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[id] || {}
|
||||
|
||||
Reference in New Issue
Block a user