mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-14 17:37:55 -05:00
Compare commits
26 Commits
feat/group
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e8c843241 | ||
|
|
7bf3d73ee6 | ||
|
|
7ffc11a738 | ||
|
|
be578e2ed7 | ||
|
|
f415e5edc4 | ||
|
|
13a6e6c3fa | ||
|
|
f5ab7f21ae | ||
|
|
bfb6fffe38 | ||
|
|
4fbec0a43f | ||
|
|
585f5e365b | ||
|
|
3792bdd252 | ||
|
|
eb5d1f3e5b | ||
|
|
54ab82c8dd | ||
|
|
f895bf469b | ||
|
|
dd3209af06 | ||
|
|
b6ba3b50a7 | ||
|
|
b304233062 | ||
|
|
57e4b49bd6 | ||
|
|
e12dd204ed | ||
|
|
3d9d9cbc54 | ||
|
|
0f4ec962ad | ||
|
|
4827866f9a | ||
|
|
3e697d9ed9 | ||
|
|
4431a1a484 | ||
|
|
4d1a9a3f22 | ||
|
|
eb07a080fb |
@@ -76,14 +76,6 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Suppress the default selection ring for grouped selections
|
|
||||||
* These blocks show a more transparent ring via the component's ring overlay
|
|
||||||
*/
|
|
||||||
.react-flow__node.selected > div[data-grouped-selection="true"] > div::after {
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Color tokens - single source of truth for all colors
|
* Color tokens - single source of truth for all colors
|
||||||
* Light mode: Warm theme
|
* Light mode: Warm theme
|
||||||
|
|||||||
@@ -97,7 +97,6 @@ const ChatMessageSchema = z.object({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
commands: z.array(z.string()).optional(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -133,7 +132,6 @@ export async function POST(req: NextRequest) {
|
|||||||
provider,
|
provider,
|
||||||
conversationId,
|
conversationId,
|
||||||
contexts,
|
contexts,
|
||||||
commands,
|
|
||||||
} = ChatMessageSchema.parse(body)
|
} = ChatMessageSchema.parse(body)
|
||||||
// Ensure we have a consistent user message ID for this request
|
// Ensure we have a consistent user message ID for this request
|
||||||
const userMessageIdToUse = userMessageId || crypto.randomUUID()
|
const userMessageIdToUse = userMessageId || crypto.randomUUID()
|
||||||
@@ -464,7 +462,6 @@ export async function POST(req: NextRequest) {
|
|||||||
...(integrationTools.length > 0 && { tools: integrationTools }),
|
...(integrationTools.length > 0 && { tools: integrationTools }),
|
||||||
...(baseTools.length > 0 && { baseTools }),
|
...(baseTools.length > 0 && { baseTools }),
|
||||||
...(credentials && { credentials }),
|
...(credentials && { credentials }),
|
||||||
...(commands && commands.length > 0 && { commands }),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -29,8 +29,6 @@ export function BlockContextMenu({
|
|||||||
onRemoveFromSubflow,
|
onRemoveFromSubflow,
|
||||||
onOpenEditor,
|
onOpenEditor,
|
||||||
onRename,
|
onRename,
|
||||||
onGroupBlocks,
|
|
||||||
onUngroupBlocks,
|
|
||||||
hasClipboard = false,
|
hasClipboard = false,
|
||||||
showRemoveFromSubflow = false,
|
showRemoveFromSubflow = false,
|
||||||
disableEdit = false,
|
disableEdit = false,
|
||||||
@@ -49,14 +47,6 @@ export function BlockContextMenu({
|
|||||||
|
|
||||||
const canRemoveFromSubflow = showRemoveFromSubflow && !hasStarterBlock
|
const canRemoveFromSubflow = showRemoveFromSubflow && !hasStarterBlock
|
||||||
|
|
||||||
// Check if we can group: need at least 2 blocks selected
|
|
||||||
const canGroup = selectedBlocks.length >= 2
|
|
||||||
|
|
||||||
// Check if we can ungroup: at least one selected block must be in a group
|
|
||||||
// Ungrouping will ungroup all blocks in that group (the entire group, not just selected blocks)
|
|
||||||
const hasGroupedBlock = selectedBlocks.some((b) => !!b.groupId)
|
|
||||||
const canUngroup = hasGroupedBlock
|
|
||||||
|
|
||||||
const getToggleEnabledLabel = () => {
|
const getToggleEnabledLabel = () => {
|
||||||
if (allEnabled) return 'Disable'
|
if (allEnabled) return 'Disable'
|
||||||
if (allDisabled) return 'Enable'
|
if (allDisabled) return 'Enable'
|
||||||
@@ -151,31 +141,6 @@ export function BlockContextMenu({
|
|||||||
</PopoverItem>
|
</PopoverItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Block group actions */}
|
|
||||||
{(canGroup || canUngroup) && <PopoverDivider />}
|
|
||||||
{canGroup && (
|
|
||||||
<PopoverItem
|
|
||||||
disabled={disableEdit}
|
|
||||||
onClick={() => {
|
|
||||||
onGroupBlocks()
|
|
||||||
onClose()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Group Blocks
|
|
||||||
</PopoverItem>
|
|
||||||
)}
|
|
||||||
{canUngroup && (
|
|
||||||
<PopoverItem
|
|
||||||
disabled={disableEdit}
|
|
||||||
onClick={() => {
|
|
||||||
onUngroupBlocks()
|
|
||||||
onClose()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Ungroup
|
|
||||||
</PopoverItem>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Single block actions */}
|
{/* Single block actions */}
|
||||||
{isSingleBlock && <PopoverDivider />}
|
{isSingleBlock && <PopoverDivider />}
|
||||||
{isSingleBlock && !isSubflow && (
|
{isSingleBlock && !isSubflow && (
|
||||||
|
|||||||
@@ -24,8 +24,6 @@ export interface ContextMenuBlockInfo {
|
|||||||
parentId?: string
|
parentId?: string
|
||||||
/** Parent type ('loop' | 'parallel') if nested */
|
/** Parent type ('loop' | 'parallel') if nested */
|
||||||
parentType?: string
|
parentType?: string
|
||||||
/** Group ID if block is in a group */
|
|
||||||
groupId?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,8 +50,6 @@ export interface BlockContextMenuProps {
|
|||||||
onRemoveFromSubflow: () => void
|
onRemoveFromSubflow: () => void
|
||||||
onOpenEditor: () => void
|
onOpenEditor: () => void
|
||||||
onRename: () => void
|
onRename: () => void
|
||||||
onGroupBlocks: () => void
|
|
||||||
onUngroupBlocks: () => void
|
|
||||||
/** Whether clipboard has content for pasting */
|
/** Whether clipboard has content for pasting */
|
||||||
hasClipboard?: boolean
|
hasClipboard?: boolean
|
||||||
/** Whether remove from subflow option should be shown */
|
/** Whether remove from subflow option should be shown */
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { memo, useCallback, useMemo } from 'react'
|
import { memo, useCallback, useMemo } from 'react'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import { type NodeProps, useReactFlow } from 'reactflow'
|
import type { NodeProps } from 'reactflow'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
useBlockDimensions,
|
useBlockDimensions,
|
||||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions'
|
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions'
|
||||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
|
||||||
import { ActionBar } from '../workflow-block/components'
|
import { ActionBar } from '../workflow-block/components'
|
||||||
import type { WorkflowBlockProps } from '../workflow-block/types'
|
import type { WorkflowBlockProps } from '../workflow-block/types'
|
||||||
|
|
||||||
@@ -199,57 +198,6 @@ export const NoteBlock = memo(function NoteBlock({ id, data }: NodeProps<NoteBlo
|
|||||||
|
|
||||||
const userPermissions = useUserPermissionsContext()
|
const userPermissions = useUserPermissionsContext()
|
||||||
|
|
||||||
// Get React Flow methods for group selection expansion
|
|
||||||
const { getNodes, setNodes } = useReactFlow()
|
|
||||||
const { getGroups } = useWorkflowStore()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Expands selection to include all group members on mouse down.
|
|
||||||
* This ensures that when a user starts dragging a note in a group,
|
|
||||||
* all other blocks in the group are also selected and will move together.
|
|
||||||
*/
|
|
||||||
const handleGroupMouseDown = useCallback(
|
|
||||||
(e: React.MouseEvent) => {
|
|
||||||
// Only process left mouse button clicks
|
|
||||||
if (e.button !== 0) return
|
|
||||||
|
|
||||||
const groupId = data.groupId
|
|
||||||
if (!groupId) return
|
|
||||||
|
|
||||||
const groups = getGroups()
|
|
||||||
const group = groups[groupId]
|
|
||||||
if (!group || group.blockIds.length <= 1) return
|
|
||||||
|
|
||||||
const groupBlockIds = new Set(group.blockIds)
|
|
||||||
const allNodes = getNodes()
|
|
||||||
|
|
||||||
// Check if all group members are already selected
|
|
||||||
const allSelected = [...groupBlockIds].every((blockId) =>
|
|
||||||
allNodes.find((n) => n.id === blockId && n.selected)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (allSelected) return
|
|
||||||
|
|
||||||
// Expand selection to include all group members
|
|
||||||
setNodes((nodes) =>
|
|
||||||
nodes.map((n) => {
|
|
||||||
const isInGroup = groupBlockIds.has(n.id)
|
|
||||||
const isThisBlock = n.id === id
|
|
||||||
return {
|
|
||||||
...n,
|
|
||||||
selected: isInGroup ? true : n.selected,
|
|
||||||
data: {
|
|
||||||
...n.data,
|
|
||||||
// Mark as grouped selection if in group but not the directly clicked block
|
|
||||||
isGroupedSelection: isInGroup && !isThisBlock && !n.selected,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
},
|
|
||||||
[id, data.groupId, getNodes, setNodes, getGroups]
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate deterministic dimensions based on content structure.
|
* Calculate deterministic dimensions based on content structure.
|
||||||
* Uses fixed width and computed height to avoid ResizeObserver jitter.
|
* Uses fixed width and computed height to avoid ResizeObserver jitter.
|
||||||
@@ -268,14 +216,8 @@ export const NoteBlock = memo(function NoteBlock({ id, data }: NodeProps<NoteBlo
|
|||||||
dependencies: [isEmpty],
|
dependencies: [isEmpty],
|
||||||
})
|
})
|
||||||
|
|
||||||
const isGroupedSelection = data.isGroupedSelection ?? false
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className='group relative'>
|
||||||
className='group relative'
|
|
||||||
data-grouped-selection={isGroupedSelection ? 'true' : undefined}
|
|
||||||
onMouseDown={handleGroupMouseDown}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative z-[20] w-[250px] cursor-default select-none rounded-[8px] border border-[var(--border)] bg-[var(--surface-2)]'
|
'relative z-[20] w-[250px] cursor-default select-none rounded-[8px] border border-[var(--border)] bg-[var(--surface-2)]'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React, { memo, useCallback, useState } from 'react'
|
import React, { useEffect, useMemo, useState } from 'react'
|
||||||
import { Check, Copy } from 'lucide-react'
|
import { Check, Copy } from 'lucide-react'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
@@ -28,95 +28,55 @@ const getTextContent = (element: React.ReactNode): string => {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Global layout fixes for markdown content inside the copilot panel
|
||||||
* Maps common language aliases to supported viewer languages
|
if (typeof document !== 'undefined') {
|
||||||
*/
|
const styleId = 'copilot-markdown-fix'
|
||||||
const LANGUAGE_MAP: Record<string, 'javascript' | 'json' | 'python'> = {
|
if (!document.getElementById(styleId)) {
|
||||||
js: 'javascript',
|
const style = document.createElement('style')
|
||||||
javascript: 'javascript',
|
style.id = styleId
|
||||||
jsx: 'javascript',
|
style.textContent = `
|
||||||
ts: 'javascript',
|
/* Prevent any markdown content from expanding beyond the panel */
|
||||||
typescript: 'javascript',
|
.copilot-markdown-wrapper,
|
||||||
tsx: 'javascript',
|
.copilot-markdown-wrapper * {
|
||||||
json: 'json',
|
max-width: 100% !important;
|
||||||
python: 'python',
|
}
|
||||||
py: 'python',
|
|
||||||
code: 'javascript',
|
.copilot-markdown-wrapper p,
|
||||||
|
.copilot-markdown-wrapper li {
|
||||||
|
overflow-wrap: anywhere !important;
|
||||||
|
word-break: break-word !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copilot-markdown-wrapper a {
|
||||||
|
overflow-wrap: anywhere !important;
|
||||||
|
word-break: break-all !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copilot-markdown-wrapper code:not(pre code) {
|
||||||
|
white-space: normal !important;
|
||||||
|
overflow-wrap: anywhere !important;
|
||||||
|
word-break: break-word !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduce top margin for first heading (e.g., right after thinking block) */
|
||||||
|
.copilot-markdown-wrapper > h1:first-child,
|
||||||
|
.copilot-markdown-wrapper > h2:first-child,
|
||||||
|
.copilot-markdown-wrapper > h3:first-child,
|
||||||
|
.copilot-markdown-wrapper > h4:first-child {
|
||||||
|
margin-top: 0.25rem !important;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
document.head.appendChild(style)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalizes a language string to a supported viewer language
|
|
||||||
*/
|
|
||||||
function normalizeLanguage(lang: string): 'javascript' | 'json' | 'python' {
|
|
||||||
const normalized = (lang || '').toLowerCase()
|
|
||||||
return LANGUAGE_MAP[normalized] || 'javascript'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Props for the CodeBlock component
|
|
||||||
*/
|
|
||||||
interface CodeBlockProps {
|
|
||||||
/** Code content to display */
|
|
||||||
code: string
|
|
||||||
/** Language identifier from markdown */
|
|
||||||
language: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CodeBlock component with isolated copy state
|
|
||||||
* Prevents full markdown re-renders when copy button is clicked
|
|
||||||
*/
|
|
||||||
const CodeBlock = memo(function CodeBlock({ code, language }: CodeBlockProps) {
|
|
||||||
const [copied, setCopied] = useState(false)
|
|
||||||
|
|
||||||
const handleCopy = useCallback(() => {
|
|
||||||
if (code) {
|
|
||||||
navigator.clipboard.writeText(code)
|
|
||||||
setCopied(true)
|
|
||||||
setTimeout(() => setCopied(false), 2000)
|
|
||||||
}
|
|
||||||
}, [code])
|
|
||||||
|
|
||||||
const viewerLanguage = normalizeLanguage(language)
|
|
||||||
const displayLanguage = language === 'code' ? viewerLanguage : language
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='mt-2.5 mb-2.5 w-0 min-w-full overflow-hidden rounded-md border border-[var(--border-1)] bg-[var(--surface-1)] text-sm'>
|
|
||||||
<div className='flex items-center justify-between border-[var(--border-1)] border-b px-3 py-1'>
|
|
||||||
<span className='font-season text-[var(--text-muted)] text-xs'>{displayLanguage}</span>
|
|
||||||
<button
|
|
||||||
onClick={handleCopy}
|
|
||||||
className='text-[var(--text-muted)] transition-colors hover:text-[var(--text-tertiary)]'
|
|
||||||
title='Copy'
|
|
||||||
type='button'
|
|
||||||
>
|
|
||||||
{copied ? (
|
|
||||||
<Check className='h-3 w-3' strokeWidth={2} />
|
|
||||||
) : (
|
|
||||||
<Copy className='h-3 w-3' strokeWidth={2} />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<Code.Viewer
|
|
||||||
code={code.replace(/\n+$/, '')}
|
|
||||||
showGutter
|
|
||||||
language={viewerLanguage}
|
|
||||||
className='m-0 min-h-0 rounded-none border-0 bg-transparent'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Link component with hover preview tooltip
|
* Link component with hover preview tooltip
|
||||||
|
* Displays full URL on hover for better UX
|
||||||
|
* @param props - Component props with href and children
|
||||||
|
* @returns Link element with tooltip preview
|
||||||
*/
|
*/
|
||||||
const LinkWithPreview = memo(function LinkWithPreview({
|
function LinkWithPreview({ href, children }: { href: string; children: React.ReactNode }) {
|
||||||
href,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
href: string
|
|
||||||
children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<Tooltip.Root delayDuration={300}>
|
<Tooltip.Root delayDuration={300}>
|
||||||
<Tooltip.Trigger asChild>
|
<Tooltip.Trigger asChild>
|
||||||
@@ -134,7 +94,7 @@ const LinkWithPreview = memo(function LinkWithPreview({
|
|||||||
</Tooltip.Content>
|
</Tooltip.Content>
|
||||||
</Tooltip.Root>
|
</Tooltip.Root>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Props for the CopilotMarkdownRenderer component
|
* Props for the CopilotMarkdownRenderer component
|
||||||
@@ -144,197 +104,275 @@ interface CopilotMarkdownRendererProps {
|
|||||||
content: string
|
content: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Static markdown component definitions - optimized for LLM chat spacing
|
|
||||||
* Tighter spacing compared to traditional prose for better chat UX
|
|
||||||
*/
|
|
||||||
const markdownComponents = {
|
|
||||||
// Paragraphs - tight spacing, no margin on last
|
|
||||||
p: ({ children }: React.HTMLAttributes<HTMLParagraphElement>) => (
|
|
||||||
<p className='mb-1.5 font-base font-season text-[var(--text-primary)] text-sm leading-[1.4] last:mb-0 dark:font-[470]'>
|
|
||||||
{children}
|
|
||||||
</p>
|
|
||||||
),
|
|
||||||
|
|
||||||
// Headings - minimal margins for chat context
|
|
||||||
h1: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
|
||||||
<h1 className='mt-2 mb-1 font-season font-semibold text-[var(--text-primary)] text-base first:mt-0'>
|
|
||||||
{children}
|
|
||||||
</h1>
|
|
||||||
),
|
|
||||||
h2: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
|
||||||
<h2 className='mt-2 mb-1 font-season font-semibold text-[15px] text-[var(--text-primary)] first:mt-0'>
|
|
||||||
{children}
|
|
||||||
</h2>
|
|
||||||
),
|
|
||||||
h3: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
|
||||||
<h3 className='mt-1.5 mb-0.5 font-season font-semibold text-[var(--text-primary)] text-sm first:mt-0'>
|
|
||||||
{children}
|
|
||||||
</h3>
|
|
||||||
),
|
|
||||||
h4: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
|
||||||
<h4 className='mt-1.5 mb-0.5 font-season font-semibold text-[var(--text-primary)] text-sm first:mt-0'>
|
|
||||||
{children}
|
|
||||||
</h4>
|
|
||||||
),
|
|
||||||
|
|
||||||
// Lists - compact spacing
|
|
||||||
ul: ({ children }: React.HTMLAttributes<HTMLUListElement>) => (
|
|
||||||
<ul
|
|
||||||
className='my-1 space-y-0.5 pl-5 font-base font-season text-[var(--text-primary)] dark:font-[470]'
|
|
||||||
style={{ listStyleType: 'disc' }}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</ul>
|
|
||||||
),
|
|
||||||
ol: ({ children }: React.HTMLAttributes<HTMLOListElement>) => (
|
|
||||||
<ol
|
|
||||||
className='my-1 space-y-0.5 pl-5 font-base font-season text-[var(--text-primary)] dark:font-[470]'
|
|
||||||
style={{ listStyleType: 'decimal' }}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</ol>
|
|
||||||
),
|
|
||||||
li: ({ children }: React.LiHTMLAttributes<HTMLLIElement>) => (
|
|
||||||
<li
|
|
||||||
className='font-base font-season text-[var(--text-primary)] text-sm leading-[1.4] dark:font-[470]'
|
|
||||||
style={{ display: 'list-item' }}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</li>
|
|
||||||
),
|
|
||||||
|
|
||||||
// Code blocks - handled by CodeBlock component
|
|
||||||
pre: ({ children }: React.HTMLAttributes<HTMLPreElement>) => {
|
|
||||||
let codeContent: React.ReactNode = children
|
|
||||||
let language = 'code'
|
|
||||||
|
|
||||||
if (
|
|
||||||
React.isValidElement<{ className?: string; children?: React.ReactNode }>(children) &&
|
|
||||||
children.type === 'code'
|
|
||||||
) {
|
|
||||||
const childElement = children as React.ReactElement<{
|
|
||||||
className?: string
|
|
||||||
children?: React.ReactNode
|
|
||||||
}>
|
|
||||||
codeContent = childElement.props.children
|
|
||||||
language = childElement.props.className?.replace('language-', '') || 'code'
|
|
||||||
}
|
|
||||||
|
|
||||||
let actualCodeText = ''
|
|
||||||
if (typeof codeContent === 'string') {
|
|
||||||
actualCodeText = codeContent
|
|
||||||
} else if (React.isValidElement(codeContent)) {
|
|
||||||
actualCodeText = getTextContent(codeContent)
|
|
||||||
} else if (Array.isArray(codeContent)) {
|
|
||||||
actualCodeText = codeContent
|
|
||||||
.map((child) =>
|
|
||||||
typeof child === 'string'
|
|
||||||
? child
|
|
||||||
: React.isValidElement(child)
|
|
||||||
? getTextContent(child)
|
|
||||||
: ''
|
|
||||||
)
|
|
||||||
.join('')
|
|
||||||
} else {
|
|
||||||
actualCodeText = String(codeContent || '')
|
|
||||||
}
|
|
||||||
|
|
||||||
return <CodeBlock code={actualCodeText} language={language} />
|
|
||||||
},
|
|
||||||
|
|
||||||
// Inline code
|
|
||||||
code: ({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.HTMLAttributes<HTMLElement> & { className?: string }) => (
|
|
||||||
<code
|
|
||||||
className='whitespace-normal break-all rounded border border-[var(--border-1)] bg-[var(--surface-1)] px-1 py-0.5 font-mono text-[0.85em] text-[var(--text-primary)]'
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</code>
|
|
||||||
),
|
|
||||||
|
|
||||||
// Text formatting
|
|
||||||
strong: ({ children }: React.HTMLAttributes<HTMLElement>) => (
|
|
||||||
<strong className='font-semibold text-[var(--text-primary)]'>{children}</strong>
|
|
||||||
),
|
|
||||||
b: ({ children }: React.HTMLAttributes<HTMLElement>) => (
|
|
||||||
<b className='font-semibold text-[var(--text-primary)]'>{children}</b>
|
|
||||||
),
|
|
||||||
em: ({ children }: React.HTMLAttributes<HTMLElement>) => (
|
|
||||||
<em className='text-[var(--text-primary)] italic'>{children}</em>
|
|
||||||
),
|
|
||||||
i: ({ children }: React.HTMLAttributes<HTMLElement>) => (
|
|
||||||
<i className='text-[var(--text-primary)] italic'>{children}</i>
|
|
||||||
),
|
|
||||||
|
|
||||||
// Blockquote - compact
|
|
||||||
blockquote: ({ children }: React.HTMLAttributes<HTMLQuoteElement>) => (
|
|
||||||
<blockquote className='my-1.5 border-[var(--border-1)] border-l-2 py-0.5 pl-3 font-season text-[var(--text-secondary)] text-sm italic'>
|
|
||||||
{children}
|
|
||||||
</blockquote>
|
|
||||||
),
|
|
||||||
|
|
||||||
// Horizontal rule
|
|
||||||
hr: () => <hr className='my-3 border-[var(--divider)] border-t' />,
|
|
||||||
|
|
||||||
// Links
|
|
||||||
a: ({ href, children }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
|
|
||||||
<LinkWithPreview href={href || '#'}>{children}</LinkWithPreview>
|
|
||||||
),
|
|
||||||
|
|
||||||
// Tables - compact
|
|
||||||
table: ({ children }: React.TableHTMLAttributes<HTMLTableElement>) => (
|
|
||||||
<div className='my-2 max-w-full overflow-x-auto'>
|
|
||||||
<table className='min-w-full table-auto border border-[var(--border-1)] font-season text-xs'>
|
|
||||||
{children}
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
thead: ({ children }: React.HTMLAttributes<HTMLTableSectionElement>) => (
|
|
||||||
<thead className='bg-[var(--surface-5)] text-left dark:bg-[var(--surface-4)]'>{children}</thead>
|
|
||||||
),
|
|
||||||
tbody: ({ children }: React.HTMLAttributes<HTMLTableSectionElement>) => (
|
|
||||||
<tbody className='divide-y divide-[var(--border-1)]'>{children}</tbody>
|
|
||||||
),
|
|
||||||
tr: ({ children }: React.HTMLAttributes<HTMLTableRowElement>) => (
|
|
||||||
<tr className='border-[var(--border-1)] border-b'>{children}</tr>
|
|
||||||
),
|
|
||||||
th: ({ children }: React.ThHTMLAttributes<HTMLTableCellElement>) => (
|
|
||||||
<th className='border-[var(--border-1)] border-r px-2 py-1 align-top font-base text-[var(--text-secondary)] last:border-r-0 dark:font-[470]'>
|
|
||||||
{children}
|
|
||||||
</th>
|
|
||||||
),
|
|
||||||
td: ({ children }: React.TdHTMLAttributes<HTMLTableCellElement>) => (
|
|
||||||
<td className='break-words border-[var(--border-1)] border-r px-2 py-1 align-top font-base text-[var(--text-primary)] last:border-r-0 dark:font-[470]'>
|
|
||||||
{children}
|
|
||||||
</td>
|
|
||||||
),
|
|
||||||
|
|
||||||
// Images
|
|
||||||
img: ({ src, alt, ...props }: React.ImgHTMLAttributes<HTMLImageElement>) => (
|
|
||||||
<img src={src} alt={alt || 'Image'} className='my-2 h-auto max-w-full rounded-md' {...props} />
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CopilotMarkdownRenderer renders markdown content with custom styling
|
* CopilotMarkdownRenderer renders markdown content with custom styling
|
||||||
* Optimized for LLM chat: tight spacing, memoized components, isolated state
|
* Supports GitHub-flavored markdown, code blocks with syntax highlighting,
|
||||||
|
* tables, links with preview, and more
|
||||||
*
|
*
|
||||||
* @param props - Component props
|
* @param props - Component props
|
||||||
* @returns Rendered markdown content
|
* @returns Rendered markdown content
|
||||||
*/
|
*/
|
||||||
function CopilotMarkdownRenderer({ content }: CopilotMarkdownRendererProps) {
|
export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRendererProps) {
|
||||||
|
const [copiedCodeBlocks, setCopiedCodeBlocks] = useState<Record<string, boolean>>({})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timers: Record<string, NodeJS.Timeout> = {}
|
||||||
|
|
||||||
|
Object.keys(copiedCodeBlocks).forEach((key) => {
|
||||||
|
if (copiedCodeBlocks[key]) {
|
||||||
|
timers[key] = setTimeout(() => {
|
||||||
|
setCopiedCodeBlocks((prev) => ({ ...prev, [key]: false }))
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
Object.values(timers).forEach(clearTimeout)
|
||||||
|
}
|
||||||
|
}, [copiedCodeBlocks])
|
||||||
|
|
||||||
|
const markdownComponents = useMemo(
|
||||||
|
() => ({
|
||||||
|
p: ({ children }: React.HTMLAttributes<HTMLParagraphElement>) => (
|
||||||
|
<p className='mb-2 font-base font-season text-[var(--text-primary)] text-sm leading-[1.25rem] last:mb-0 dark:font-[470]'>
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
|
||||||
|
h1: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||||
|
<h1 className='mt-3 mb-3 font-season font-semibold text-2xl text-[var(--text-primary)]'>
|
||||||
|
{children}
|
||||||
|
</h1>
|
||||||
|
),
|
||||||
|
h2: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||||
|
<h2 className='mt-2.5 mb-2.5 font-season font-semibold text-[var(--text-primary)] text-xl'>
|
||||||
|
{children}
|
||||||
|
</h2>
|
||||||
|
),
|
||||||
|
h3: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||||
|
<h3 className='mt-2 mb-2 font-season font-semibold text-[var(--text-primary)] text-lg'>
|
||||||
|
{children}
|
||||||
|
</h3>
|
||||||
|
),
|
||||||
|
h4: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||||
|
<h4 className='mt-2 mb-2 font-season font-semibold text-[var(--text-primary)] text-base'>
|
||||||
|
{children}
|
||||||
|
</h4>
|
||||||
|
),
|
||||||
|
|
||||||
|
ul: ({ children }: React.HTMLAttributes<HTMLUListElement>) => (
|
||||||
|
<ul
|
||||||
|
className='mt-1 mb-1 space-y-1.5 pl-6 font-base font-season text-[var(--text-primary)] dark:font-[470]'
|
||||||
|
style={{ listStyleType: 'disc' }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ul>
|
||||||
|
),
|
||||||
|
ol: ({ children }: React.HTMLAttributes<HTMLOListElement>) => (
|
||||||
|
<ol
|
||||||
|
className='mt-1 mb-1 space-y-1.5 pl-6 font-base font-season text-[var(--text-primary)] dark:font-[470]'
|
||||||
|
style={{ listStyleType: 'decimal' }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ol>
|
||||||
|
),
|
||||||
|
li: ({
|
||||||
|
children,
|
||||||
|
ordered,
|
||||||
|
}: React.LiHTMLAttributes<HTMLLIElement> & { ordered?: boolean }) => (
|
||||||
|
<li
|
||||||
|
className='font-base font-season text-[var(--text-primary)] dark:font-[470]'
|
||||||
|
style={{ display: 'list-item' }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</li>
|
||||||
|
),
|
||||||
|
|
||||||
|
pre: ({ children }: React.HTMLAttributes<HTMLPreElement>) => {
|
||||||
|
let codeContent: React.ReactNode = children
|
||||||
|
let language = 'code'
|
||||||
|
|
||||||
|
if (
|
||||||
|
React.isValidElement<{ className?: string; children?: React.ReactNode }>(children) &&
|
||||||
|
children.type === 'code'
|
||||||
|
) {
|
||||||
|
const childElement = children as React.ReactElement<{
|
||||||
|
className?: string
|
||||||
|
children?: React.ReactNode
|
||||||
|
}>
|
||||||
|
codeContent = childElement.props.children
|
||||||
|
language = childElement.props.className?.replace('language-', '') || 'code'
|
||||||
|
}
|
||||||
|
|
||||||
|
let actualCodeText = ''
|
||||||
|
if (typeof codeContent === 'string') {
|
||||||
|
actualCodeText = codeContent
|
||||||
|
} else if (React.isValidElement(codeContent)) {
|
||||||
|
actualCodeText = getTextContent(codeContent)
|
||||||
|
} else if (Array.isArray(codeContent)) {
|
||||||
|
actualCodeText = codeContent
|
||||||
|
.map((child) =>
|
||||||
|
typeof child === 'string'
|
||||||
|
? child
|
||||||
|
: React.isValidElement(child)
|
||||||
|
? getTextContent(child)
|
||||||
|
: ''
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
} else {
|
||||||
|
actualCodeText = String(codeContent || '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeText = actualCodeText || 'code'
|
||||||
|
const codeBlockKey = `${language}-${codeText.substring(0, 30).replace(/\s/g, '-')}-${codeText.length}`
|
||||||
|
|
||||||
|
const showCopySuccess = copiedCodeBlocks[codeBlockKey] || false
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
const textToCopy = actualCodeText
|
||||||
|
if (textToCopy) {
|
||||||
|
navigator.clipboard.writeText(textToCopy)
|
||||||
|
setCopiedCodeBlocks((prev) => ({ ...prev, [codeBlockKey]: true }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedLanguage = (language || '').toLowerCase()
|
||||||
|
const viewerLanguage: 'javascript' | 'json' | 'python' =
|
||||||
|
normalizedLanguage === 'json'
|
||||||
|
? 'json'
|
||||||
|
: normalizedLanguage === 'python' || normalizedLanguage === 'py'
|
||||||
|
? 'python'
|
||||||
|
: 'javascript'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='mt-6 mb-6 w-0 min-w-full overflow-hidden rounded-md border border-[var(--border-1)] bg-[var(--surface-1)] text-sm'>
|
||||||
|
<div className='flex items-center justify-between border-[var(--border-1)] border-b px-4 py-1.5'>
|
||||||
|
<span className='font-season text-[var(--text-muted)] text-xs'>
|
||||||
|
{language === 'code' ? viewerLanguage : language}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className='text-[var(--text-muted)] transition-colors hover:text-[var(--text-tertiary)]'
|
||||||
|
title='Copy'
|
||||||
|
>
|
||||||
|
{showCopySuccess ? (
|
||||||
|
<Check className='h-3 w-3' strokeWidth={2} />
|
||||||
|
) : (
|
||||||
|
<Copy className='h-3 w-3' strokeWidth={2} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Code.Viewer
|
||||||
|
code={actualCodeText.replace(/\n+$/, '')}
|
||||||
|
showGutter
|
||||||
|
language={viewerLanguage}
|
||||||
|
className='m-0 min-h-0 rounded-none border-0 bg-transparent'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
code: ({
|
||||||
|
inline,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLElement> & { className?: string; inline?: boolean }) => {
|
||||||
|
if (inline) {
|
||||||
|
return (
|
||||||
|
<code
|
||||||
|
className='whitespace-normal break-all rounded border border-[var(--border-1)] bg-[var(--surface-1)] px-1 py-0.5 font-mono text-[0.9em] text-[var(--text-primary)]'
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<code className={className} {...props}>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
strong: ({ children }: React.HTMLAttributes<HTMLElement>) => (
|
||||||
|
<strong className='font-semibold text-[var(--text-primary)]'>{children}</strong>
|
||||||
|
),
|
||||||
|
|
||||||
|
b: ({ children }: React.HTMLAttributes<HTMLElement>) => (
|
||||||
|
<b className='font-semibold text-[var(--text-primary)]'>{children}</b>
|
||||||
|
),
|
||||||
|
|
||||||
|
em: ({ children }: React.HTMLAttributes<HTMLElement>) => (
|
||||||
|
<em className='text-[var(--text-primary)] italic'>{children}</em>
|
||||||
|
),
|
||||||
|
|
||||||
|
i: ({ children }: React.HTMLAttributes<HTMLElement>) => (
|
||||||
|
<i className='text-[var(--text-primary)] italic'>{children}</i>
|
||||||
|
),
|
||||||
|
|
||||||
|
blockquote: ({ children }: React.HTMLAttributes<HTMLQuoteElement>) => (
|
||||||
|
<blockquote className='my-4 border-[var(--border-1)] border-l-4 py-1 pl-4 font-season text-[var(--text-secondary)] italic'>
|
||||||
|
{children}
|
||||||
|
</blockquote>
|
||||||
|
),
|
||||||
|
|
||||||
|
hr: () => <hr className='my-8 border-[var(--divider)] border-t' />,
|
||||||
|
|
||||||
|
a: ({ href, children, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
|
||||||
|
<LinkWithPreview href={href || '#'} {...props}>
|
||||||
|
{children}
|
||||||
|
</LinkWithPreview>
|
||||||
|
),
|
||||||
|
|
||||||
|
table: ({ children }: React.TableHTMLAttributes<HTMLTableElement>) => (
|
||||||
|
<div className='my-3 max-w-full overflow-x-auto'>
|
||||||
|
<table className='min-w-full table-auto border border-[var(--border-1)] font-season text-xs'>
|
||||||
|
{children}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
thead: ({ children }: React.HTMLAttributes<HTMLTableSectionElement>) => (
|
||||||
|
<thead className='bg-[var(--surface-5)] text-left dark:bg-[var(--surface-4)]'>
|
||||||
|
{children}
|
||||||
|
</thead>
|
||||||
|
),
|
||||||
|
tbody: ({ children }: React.HTMLAttributes<HTMLTableSectionElement>) => (
|
||||||
|
<tbody className='divide-y divide-[var(--border-1)]'>{children}</tbody>
|
||||||
|
),
|
||||||
|
tr: ({ children }: React.HTMLAttributes<HTMLTableRowElement>) => (
|
||||||
|
<tr className='border-[var(--border-1)] border-b transition-colors hover:bg-[var(--surface-5)] dark:hover:bg-[var(--surface-4)]/60'>
|
||||||
|
{children}
|
||||||
|
</tr>
|
||||||
|
),
|
||||||
|
th: ({ children }: React.ThHTMLAttributes<HTMLTableCellElement>) => (
|
||||||
|
<th className='border-[var(--border-1)] border-r px-2.5 py-1.5 align-top font-base text-[var(--text-secondary)] last:border-r-0 dark:font-[470]'>
|
||||||
|
{children}
|
||||||
|
</th>
|
||||||
|
),
|
||||||
|
td: ({ children }: React.TdHTMLAttributes<HTMLTableCellElement>) => (
|
||||||
|
<td className='break-words border-[var(--border-1)] border-r px-2.5 py-1.5 align-top font-base text-[var(--text-primary)] last:border-r-0 dark:font-[470]'>
|
||||||
|
{children}
|
||||||
|
</td>
|
||||||
|
),
|
||||||
|
|
||||||
|
img: ({ src, alt, ...props }: React.ImgHTMLAttributes<HTMLImageElement>) => (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt || 'Image'}
|
||||||
|
className='my-3 h-auto max-w-full rounded-md'
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
[copiedCodeBlocks]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='max-w-full break-words font-base font-season text-[var(--text-primary)] text-sm leading-[1.4] dark:font-[470] [&_*]:max-w-full [&_a]:break-all [&_code:not(pre_code)]:break-words [&_li]:break-words [&_p]:break-words'>
|
<div className='copilot-markdown-wrapper max-w-full space-y-3 break-words font-base font-season text-[var(--text-primary)] text-sm leading-[1.25rem] dark:font-[470]'>
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||||
{content}
|
{content}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(CopilotMarkdownRenderer)
|
|
||||||
|
|||||||
@@ -2,38 +2,18 @@ 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'
|
import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimum delay between characters (fast catch-up mode)
|
* Character animation delay in milliseconds
|
||||||
*/
|
*/
|
||||||
const MIN_DELAY = 1
|
const CHARACTER_DELAY = 3
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
* StreamingIndicator shows animated dots during message streaming
|
||||||
* Used as a standalone indicator when no content has arrived yet
|
* Uses CSS classes for animations to follow best practices
|
||||||
*
|
*
|
||||||
* @returns Animated loading indicator
|
* @returns Animated loading indicator
|
||||||
*/
|
*/
|
||||||
export const StreamingIndicator = memo(() => (
|
export const StreamingIndicator = memo(() => (
|
||||||
<div className='flex h-[1.25rem] items-center text-muted-foreground'>
|
<div className='flex items-center py-1 text-muted-foreground transition-opacity duration-200 ease-in-out'>
|
||||||
<div className='flex space-x-0.5'>
|
<div className='flex space-x-0.5'>
|
||||||
<div className='h-1 w-1 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms] [animation-duration:1.2s]' />
|
<div className='h-1 w-1 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms] [animation-duration:1.2s]' />
|
||||||
<div className='h-1 w-1 animate-bounce rounded-full bg-muted-foreground [animation-delay:150ms] [animation-duration:1.2s]' />
|
<div className='h-1 w-1 animate-bounce rounded-full bg-muted-foreground [animation-delay:150ms] [animation-duration:1.2s]' />
|
||||||
@@ -54,39 +34,9 @@ interface SmoothStreamingTextProps {
|
|||||||
isStreaming: boolean
|
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
|
* SmoothStreamingText component displays text with character-by-character animation
|
||||||
* Creates a smooth streaming effect for AI responses with adaptive speed
|
* Creates a smooth streaming effect for AI responses
|
||||||
*
|
|
||||||
* Uses adaptive pacing: speeds up when catching up, slows down near content edge
|
|
||||||
*
|
*
|
||||||
* @param props - Component props
|
* @param props - Component props
|
||||||
* @returns Streaming text with smooth animation
|
* @returns Streaming text with smooth animation
|
||||||
@@ -95,73 +45,74 @@ export const SmoothStreamingText = memo(
|
|||||||
({ content, isStreaming }: SmoothStreamingTextProps) => {
|
({ content, isStreaming }: SmoothStreamingTextProps) => {
|
||||||
const [displayedContent, setDisplayedContent] = useState('')
|
const [displayedContent, setDisplayedContent] = useState('')
|
||||||
const contentRef = useRef(content)
|
const contentRef = useRef(content)
|
||||||
const rafRef = useRef<number | null>(null)
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
const indexRef = useRef(0)
|
const indexRef = useRef(0)
|
||||||
const lastFrameTimeRef = useRef<number>(0)
|
const streamingStartTimeRef = useRef<number | null>(null)
|
||||||
const isAnimatingRef = useRef(false)
|
const isAnimatingRef = useRef(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles content streaming animation
|
||||||
|
* Updates displayed content character by character during streaming
|
||||||
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
contentRef.current = content
|
contentRef.current = content
|
||||||
|
|
||||||
if (content.length === 0) {
|
if (content.length === 0) {
|
||||||
setDisplayedContent('')
|
setDisplayedContent('')
|
||||||
indexRef.current = 0
|
indexRef.current = 0
|
||||||
|
streamingStartTimeRef.current = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isStreaming) {
|
if (isStreaming) {
|
||||||
if (indexRef.current < content.length && !isAnimatingRef.current) {
|
if (streamingStartTimeRef.current === null) {
|
||||||
isAnimatingRef.current = true
|
streamingStartTimeRef.current = Date.now()
|
||||||
lastFrameTimeRef.current = performance.now()
|
}
|
||||||
|
|
||||||
const animateText = (timestamp: number) => {
|
if (indexRef.current < content.length) {
|
||||||
|
const animateText = () => {
|
||||||
const currentContent = contentRef.current
|
const currentContent = contentRef.current
|
||||||
const currentIndex = indexRef.current
|
const currentIndex = indexRef.current
|
||||||
const elapsed = timestamp - lastFrameTimeRef.current
|
|
||||||
|
|
||||||
// Calculate adaptive delay based on how far behind we are
|
if (currentIndex < currentContent.length) {
|
||||||
const delay = calculateAdaptiveDelay(currentIndex, currentContent.length)
|
const chunkSize = 1
|
||||||
|
const newDisplayed = currentContent.slice(0, currentIndex + chunkSize)
|
||||||
|
|
||||||
if (elapsed >= delay) {
|
setDisplayedContent(newDisplayed)
|
||||||
if (currentIndex < currentContent.length) {
|
indexRef.current = currentIndex + chunkSize
|
||||||
const newDisplayed = currentContent.slice(0, currentIndex + 1)
|
|
||||||
setDisplayedContent(newDisplayed)
|
|
||||||
indexRef.current = currentIndex + 1
|
|
||||||
lastFrameTimeRef.current = timestamp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (indexRef.current < currentContent.length) {
|
timeoutRef.current = setTimeout(animateText, CHARACTER_DELAY)
|
||||||
rafRef.current = requestAnimationFrame(animateText)
|
|
||||||
} else {
|
} else {
|
||||||
isAnimatingRef.current = false
|
isAnimatingRef.current = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rafRef.current = requestAnimationFrame(animateText)
|
if (!isAnimatingRef.current) {
|
||||||
} else if (indexRef.current < content.length && isAnimatingRef.current) {
|
if (timeoutRef.current) {
|
||||||
// Animation already running, it will pick up new content automatically
|
clearTimeout(timeoutRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
isAnimatingRef.current = true
|
||||||
|
animateText()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Streaming ended - show full content immediately
|
|
||||||
if (rafRef.current) {
|
|
||||||
cancelAnimationFrame(rafRef.current)
|
|
||||||
}
|
|
||||||
setDisplayedContent(content)
|
setDisplayedContent(content)
|
||||||
indexRef.current = content.length
|
indexRef.current = content.length
|
||||||
isAnimatingRef.current = false
|
isAnimatingRef.current = false
|
||||||
|
streamingStartTimeRef.current = null
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (rafRef.current) {
|
if (timeoutRef.current) {
|
||||||
cancelAnimationFrame(rafRef.current)
|
clearTimeout(timeoutRef.current)
|
||||||
}
|
}
|
||||||
isAnimatingRef.current = false
|
isAnimatingRef.current = false
|
||||||
}
|
}
|
||||||
}, [content, isStreaming])
|
}, [content, isStreaming])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='min-h-[1.25rem] max-w-full'>
|
<div className='relative min-h-[1.25rem] max-w-full overflow-hidden'>
|
||||||
<CopilotMarkdownRenderer content={displayedContent} />
|
<CopilotMarkdownRenderer content={displayedContent} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -170,6 +121,7 @@ export const SmoothStreamingText = memo(
|
|||||||
// Prevent re-renders during streaming unless content actually changed
|
// Prevent re-renders during streaming unless content actually changed
|
||||||
return (
|
return (
|
||||||
prevProps.content === nextProps.content && prevProps.isStreaming === nextProps.isStreaming
|
prevProps.content === nextProps.content && prevProps.isStreaming === nextProps.isStreaming
|
||||||
|
// markdownComponents is now memoized so no need to compare
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { memo, useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { ChevronUp } from 'lucide-react'
|
import { ChevronUp } from 'lucide-react'
|
||||||
import CopilotMarkdownRenderer from './markdown-renderer'
|
import CopilotMarkdownRenderer from './markdown-renderer'
|
||||||
@@ -8,151 +8,18 @@ import CopilotMarkdownRenderer from './markdown-renderer'
|
|||||||
/**
|
/**
|
||||||
* Max height for thinking content before internal scrolling kicks in
|
* Max height for thinking content before internal scrolling kicks in
|
||||||
*/
|
*/
|
||||||
const THINKING_MAX_HEIGHT = 150
|
const THINKING_MAX_HEIGHT = 200
|
||||||
|
|
||||||
/**
|
|
||||||
* Height threshold before gradient fade kicks in
|
|
||||||
*/
|
|
||||||
const GRADIENT_THRESHOLD = 100
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interval for auto-scroll during streaming (ms)
|
* Interval for auto-scroll during streaming (ms)
|
||||||
*/
|
*/
|
||||||
const SCROLL_INTERVAL = 50
|
const SCROLL_INTERVAL = 100
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Timer update interval in milliseconds
|
* Timer update interval in milliseconds
|
||||||
*/
|
*/
|
||||||
const TIMER_UPDATE_INTERVAL = 100
|
const TIMER_UPDATE_INTERVAL = 100
|
||||||
|
|
||||||
/**
|
|
||||||
* Thinking text streaming - much faster than main text
|
|
||||||
* Essentially instant with minimal delay
|
|
||||||
*/
|
|
||||||
const THINKING_DELAY = 0.5
|
|
||||||
const THINKING_CHARS_PER_FRAME = 3
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Props for the SmoothThinkingText component
|
|
||||||
*/
|
|
||||||
interface SmoothThinkingTextProps {
|
|
||||||
content: string
|
|
||||||
isStreaming: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SmoothThinkingText renders thinking content with fast streaming animation
|
|
||||||
* Uses gradient fade at top when content is tall enough
|
|
||||||
*/
|
|
||||||
const SmoothThinkingText = memo(
|
|
||||||
({ content, isStreaming }: SmoothThinkingTextProps) => {
|
|
||||||
const [displayedContent, setDisplayedContent] = useState('')
|
|
||||||
const [showGradient, setShowGradient] = useState(false)
|
|
||||||
const contentRef = useRef(content)
|
|
||||||
const textRef = useRef<HTMLDivElement>(null)
|
|
||||||
const rafRef = useRef<number | null>(null)
|
|
||||||
const indexRef = useRef(0)
|
|
||||||
const lastFrameTimeRef = useRef<number>(0)
|
|
||||||
const isAnimatingRef = useRef(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
contentRef.current = content
|
|
||||||
|
|
||||||
if (content.length === 0) {
|
|
||||||
setDisplayedContent('')
|
|
||||||
indexRef.current = 0
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isStreaming) {
|
|
||||||
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 (elapsed >= THINKING_DELAY) {
|
|
||||||
if (currentIndex < currentContent.length) {
|
|
||||||
// Reveal multiple characters per frame for faster streaming
|
|
||||||
const newIndex = Math.min(
|
|
||||||
currentIndex + THINKING_CHARS_PER_FRAME,
|
|
||||||
currentContent.length
|
|
||||||
)
|
|
||||||
const newDisplayed = currentContent.slice(0, newIndex)
|
|
||||||
setDisplayedContent(newDisplayed)
|
|
||||||
indexRef.current = newIndex
|
|
||||||
lastFrameTimeRef.current = timestamp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (indexRef.current < currentContent.length) {
|
|
||||||
rafRef.current = requestAnimationFrame(animateText)
|
|
||||||
} else {
|
|
||||||
isAnimatingRef.current = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rafRef.current = requestAnimationFrame(animateText)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Streaming ended - show full content immediately
|
|
||||||
if (rafRef.current) {
|
|
||||||
cancelAnimationFrame(rafRef.current)
|
|
||||||
}
|
|
||||||
setDisplayedContent(content)
|
|
||||||
indexRef.current = content.length
|
|
||||||
isAnimatingRef.current = false
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (rafRef.current) {
|
|
||||||
cancelAnimationFrame(rafRef.current)
|
|
||||||
}
|
|
||||||
isAnimatingRef.current = false
|
|
||||||
}
|
|
||||||
}, [content, isStreaming])
|
|
||||||
|
|
||||||
// Check if content height exceeds threshold for gradient
|
|
||||||
useEffect(() => {
|
|
||||||
if (textRef.current && isStreaming) {
|
|
||||||
const height = textRef.current.scrollHeight
|
|
||||||
setShowGradient(height > GRADIENT_THRESHOLD)
|
|
||||||
} else {
|
|
||||||
setShowGradient(false)
|
|
||||||
}
|
|
||||||
}, [displayedContent, isStreaming])
|
|
||||||
|
|
||||||
// Apply vertical gradient fade at the top only when content is tall enough
|
|
||||||
const gradientStyle =
|
|
||||||
isStreaming && showGradient
|
|
||||||
? {
|
|
||||||
maskImage: 'linear-gradient(to bottom, transparent 0%, black 30%, black 100%)',
|
|
||||||
WebkitMaskImage: 'linear-gradient(to bottom, transparent 0%, black 30%, black 100%)',
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={textRef}
|
|
||||||
className='[&_*]:!text-[var(--text-muted)] [&_*]:!text-[12px] [&_*]:!leading-[1.4] [&_p]:!m-0 [&_p]:!mb-1 [&_h1]:!text-[12px] [&_h1]:!font-semibold [&_h1]:!m-0 [&_h1]:!mb-1 [&_h2]:!text-[12px] [&_h2]:!font-semibold [&_h2]:!m-0 [&_h2]:!mb-1 [&_h3]:!text-[12px] [&_h3]:!font-semibold [&_h3]:!m-0 [&_h3]:!mb-1 [&_code]:!text-[11px] [&_ul]:!pl-5 [&_ul]:!my-1 [&_ol]:!pl-6 [&_ol]:!my-1 [&_li]:!my-0.5 [&_li]:!py-0 font-season text-[12px] text-[var(--text-muted)]'
|
|
||||||
style={gradientStyle}
|
|
||||||
>
|
|
||||||
<CopilotMarkdownRenderer content={displayedContent} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
(prevProps, nextProps) => {
|
|
||||||
return (
|
|
||||||
prevProps.content === nextProps.content && prevProps.isStreaming === nextProps.isStreaming
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
SmoothThinkingText.displayName = 'SmoothThinkingText'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Props for the ThinkingBlock component
|
* Props for the ThinkingBlock component
|
||||||
*/
|
*/
|
||||||
@@ -199,8 +66,8 @@ export function ThinkingBlock({
|
|||||||
* Auto-collapses when streaming ends OR when following content arrives
|
* Auto-collapses when streaming ends OR when following content arrives
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Collapse if streaming ended, there's following content, or special tags arrived
|
// Collapse if streaming ended or if there's following content (like a tool call)
|
||||||
if (!isStreaming || hasFollowingContent || hasSpecialTags) {
|
if (!isStreaming || hasFollowingContent) {
|
||||||
setIsExpanded(false)
|
setIsExpanded(false)
|
||||||
userCollapsedRef.current = false
|
userCollapsedRef.current = false
|
||||||
setUserHasScrolledAway(false)
|
setUserHasScrolledAway(false)
|
||||||
@@ -210,7 +77,7 @@ export function ThinkingBlock({
|
|||||||
if (!userCollapsedRef.current && content && content.trim().length > 0) {
|
if (!userCollapsedRef.current && content && content.trim().length > 0) {
|
||||||
setIsExpanded(true)
|
setIsExpanded(true)
|
||||||
}
|
}
|
||||||
}, [isStreaming, content, hasFollowingContent, hasSpecialTags])
|
}, [isStreaming, content, hasFollowingContent])
|
||||||
|
|
||||||
// Reset start time when streaming begins
|
// Reset start time when streaming begins
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -246,14 +113,14 @@ export function ThinkingBlock({
|
|||||||
const isNearBottom = distanceFromBottom <= 20
|
const isNearBottom = distanceFromBottom <= 20
|
||||||
|
|
||||||
const delta = scrollTop - lastScrollTopRef.current
|
const delta = scrollTop - lastScrollTopRef.current
|
||||||
const movedUp = delta < -1
|
const movedUp = delta < -2
|
||||||
|
|
||||||
if (movedUp && !isNearBottom) {
|
if (movedUp && !isNearBottom) {
|
||||||
setUserHasScrolledAway(true)
|
setUserHasScrolledAway(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-stick if user scrolls back to bottom with intent
|
// Re-stick if user scrolls back to bottom
|
||||||
if (userHasScrolledAway && isNearBottom && delta > 10) {
|
if (userHasScrolledAway && isNearBottom) {
|
||||||
setUserHasScrolledAway(false)
|
setUserHasScrolledAway(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,7 +133,7 @@ export function ThinkingBlock({
|
|||||||
return () => container.removeEventListener('scroll', handleScroll)
|
return () => container.removeEventListener('scroll', handleScroll)
|
||||||
}, [isExpanded, userHasScrolledAway])
|
}, [isExpanded, userHasScrolledAway])
|
||||||
|
|
||||||
// Smart auto-scroll: always scroll to bottom while streaming unless user scrolled away
|
// Smart auto-scroll: only scroll if user hasn't scrolled away
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isStreaming || !isExpanded || userHasScrolledAway) return
|
if (!isStreaming || !isExpanded || userHasScrolledAway) return
|
||||||
|
|
||||||
@@ -274,14 +141,20 @@ export function ThinkingBlock({
|
|||||||
const container = scrollContainerRef.current
|
const container = scrollContainerRef.current
|
||||||
if (!container) return
|
if (!container) return
|
||||||
|
|
||||||
programmaticScrollRef.current = true
|
const { scrollTop, scrollHeight, clientHeight } = container
|
||||||
container.scrollTo({
|
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
|
||||||
top: container.scrollHeight,
|
const isNearBottom = distanceFromBottom <= 50
|
||||||
behavior: 'auto',
|
|
||||||
})
|
if (isNearBottom) {
|
||||||
window.setTimeout(() => {
|
programmaticScrollRef.current = true
|
||||||
programmaticScrollRef.current = false
|
container.scrollTo({
|
||||||
}, 16)
|
top: container.scrollHeight,
|
||||||
|
behavior: 'smooth',
|
||||||
|
})
|
||||||
|
window.setTimeout(() => {
|
||||||
|
programmaticScrollRef.current = false
|
||||||
|
}, 150)
|
||||||
|
}
|
||||||
}, SCROLL_INTERVAL)
|
}, SCROLL_INTERVAL)
|
||||||
|
|
||||||
return () => window.clearInterval(intervalId)
|
return () => window.clearInterval(intervalId)
|
||||||
@@ -368,11 +241,15 @@ export function ThinkingBlock({
|
|||||||
<div
|
<div
|
||||||
ref={scrollContainerRef}
|
ref={scrollContainerRef}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'overflow-y-auto transition-all duration-150 ease-out',
|
'overflow-y-auto transition-all duration-300 ease-in-out',
|
||||||
isExpanded ? 'mt-1.5 max-h-[150px] opacity-100' : 'max-h-0 opacity-0'
|
isExpanded ? 'mt-1.5 max-h-[200px] opacity-100' : 'max-h-0 opacity-0'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SmoothThinkingText content={content} isStreaming={isStreaming && !hasFollowingContent} />
|
{/* Render markdown during streaming with thinking text styling */}
|
||||||
|
<div className='[&_*]:!text-[var(--text-muted)] [&_*]:!text-[12px] [&_*]:!leading-[1.3] [&_p]:!m-0 [&_p]:!mb-1 [&_h1]:!text-[12px] [&_h1]:!font-semibold [&_h1]:!m-0 [&_h1]:!mb-1 [&_h2]:!text-[12px] [&_h2]:!font-semibold [&_h2]:!m-0 [&_h2]:!mb-1 [&_h3]:!text-[12px] [&_h3]:!font-semibold [&_h3]:!m-0 [&_h3]:!mb-1 [&_code]:!text-[11px] [&_ul]:!pl-5 [&_ul]:!my-1 [&_ol]:!pl-6 [&_ol]:!my-1 [&_li]:!my-0.5 [&_li]:!py-0 [&_br]:!leading-[0.5] [&_table]:!my-2 [&_th]:!px-2 [&_th]:!py-1 [&_th]:!text-[11px] [&_td]:!px-2 [&_td]:!py-1 [&_td]:!text-[11px] whitespace-pre-wrap font-[470] font-season text-[12px] text-[var(--text-muted)]'>
|
||||||
|
<CopilotMarkdownRenderer content={content} />
|
||||||
|
<span className='ml-1 inline-block h-2 w-1 animate-pulse bg-[var(--text-muted)]' />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -404,12 +281,12 @@ export function ThinkingBlock({
|
|||||||
<div
|
<div
|
||||||
ref={scrollContainerRef}
|
ref={scrollContainerRef}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'overflow-y-auto transition-all duration-150 ease-out',
|
'overflow-y-auto transition-all duration-300 ease-in-out',
|
||||||
isExpanded ? 'mt-1.5 max-h-[150px] opacity-100' : 'max-h-0 opacity-0'
|
isExpanded ? 'mt-1.5 max-h-[200px] opacity-100' : 'max-h-0 opacity-0'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Completed thinking text - dimmed with markdown */}
|
{/* Use markdown renderer for completed content */}
|
||||||
<div className='[&_*]:!text-[var(--text-muted)] [&_*]:!text-[12px] [&_*]:!leading-[1.4] [&_p]:!m-0 [&_p]:!mb-1 [&_h1]:!text-[12px] [&_h1]:!font-semibold [&_h1]:!m-0 [&_h1]:!mb-1 [&_h2]:!text-[12px] [&_h2]:!font-semibold [&_h2]:!m-0 [&_h2]:!mb-1 [&_h3]:!text-[12px] [&_h3]:!font-semibold [&_h3]:!m-0 [&_h3]:!mb-1 [&_code]:!text-[11px] [&_ul]:!pl-5 [&_ul]:!my-1 [&_ol]:!pl-6 [&_ol]:!my-1 [&_li]:!my-0.5 [&_li]:!py-0 font-season text-[12px] text-[var(--text-muted)]'>
|
<div className='[&_*]:!text-[var(--text-muted)] [&_*]:!text-[12px] [&_*]:!leading-[1.3] [&_p]:!m-0 [&_p]:!mb-1 [&_h1]:!text-[12px] [&_h1]:!font-semibold [&_h1]:!m-0 [&_h1]:!mb-1 [&_h2]:!text-[12px] [&_h2]:!font-semibold [&_h2]:!m-0 [&_h2]:!mb-1 [&_h3]:!text-[12px] [&_h3]:!font-semibold [&_h3]:!m-0 [&_h3]:!mb-1 [&_code]:!text-[11px] [&_ul]:!pl-5 [&_ul]:!my-1 [&_ol]:!pl-6 [&_ol]:!my-1 [&_li]:!my-0.5 [&_li]:!py-0 [&_br]:!leading-[0.5] [&_table]:!my-2 [&_th]:!px-2 [&_th]:!py-1 [&_th]:!text-[11px] [&_td]:!px-2 [&_td]:!py-1 [&_td]:!text-[11px] whitespace-pre-wrap font-[470] font-season text-[12px] text-[var(--text-muted)]'>
|
||||||
<CopilotMarkdownRenderer content={content} />
|
<CopilotMarkdownRenderer content={content} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -187,7 +187,6 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Memoize content blocks to avoid re-rendering unchanged blocks
|
// Memoize content blocks to avoid re-rendering unchanged blocks
|
||||||
// No entrance animations to prevent layout shift
|
|
||||||
const memoizedContentBlocks = useMemo(() => {
|
const memoizedContentBlocks = useMemo(() => {
|
||||||
if (!message.contentBlocks || message.contentBlocks.length === 0) {
|
if (!message.contentBlocks || message.contentBlocks.length === 0) {
|
||||||
return null
|
return null
|
||||||
@@ -206,10 +205,14 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
|
|
||||||
// Use smooth streaming for the last text block if we're streaming
|
// Use smooth streaming for the last text block if we're streaming
|
||||||
const shouldUseSmoothing = isStreaming && isLastTextBlock
|
const shouldUseSmoothing = isStreaming && isLastTextBlock
|
||||||
const blockKey = `text-${index}-${block.timestamp || index}`
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={blockKey} className='w-full max-w-full'>
|
<div
|
||||||
|
key={`text-${index}-${block.timestamp || index}`}
|
||||||
|
className={`w-full max-w-full overflow-hidden transition-opacity duration-200 ease-in-out ${
|
||||||
|
cleanBlockContent.length > 0 ? 'opacity-100' : 'opacity-70'
|
||||||
|
} ${shouldUseSmoothing ? 'translate-y-0 transition-transform duration-100 ease-out' : ''}`}
|
||||||
|
>
|
||||||
{shouldUseSmoothing ? (
|
{shouldUseSmoothing ? (
|
||||||
<SmoothStreamingText content={cleanBlockContent} isStreaming={isStreaming} />
|
<SmoothStreamingText content={cleanBlockContent} isStreaming={isStreaming} />
|
||||||
) : (
|
) : (
|
||||||
@@ -221,33 +224,29 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
if (block.type === 'thinking') {
|
if (block.type === 'thinking') {
|
||||||
// Check if there are any blocks after this one (tool calls, text, etc.)
|
// Check if there are any blocks after this one (tool calls, text, etc.)
|
||||||
const hasFollowingContent = index < message.contentBlocks!.length - 1
|
const hasFollowingContent = index < message.contentBlocks!.length - 1
|
||||||
// Check if special tags (options, plan) are present - should also close thinking
|
|
||||||
const hasSpecialTags = !!(parsedTags?.options || parsedTags?.plan)
|
|
||||||
const blockKey = `thinking-${index}-${block.timestamp || index}`
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={blockKey} className='w-full'>
|
<div key={`thinking-${index}-${block.timestamp || index}`} className='w-full'>
|
||||||
<ThinkingBlock
|
<ThinkingBlock
|
||||||
content={block.content}
|
content={block.content}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
hasFollowingContent={hasFollowingContent}
|
hasFollowingContent={hasFollowingContent}
|
||||||
hasSpecialTags={hasSpecialTags}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (block.type === 'tool_call') {
|
if (block.type === 'tool_call') {
|
||||||
const blockKey = `tool-${block.toolCall.id}`
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={blockKey}>
|
<div
|
||||||
|
key={`tool-${block.toolCall.id}`}
|
||||||
|
className='opacity-100 transition-opacity duration-300 ease-in-out'
|
||||||
|
>
|
||||||
<ToolCall toolCallId={block.toolCall.id} toolCall={block.toolCall} />
|
<ToolCall toolCallId={block.toolCall.id} toolCall={block.toolCall} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
}, [message.contentBlocks, isStreaming, parsedTags])
|
}, [message.contentBlocks, isStreaming])
|
||||||
|
|
||||||
if (isUser) {
|
if (isUser) {
|
||||||
return (
|
return (
|
||||||
@@ -280,7 +279,6 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
onModeChange={setMode}
|
onModeChange={setMode}
|
||||||
panelWidth={panelWidth}
|
panelWidth={panelWidth}
|
||||||
clearOnSubmit={false}
|
clearOnSubmit={false}
|
||||||
initialContexts={message.contexts}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Inline Checkpoint Discard Confirmation - shown below input in edit mode */}
|
{/* Inline Checkpoint Discard Confirmation - shown below input in edit mode */}
|
||||||
@@ -348,18 +346,14 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
const contexts: any[] = Array.isArray((message as any).contexts)
|
const contexts: any[] = Array.isArray((message as any).contexts)
|
||||||
? ((message as any).contexts as any[])
|
? ((message as any).contexts as any[])
|
||||||
: []
|
: []
|
||||||
|
const labels = contexts
|
||||||
// Build tokens with their prefixes (@ for mentions, / for commands)
|
.filter((c) => c?.kind !== 'current_workflow')
|
||||||
const tokens = contexts
|
.map((c) => c?.label)
|
||||||
.filter((c) => c?.kind !== 'current_workflow' && c?.label)
|
.filter(Boolean) as string[]
|
||||||
.map((c) => {
|
if (!labels.length) return text
|
||||||
const prefix = c?.kind === 'slash_command' ? '/' : '@'
|
|
||||||
return `${prefix}${c.label}`
|
|
||||||
})
|
|
||||||
if (!tokens.length) return text
|
|
||||||
|
|
||||||
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
const pattern = new RegExp(`(${tokens.map(escapeRegex).join('|')})`, 'g')
|
const pattern = new RegExp(`@(${labels.map(escapeRegex).join('|')})`, 'g')
|
||||||
|
|
||||||
const nodes: React.ReactNode[] = []
|
const nodes: React.ReactNode[] = []
|
||||||
let lastIndex = 0
|
let lastIndex = 0
|
||||||
@@ -466,29 +460,17 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if there's any visible content in the blocks
|
|
||||||
const hasVisibleContent = useMemo(() => {
|
|
||||||
if (!message.contentBlocks || message.contentBlocks.length === 0) return false
|
|
||||||
return message.contentBlocks.some((block) => {
|
|
||||||
if (block.type === 'text') {
|
|
||||||
const parsed = parseSpecialTags(block.content)
|
|
||||||
return parsed.cleanContent.trim().length > 0
|
|
||||||
}
|
|
||||||
return block.type === 'thinking' || block.type === 'tool_call'
|
|
||||||
})
|
|
||||||
}, [message.contentBlocks])
|
|
||||||
|
|
||||||
if (isAssistant) {
|
if (isAssistant) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`w-full max-w-full overflow-hidden [max-width:var(--panel-max-width)] ${isDimmed ? 'opacity-40' : 'opacity-100'}`}
|
className={`w-full max-w-full overflow-hidden transition-opacity duration-200 [max-width:var(--panel-max-width)] ${isDimmed ? 'opacity-40' : 'opacity-100'}`}
|
||||||
style={{ '--panel-max-width': `${panelWidth - 16}px` } as React.CSSProperties}
|
style={{ '--panel-max-width': `${panelWidth - 16}px` } as React.CSSProperties}
|
||||||
>
|
>
|
||||||
<div className='max-w-full space-y-1 px-[2px]'>
|
<div className='max-w-full space-y-1.5 px-[2px] transition-all duration-200 ease-in-out'>
|
||||||
{/* Content blocks in chronological order */}
|
{/* Content blocks in chronological order */}
|
||||||
{memoizedContentBlocks}
|
{memoizedContentBlocks}
|
||||||
|
|
||||||
{/* Streaming indicator always at bottom during streaming */}
|
{/* Always show streaming indicator at the end while streaming */}
|
||||||
{isStreaming && <StreamingIndicator />}
|
{isStreaming && <StreamingIndicator />}
|
||||||
|
|
||||||
{message.errorType === 'usage_limit' && (
|
{message.errorType === 'usage_limit' && (
|
||||||
|
|||||||
@@ -497,11 +497,6 @@ const ACTION_VERBS = [
|
|||||||
'Accessed',
|
'Accessed',
|
||||||
'Managing',
|
'Managing',
|
||||||
'Managed',
|
'Managed',
|
||||||
'Scraping',
|
|
||||||
'Scraped',
|
|
||||||
'Crawling',
|
|
||||||
'Crawled',
|
|
||||||
'Getting',
|
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1066,7 +1061,7 @@ function SubAgentContent({
|
|||||||
<div
|
<div
|
||||||
ref={scrollContainerRef}
|
ref={scrollContainerRef}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'overflow-y-auto transition-all duration-150 ease-out',
|
'overflow-y-auto transition-all duration-300 ease-in-out',
|
||||||
isExpanded ? 'mt-1.5 max-h-[200px] opacity-100' : 'max-h-0 opacity-0'
|
isExpanded ? 'mt-1.5 max-h-[200px] opacity-100' : 'max-h-0 opacity-0'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -1162,10 +1157,10 @@ function SubAgentThinkingContent({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Subagents that should collapse when done streaming.
|
* Subagents that should collapse when done streaming.
|
||||||
* Default behavior is to NOT collapse (stay expanded like edit, superagent, info, etc.).
|
* Default behavior is to NOT collapse (stay expanded like edit).
|
||||||
* Only plan, debug, and research collapse into summary headers.
|
* Only these specific subagents collapse into "Planned for Xs >" style headers.
|
||||||
*/
|
*/
|
||||||
const COLLAPSIBLE_SUBAGENTS = new Set(['plan', 'debug', 'research'])
|
const COLLAPSIBLE_SUBAGENTS = new Set(['plan', 'debug', 'research', 'info'])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SubagentContentRenderer handles the rendering of subagent content.
|
* SubagentContentRenderer handles the rendering of subagent content.
|
||||||
@@ -1326,7 +1321,7 @@ function SubagentContentRenderer({
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'overflow-hidden transition-all duration-150 ease-out',
|
'overflow-hidden transition-all duration-300 ease-in-out',
|
||||||
isExpanded ? 'mt-1.5 max-h-[5000px] opacity-100' : 'max-h-0 opacity-0'
|
isExpanded ? 'mt-1.5 max-h-[5000px] opacity-100' : 'max-h-0 opacity-0'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -1636,8 +1631,10 @@ function WorkflowEditSummary({ toolCall }: { toolCall: CopilotToolCall }) {
|
|||||||
* Checks if a tool is an integration tool (server-side executed, not a client tool)
|
* Checks if a tool is an integration tool (server-side executed, not a client tool)
|
||||||
*/
|
*/
|
||||||
function isIntegrationTool(toolName: string): boolean {
|
function isIntegrationTool(toolName: string): boolean {
|
||||||
// Any tool NOT in CLASS_TOOL_METADATA is an integration tool (server-side execution)
|
// Check if it's NOT a client tool (not in CLASS_TOOL_METADATA and not in registered tools)
|
||||||
return !CLASS_TOOL_METADATA[toolName]
|
const isClientTool = !!CLASS_TOOL_METADATA[toolName]
|
||||||
|
const isRegisteredTool = !!getRegisteredTools()[toolName]
|
||||||
|
return !isClientTool && !isRegisteredTool
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldShowRunSkipButtons(toolCall: CopilotToolCall): boolean {
|
function shouldShowRunSkipButtons(toolCall: CopilotToolCall): boolean {
|
||||||
@@ -1666,9 +1663,16 @@ function shouldShowRunSkipButtons(toolCall: CopilotToolCall): boolean {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always show buttons for integration tools in pending state (they need user confirmation)
|
// Also show buttons for integration tools in pending state (they need user confirmation)
|
||||||
|
// But NOT if the tool is auto-allowed (it will auto-execute)
|
||||||
const mode = useCopilotStore.getState().mode
|
const mode = useCopilotStore.getState().mode
|
||||||
if (mode === 'build' && isIntegrationTool(toolCall.name) && toolCall.state === 'pending') {
|
const isAutoAllowed = useCopilotStore.getState().isToolAutoAllowed(toolCall.name)
|
||||||
|
if (
|
||||||
|
mode === 'build' &&
|
||||||
|
isIntegrationTool(toolCall.name) &&
|
||||||
|
toolCall.state === 'pending' &&
|
||||||
|
!isAutoAllowed
|
||||||
|
) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1891,20 +1895,15 @@ function RunSkipButtons({
|
|||||||
|
|
||||||
if (buttonsHidden) return null
|
if (buttonsHidden) return null
|
||||||
|
|
||||||
// Hide "Always Allow" for integration tools (only show for client tools with interrupts)
|
// Standardized buttons for all interrupt tools: Allow, Always Allow, Skip
|
||||||
const showAlwaysAllow = !isIntegrationTool(toolCall.name)
|
|
||||||
|
|
||||||
// Standardized buttons for all interrupt tools: Allow, (Always Allow for client tools only), Skip
|
|
||||||
return (
|
return (
|
||||||
<div className='mt-1.5 flex gap-[6px]'>
|
<div className='mt-1.5 flex gap-[6px]'>
|
||||||
<Button onClick={onRun} disabled={isProcessing} variant='tertiary'>
|
<Button onClick={onRun} disabled={isProcessing} variant='tertiary'>
|
||||||
{isProcessing ? 'Allowing...' : 'Allow'}
|
{isProcessing ? 'Allowing...' : 'Allow'}
|
||||||
</Button>
|
</Button>
|
||||||
{showAlwaysAllow && (
|
<Button onClick={onAlwaysAllow} disabled={isProcessing} variant='default'>
|
||||||
<Button onClick={onAlwaysAllow} disabled={isProcessing} variant='default'>
|
{isProcessing ? 'Allowing...' : 'Always Allow'}
|
||||||
{isProcessing ? 'Allowing...' : 'Always Allow'}
|
</Button>
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button onClick={onSkip} disabled={isProcessing} variant='default'>
|
<Button onClick={onSkip} disabled={isProcessing} variant='default'>
|
||||||
Skip
|
Skip
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1970,7 +1969,6 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
|
|||||||
'tour',
|
'tour',
|
||||||
'info',
|
'info',
|
||||||
'workflow',
|
'workflow',
|
||||||
'superagent',
|
|
||||||
]
|
]
|
||||||
const isSubagentTool = SUBAGENT_TOOLS.includes(toolCall.name)
|
const isSubagentTool = SUBAGENT_TOOLS.includes(toolCall.name)
|
||||||
|
|
||||||
@@ -2598,23 +2596,16 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For edit_workflow, hide text display when we have operations (WorkflowEditSummary replaces it)
|
|
||||||
const isEditWorkflow = toolCall.name === 'edit_workflow'
|
|
||||||
const hasOperations = Array.isArray(params.operations) && params.operations.length > 0
|
|
||||||
const hideTextForEditWorkflow = isEditWorkflow && hasOperations
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='w-full'>
|
<div className='w-full'>
|
||||||
{!hideTextForEditWorkflow && (
|
<div className={isToolNameClickable ? 'cursor-pointer' : ''} onClick={handleToolNameClick}>
|
||||||
<div className={isToolNameClickable ? 'cursor-pointer' : ''} onClick={handleToolNameClick}>
|
<ShimmerOverlayText
|
||||||
<ShimmerOverlayText
|
text={displayName}
|
||||||
text={displayName}
|
active={isLoadingState}
|
||||||
active={isLoadingState}
|
isSpecial={isSpecial}
|
||||||
isSpecial={isSpecial}
|
className='font-[470] font-season text-[var(--text-secondary)] text-sm dark:text-[var(--text-muted)]'
|
||||||
className='font-[470] font-season text-[var(--text-secondary)] text-sm dark:text-[var(--text-muted)]'
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isExpandableTool && expanded && <div className='mt-1.5'>{renderPendingDetails()}</div>}
|
{isExpandableTool && expanded && <div className='mt-1.5'>{renderPendingDetails()}</div>}
|
||||||
{showRemoveAutoAllow && isAutoAllowed && (
|
{showRemoveAutoAllow && isAutoAllowed && (
|
||||||
<div className='mt-1.5'>
|
<div className='mt-1.5'>
|
||||||
|
|||||||
@@ -3,4 +3,3 @@ export { ContextPills } from './context-pills/context-pills'
|
|||||||
export { MentionMenu } from './mention-menu/mention-menu'
|
export { MentionMenu } from './mention-menu/mention-menu'
|
||||||
export { ModeSelector } from './mode-selector/mode-selector'
|
export { ModeSelector } from './mode-selector/mode-selector'
|
||||||
export { ModelSelector } from './model-selector/model-selector'
|
export { ModelSelector } from './model-selector/model-selector'
|
||||||
export { SlashMenu } from './slash-menu/slash-menu'
|
|
||||||
|
|||||||
@@ -1,249 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useMemo } from 'react'
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverAnchor,
|
|
||||||
PopoverBackButton,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverFolder,
|
|
||||||
PopoverItem,
|
|
||||||
PopoverScrollArea,
|
|
||||||
} from '@/components/emcn'
|
|
||||||
import type { useMentionMenu } from '../../hooks/use-mention-menu'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Top-level slash command options
|
|
||||||
*/
|
|
||||||
const TOP_LEVEL_COMMANDS = [
|
|
||||||
{ id: 'fast', label: 'fast' },
|
|
||||||
{ id: 'plan', label: 'plan' },
|
|
||||||
{ id: 'debug', label: 'debug' },
|
|
||||||
{ id: 'research', label: 'research' },
|
|
||||||
{ id: 'deploy', label: 'deploy' },
|
|
||||||
{ id: 'superagent', label: 'superagent' },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Web submenu commands
|
|
||||||
*/
|
|
||||||
const WEB_COMMANDS = [
|
|
||||||
{ id: 'search', label: 'search' },
|
|
||||||
{ id: 'read', label: 'read' },
|
|
||||||
{ id: 'scrape', label: 'scrape' },
|
|
||||||
{ id: 'crawl', label: 'crawl' },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All command labels for filtering
|
|
||||||
*/
|
|
||||||
const ALL_COMMANDS = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS]
|
|
||||||
|
|
||||||
interface SlashMenuProps {
|
|
||||||
mentionMenu: ReturnType<typeof useMentionMenu>
|
|
||||||
message: string
|
|
||||||
onSelectCommand: (command: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SlashMenu component for slash command dropdown.
|
|
||||||
* Shows command options when user types '/'.
|
|
||||||
*
|
|
||||||
* @param props - Component props
|
|
||||||
* @returns Rendered slash menu
|
|
||||||
*/
|
|
||||||
export function SlashMenu({ mentionMenu, message, onSelectCommand }: SlashMenuProps) {
|
|
||||||
const {
|
|
||||||
mentionMenuRef,
|
|
||||||
menuListRef,
|
|
||||||
getActiveSlashQueryAtPosition,
|
|
||||||
getCaretPos,
|
|
||||||
submenuActiveIndex,
|
|
||||||
mentionActiveIndex,
|
|
||||||
openSubmenuFor,
|
|
||||||
setOpenSubmenuFor,
|
|
||||||
} = mentionMenu
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current query string after /
|
|
||||||
*/
|
|
||||||
const currentQuery = useMemo(() => {
|
|
||||||
const caretPos = getCaretPos()
|
|
||||||
const active = getActiveSlashQueryAtPosition(caretPos, message)
|
|
||||||
return active?.query.trim().toLowerCase() || ''
|
|
||||||
}, [message, getCaretPos, getActiveSlashQueryAtPosition])
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filter commands based on query (search across all commands when there's a query)
|
|
||||||
*/
|
|
||||||
const filteredCommands = useMemo(() => {
|
|
||||||
if (!currentQuery) return null // Show folder view when no query
|
|
||||||
return ALL_COMMANDS.filter((cmd) => cmd.label.toLowerCase().includes(currentQuery))
|
|
||||||
}, [currentQuery])
|
|
||||||
|
|
||||||
// Show aggregated view when there's a query
|
|
||||||
const showAggregatedView = currentQuery.length > 0
|
|
||||||
|
|
||||||
// Compute caret viewport position via mirror technique for precise anchoring
|
|
||||||
const textareaEl = mentionMenu.textareaRef.current
|
|
||||||
if (!textareaEl) return null
|
|
||||||
|
|
||||||
const getCaretViewport = (textarea: HTMLTextAreaElement, caretPosition: number, text: string) => {
|
|
||||||
const textareaRect = textarea.getBoundingClientRect()
|
|
||||||
const style = window.getComputedStyle(textarea)
|
|
||||||
|
|
||||||
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 = text.substring(0, caretPosition)
|
|
||||||
|
|
||||||
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 leftOffset = markerRect.left - mirrorRect.left - textarea.scrollLeft
|
|
||||||
const topOffset = markerRect.top - mirrorRect.top - textarea.scrollTop
|
|
||||||
|
|
||||||
return {
|
|
||||||
left: textareaRect.left + leftOffset,
|
|
||||||
top: textareaRect.top + topOffset,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const caretPos = getCaretPos()
|
|
||||||
const caretViewport = getCaretViewport(textareaEl, caretPos, message)
|
|
||||||
|
|
||||||
// Decide preferred side based on available space
|
|
||||||
const margin = 8
|
|
||||||
const spaceAbove = caretViewport.top - margin
|
|
||||||
const spaceBelow = window.innerHeight - caretViewport.top - margin
|
|
||||||
const side: 'top' | 'bottom' = spaceBelow >= spaceAbove ? 'bottom' : 'top'
|
|
||||||
|
|
||||||
// Check if we're in folder navigation mode (no query, not in submenu)
|
|
||||||
const isInFolderNavigationMode = !openSubmenuFor && !showAggregatedView
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover
|
|
||||||
open={true}
|
|
||||||
onOpenChange={() => {
|
|
||||||
/* controlled externally */
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PopoverAnchor asChild>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
top: `${caretViewport.top}px`,
|
|
||||||
left: `${caretViewport.left}px`,
|
|
||||||
width: '1px',
|
|
||||||
height: '1px',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</PopoverAnchor>
|
|
||||||
<PopoverContent
|
|
||||||
ref={mentionMenuRef}
|
|
||||||
side={side}
|
|
||||||
align='start'
|
|
||||||
collisionPadding={6}
|
|
||||||
maxHeight={360}
|
|
||||||
className='pointer-events-auto'
|
|
||||||
style={{
|
|
||||||
width: `180px`,
|
|
||||||
}}
|
|
||||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
||||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<PopoverBackButton />
|
|
||||||
<PopoverScrollArea ref={menuListRef} className='space-y-[2px]'>
|
|
||||||
{openSubmenuFor === 'Web' ? (
|
|
||||||
// Web submenu view
|
|
||||||
<>
|
|
||||||
{WEB_COMMANDS.map((cmd, index) => (
|
|
||||||
<PopoverItem
|
|
||||||
key={cmd.id}
|
|
||||||
onClick={() => onSelectCommand(cmd.label)}
|
|
||||||
data-idx={index}
|
|
||||||
active={index === submenuActiveIndex}
|
|
||||||
>
|
|
||||||
<span className='truncate capitalize'>{cmd.label}</span>
|
|
||||||
</PopoverItem>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
) : showAggregatedView ? (
|
|
||||||
// Aggregated filtered view
|
|
||||||
<>
|
|
||||||
{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.label)}
|
|
||||||
data-idx={index}
|
|
||||||
active={index === submenuActiveIndex}
|
|
||||||
>
|
|
||||||
<span className='truncate capitalize'>{cmd.label}</span>
|
|
||||||
</PopoverItem>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
// Folder navigation view
|
|
||||||
<>
|
|
||||||
{TOP_LEVEL_COMMANDS.map((cmd, index) => (
|
|
||||||
<PopoverItem
|
|
||||||
key={cmd.id}
|
|
||||||
onClick={() => onSelectCommand(cmd.label)}
|
|
||||||
data-idx={index}
|
|
||||||
active={isInFolderNavigationMode && index === mentionActiveIndex}
|
|
||||||
>
|
|
||||||
<span className='truncate capitalize'>{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.label)}>
|
|
||||||
<span className='truncate capitalize'>{cmd.label}</span>
|
|
||||||
</PopoverItem>
|
|
||||||
))}
|
|
||||||
</PopoverFolder>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</PopoverScrollArea>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import type { ChatContext } from '@/stores/panel'
|
import type { ChatContext } from '@/stores/panel'
|
||||||
|
|
||||||
interface UseContextManagementProps {
|
interface UseContextManagementProps {
|
||||||
/** Current message text */
|
/** Current message text */
|
||||||
message: string
|
message: string
|
||||||
/** Initial contexts to populate when editing a message */
|
|
||||||
initialContexts?: ChatContext[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,17 +13,8 @@ interface UseContextManagementProps {
|
|||||||
* @param props - Configuration object
|
* @param props - Configuration object
|
||||||
* @returns Context state and management functions
|
* @returns Context state and management functions
|
||||||
*/
|
*/
|
||||||
export function useContextManagement({ message, initialContexts }: UseContextManagementProps) {
|
export function useContextManagement({ message }: UseContextManagementProps) {
|
||||||
const [selectedContexts, setSelectedContexts] = useState<ChatContext[]>(initialContexts ?? [])
|
const [selectedContexts, setSelectedContexts] = useState<ChatContext[]>([])
|
||||||
const initializedRef = useRef(false)
|
|
||||||
|
|
||||||
// Initialize with initial contexts when they're first provided (for edit mode)
|
|
||||||
useEffect(() => {
|
|
||||||
if (initialContexts && initialContexts.length > 0 && !initializedRef.current) {
|
|
||||||
setSelectedContexts(initialContexts)
|
|
||||||
initializedRef.current = true
|
|
||||||
}
|
|
||||||
}, [initialContexts])
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds a context to the selected contexts list, avoiding duplicates
|
* Adds a context to the selected contexts list, avoiding duplicates
|
||||||
@@ -74,9 +63,6 @@ export function useContextManagement({ message, initialContexts }: UseContextMan
|
|||||||
if (c.kind === 'docs') {
|
if (c.kind === 'docs') {
|
||||||
return true // Only one docs context allowed
|
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
|
return false
|
||||||
@@ -117,8 +103,6 @@ export function useContextManagement({ message, initialContexts }: UseContextMan
|
|||||||
return (c as any).executionId !== (contextToRemove as any).executionId
|
return (c as any).executionId !== (contextToRemove as any).executionId
|
||||||
case 'docs':
|
case 'docs':
|
||||||
return false // Remove docs (only one docs context)
|
return false // Remove docs (only one docs context)
|
||||||
case 'slash_command':
|
|
||||||
return (c as any).command !== (contextToRemove as any).command
|
|
||||||
default:
|
default:
|
||||||
return c.label !== contextToRemove.label
|
return c.label !== contextToRemove.label
|
||||||
}
|
}
|
||||||
@@ -134,7 +118,7 @@ export function useContextManagement({ message, initialContexts }: UseContextMan
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Synchronizes selected contexts with inline @label or /label tokens in the message.
|
* Synchronizes selected contexts with inline @label tokens in the message.
|
||||||
* Removes contexts whose labels are no longer present in the message.
|
* Removes contexts whose labels are no longer present in the message.
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -146,16 +130,17 @@ export function useContextManagement({ message, initialContexts }: UseContextMan
|
|||||||
setSelectedContexts((prev) => {
|
setSelectedContexts((prev) => {
|
||||||
if (prev.length === 0) return prev
|
if (prev.length === 0) return prev
|
||||||
|
|
||||||
const filtered = prev.filter((c) => {
|
const presentLabels = new Set<string>()
|
||||||
if (!c.label) return false
|
const labels = prev.map((c) => c.label).filter(Boolean)
|
||||||
// Check for slash command tokens or mention tokens based on kind
|
|
||||||
const isSlashCommand = c.kind === 'slash_command'
|
for (const label of labels) {
|
||||||
const prefix = isSlashCommand ? '/' : '@'
|
const token = ` @${label} `
|
||||||
const tokenWithSpaces = ` ${prefix}${c.label} `
|
if (message.includes(token)) {
|
||||||
const tokenAtStart = `${prefix}${c.label} `
|
presentLabels.add(label)
|
||||||
// Token can appear with leading space OR at the start of the message
|
}
|
||||||
return message.includes(tokenWithSpaces) || message.startsWith(tokenAtStart)
|
}
|
||||||
})
|
|
||||||
|
const filtered = prev.filter((c) => !!c.label && presentLabels.has(c.label))
|
||||||
return filtered.length === prev.length ? prev : filtered
|
return filtered.length === prev.length ? prev : filtered
|
||||||
})
|
})
|
||||||
}, [message])
|
}, [message])
|
||||||
|
|||||||
@@ -70,25 +70,11 @@ export function useMentionMenu({
|
|||||||
// Ensure '@' starts a token (start or whitespace before)
|
// Ensure '@' starts a token (start or whitespace before)
|
||||||
if (atIndex > 0 && !/\s/.test(before.charAt(atIndex - 1))) return null
|
if (atIndex > 0 && !/\s/.test(before.charAt(atIndex - 1))) return null
|
||||||
|
|
||||||
// Check if this '@' is part of a completed mention token
|
// Check if this '@' is part of a completed mention token ( @label )
|
||||||
if (selectedContexts.length > 0) {
|
if (selectedContexts.length > 0) {
|
||||||
// Only check non-slash_command contexts for mentions
|
const labels = selectedContexts.map((c) => c.label).filter(Boolean) as string[]
|
||||||
const mentionLabels = selectedContexts
|
for (const label of labels) {
|
||||||
.filter((c) => c.kind !== 'slash_command')
|
// Space-wrapped token: " @label "
|
||||||
.map((c) => c.label)
|
|
||||||
.filter(Boolean) as string[]
|
|
||||||
|
|
||||||
for (const label of mentionLabels) {
|
|
||||||
// Check for token at start of text: "@label "
|
|
||||||
if (atIndex === 0) {
|
|
||||||
const startToken = `@${label} `
|
|
||||||
if (text.startsWith(startToken)) {
|
|
||||||
// This @ is part of a completed token
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for space-wrapped token: " @label "
|
|
||||||
const token = ` @${label} `
|
const token = ` @${label} `
|
||||||
let fromIndex = 0
|
let fromIndex = 0
|
||||||
while (fromIndex <= text.length) {
|
while (fromIndex <= text.length) {
|
||||||
@@ -102,6 +88,7 @@ export function useMentionMenu({
|
|||||||
// Check if the @ we found is the @ of this completed token
|
// Check if the @ we found is the @ of this completed token
|
||||||
if (atIndex === atPositionInToken) {
|
if (atIndex === atPositionInToken) {
|
||||||
// The @ we found is part of a completed mention
|
// The @ we found is part of a completed mention
|
||||||
|
// Don't show menu - user is typing after the completed mention
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,76 +113,6 @@ export function useMentionMenu({
|
|||||||
[message, selectedContexts]
|
[message, selectedContexts]
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* Finds active slash command query at the given position
|
|
||||||
*
|
|
||||||
* @param pos - Position in the text to check
|
|
||||||
* @param textOverride - Optional text override (for checking during input)
|
|
||||||
* @returns Active slash query object or null if no active slash command
|
|
||||||
*/
|
|
||||||
const getActiveSlashQueryAtPosition = useCallback(
|
|
||||||
(pos: number, textOverride?: string) => {
|
|
||||||
const text = textOverride ?? message
|
|
||||||
const before = text.slice(0, pos)
|
|
||||||
const slashIndex = before.lastIndexOf('/')
|
|
||||||
if (slashIndex === -1) return null
|
|
||||||
|
|
||||||
// Ensure '/' starts a token (start or whitespace before)
|
|
||||||
if (slashIndex > 0 && !/\s/.test(before.charAt(slashIndex - 1))) return null
|
|
||||||
|
|
||||||
// Check if this '/' is part of a completed slash token
|
|
||||||
if (selectedContexts.length > 0) {
|
|
||||||
// Only check slash_command contexts
|
|
||||||
const slashLabels = selectedContexts
|
|
||||||
.filter((c) => c.kind === 'slash_command')
|
|
||||||
.map((c) => c.label)
|
|
||||||
.filter(Boolean) as string[]
|
|
||||||
|
|
||||||
for (const label of slashLabels) {
|
|
||||||
// Check for token at start of text: "/label "
|
|
||||||
if (slashIndex === 0) {
|
|
||||||
const startToken = `/${label} `
|
|
||||||
if (text.startsWith(startToken)) {
|
|
||||||
// This slash is part of a completed token
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for space-wrapped token: " /label "
|
|
||||||
const token = ` /${label} `
|
|
||||||
let fromIndex = 0
|
|
||||||
while (fromIndex <= text.length) {
|
|
||||||
const idx = text.indexOf(token, fromIndex)
|
|
||||||
if (idx === -1) break
|
|
||||||
|
|
||||||
const tokenStart = idx
|
|
||||||
const tokenEnd = idx + token.length
|
|
||||||
const slashPositionInToken = idx + 1 // position of / in " /label "
|
|
||||||
|
|
||||||
if (slashIndex === slashPositionInToken) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pos > tokenStart && pos < tokenEnd) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
fromIndex = tokenEnd
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const segment = before.slice(slashIndex + 1)
|
|
||||||
// Close the popup if user types space immediately after /
|
|
||||||
if (segment.length > 0 && /^\s/.test(segment)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return { query: segment, start: slashIndex, end: pos }
|
|
||||||
},
|
|
||||||
[message, selectedContexts]
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the submenu query text
|
* Gets the submenu query text
|
||||||
*
|
*
|
||||||
@@ -283,10 +200,9 @@ export function useMentionMenu({
|
|||||||
const before = message.slice(0, active.start)
|
const before = message.slice(0, active.start)
|
||||||
const after = message.slice(active.end)
|
const after = message.slice(active.end)
|
||||||
|
|
||||||
// Add leading space only if not at start and previous char isn't whitespace
|
// Always include leading space, avoid duplicate if one exists
|
||||||
const needsLeadingSpace = before.length > 0 && !before.endsWith(' ')
|
const needsLeadingSpace = !before.endsWith(' ')
|
||||||
// Always add trailing space for easy continued typing
|
const insertion = `${needsLeadingSpace ? ' ' : ''}@${label} `
|
||||||
const insertion = `${needsLeadingSpace ? ' ' : ''}@${label} `
|
|
||||||
|
|
||||||
const next = `${before}${insertion}${after}`
|
const next = `${before}${insertion}${after}`
|
||||||
onMessageChange(next)
|
onMessageChange(next)
|
||||||
@@ -301,41 +217,6 @@ export function useMentionMenu({
|
|||||||
[message, getActiveMentionQueryAtPosition, onMessageChange]
|
[message, getActiveMentionQueryAtPosition, onMessageChange]
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* Replaces active slash command with a label
|
|
||||||
*
|
|
||||||
* @param label - Label to replace the slash command with
|
|
||||||
* @returns True if replacement was successful, false if no active slash command found
|
|
||||||
*/
|
|
||||||
const replaceActiveSlashWith = useCallback(
|
|
||||||
(label: string) => {
|
|
||||||
const textarea = textareaRef.current
|
|
||||||
if (!textarea) return false
|
|
||||||
const pos = textarea.selectionStart ?? message.length
|
|
||||||
const active = getActiveSlashQueryAtPosition(pos)
|
|
||||||
if (!active) return false
|
|
||||||
|
|
||||||
const before = message.slice(0, active.start)
|
|
||||||
const after = message.slice(active.end)
|
|
||||||
|
|
||||||
// Add leading space only if not at start and previous char isn't whitespace
|
|
||||||
const needsLeadingSpace = before.length > 0 && !before.endsWith(' ')
|
|
||||||
// Always add trailing space for easy continued typing
|
|
||||||
const insertion = `${needsLeadingSpace ? ' ' : ''}/${label} `
|
|
||||||
|
|
||||||
const next = `${before}${insertion}${after}`
|
|
||||||
onMessageChange(next)
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
const cursorPos = before.length + insertion.length
|
|
||||||
textarea.setSelectionRange(cursorPos, cursorPos)
|
|
||||||
textarea.focus()
|
|
||||||
}, 0)
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
[message, getActiveSlashQueryAtPosition, onMessageChange]
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scrolls active item into view in the menu
|
* Scrolls active item into view in the menu
|
||||||
*
|
*
|
||||||
@@ -423,12 +304,10 @@ export function useMentionMenu({
|
|||||||
// Operations
|
// Operations
|
||||||
getCaretPos,
|
getCaretPos,
|
||||||
getActiveMentionQueryAtPosition,
|
getActiveMentionQueryAtPosition,
|
||||||
getActiveSlashQueryAtPosition,
|
|
||||||
getSubmenuQuery,
|
getSubmenuQuery,
|
||||||
resetActiveMentionQuery,
|
resetActiveMentionQuery,
|
||||||
insertAtCursor,
|
insertAtCursor,
|
||||||
replaceActiveMentionWith,
|
replaceActiveMentionWith,
|
||||||
replaceActiveSlashWith,
|
|
||||||
scrollActiveItemIntoView,
|
scrollActiveItemIntoView,
|
||||||
closeMentionMenu,
|
closeMentionMenu,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function useMentionTokens({
|
|||||||
setSelectedContexts,
|
setSelectedContexts,
|
||||||
}: UseMentionTokensProps) {
|
}: UseMentionTokensProps) {
|
||||||
/**
|
/**
|
||||||
* Computes all mention ranges in the message (both @mentions and /commands)
|
* Computes all mention ranges in the message
|
||||||
*
|
*
|
||||||
* @returns Array of mention ranges sorted by start position
|
* @returns Array of mention ranges sorted by start position
|
||||||
*/
|
*/
|
||||||
@@ -55,19 +55,8 @@ export function useMentionTokens({
|
|||||||
const uniqueLabels = Array.from(new Set(labels))
|
const uniqueLabels = Array.from(new Set(labels))
|
||||||
|
|
||||||
for (const label of uniqueLabels) {
|
for (const label of uniqueLabels) {
|
||||||
// Find matching context to determine if it's a slash command
|
// Space-wrapped token: " @label " (search from start)
|
||||||
const matchingContext = selectedContexts.find((c) => c.label === label)
|
const token = ` @${label} `
|
||||||
const isSlashCommand = matchingContext?.kind === 'slash_command'
|
|
||||||
const prefix = isSlashCommand ? '/' : '@'
|
|
||||||
|
|
||||||
// Check for token at the very start of the message (no leading space)
|
|
||||||
const tokenAtStart = `${prefix}${label} `
|
|
||||||
if (message.startsWith(tokenAtStart)) {
|
|
||||||
ranges.push({ start: 0, end: tokenAtStart.length, label })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Space-wrapped token: " @label " or " /label " (search from start)
|
|
||||||
const token = ` ${prefix}${label} `
|
|
||||||
let fromIndex = 0
|
let fromIndex = 0
|
||||||
while (fromIndex <= message.length) {
|
while (fromIndex <= message.length) {
|
||||||
const idx = message.indexOf(token, fromIndex)
|
const idx = message.indexOf(token, fromIndex)
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
MentionMenu,
|
MentionMenu,
|
||||||
ModelSelector,
|
ModelSelector,
|
||||||
ModeSelector,
|
ModeSelector,
|
||||||
SlashMenu,
|
|
||||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components'
|
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components'
|
||||||
import { NEAR_TOP_THRESHOLD } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
|
import { NEAR_TOP_THRESHOLD } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
|
||||||
import {
|
import {
|
||||||
@@ -68,8 +67,6 @@ interface UserInputProps {
|
|||||||
hideModeSelector?: boolean
|
hideModeSelector?: boolean
|
||||||
/** Disable @mention functionality */
|
/** Disable @mention functionality */
|
||||||
disableMentions?: boolean
|
disableMentions?: boolean
|
||||||
/** Initial contexts for editing a message with existing context mentions */
|
|
||||||
initialContexts?: ChatContext[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserInputRef {
|
interface UserInputRef {
|
||||||
@@ -106,7 +103,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
|||||||
onModelChangeOverride,
|
onModelChangeOverride,
|
||||||
hideModeSelector = false,
|
hideModeSelector = false,
|
||||||
disableMentions = false,
|
disableMentions = false,
|
||||||
initialContexts,
|
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
@@ -127,7 +123,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
|||||||
const [isNearTop, setIsNearTop] = useState(false)
|
const [isNearTop, setIsNearTop] = useState(false)
|
||||||
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null)
|
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null)
|
||||||
const [inputContainerRef, setInputContainerRef] = useState<HTMLDivElement | null>(null)
|
const [inputContainerRef, setInputContainerRef] = useState<HTMLDivElement | null>(null)
|
||||||
const [showSlashMenu, setShowSlashMenu] = useState(false)
|
|
||||||
|
|
||||||
// Controlled vs uncontrolled message state
|
// Controlled vs uncontrolled message state
|
||||||
const message = controlledValue !== undefined ? controlledValue : internalMessage
|
const message = controlledValue !== undefined ? controlledValue : internalMessage
|
||||||
@@ -145,7 +140,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
|||||||
|
|
||||||
// Custom hooks - order matters for ref sharing
|
// Custom hooks - order matters for ref sharing
|
||||||
// Context management (manages selectedContexts state)
|
// Context management (manages selectedContexts state)
|
||||||
const contextManagement = useContextManagement({ message, initialContexts })
|
const contextManagement = useContextManagement({ message })
|
||||||
|
|
||||||
// Mention menu
|
// Mention menu
|
||||||
const mentionMenu = useMentionMenu({
|
const mentionMenu = useMentionMenu({
|
||||||
@@ -375,131 +370,20 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
|||||||
}
|
}
|
||||||
}, [onAbort, isLoading])
|
}, [onAbort, isLoading])
|
||||||
|
|
||||||
const handleSlashCommandSelect = useCallback(
|
|
||||||
(command: string) => {
|
|
||||||
// Capitalize the command for display
|
|
||||||
const capitalizedCommand = command.charAt(0).toUpperCase() + command.slice(1)
|
|
||||||
|
|
||||||
// Replace the active slash query with the capitalized command
|
|
||||||
mentionMenu.replaceActiveSlashWith(capitalizedCommand)
|
|
||||||
|
|
||||||
// Add as a context so it gets highlighted
|
|
||||||
contextManagement.addContext({
|
|
||||||
kind: 'slash_command',
|
|
||||||
command,
|
|
||||||
label: capitalizedCommand,
|
|
||||||
})
|
|
||||||
|
|
||||||
setShowSlashMenu(false)
|
|
||||||
mentionMenu.textareaRef.current?.focus()
|
|
||||||
},
|
|
||||||
[mentionMenu, contextManagement]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
// Escape key handling
|
// Escape key handling
|
||||||
if (e.key === 'Escape' && (mentionMenu.showMentionMenu || showSlashMenu)) {
|
if (e.key === 'Escape' && mentionMenu.showMentionMenu) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (mentionMenu.openSubmenuFor) {
|
if (mentionMenu.openSubmenuFor) {
|
||||||
mentionMenu.setOpenSubmenuFor(null)
|
mentionMenu.setOpenSubmenuFor(null)
|
||||||
mentionMenu.setSubmenuQueryStart(null)
|
mentionMenu.setSubmenuQueryStart(null)
|
||||||
} else {
|
} else {
|
||||||
mentionMenu.closeMentionMenu()
|
mentionMenu.closeMentionMenu()
|
||||||
setShowSlashMenu(false)
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Arrow navigation in slash menu
|
|
||||||
if (showSlashMenu) {
|
|
||||||
const TOP_LEVEL_COMMANDS = ['fast', 'plan', 'debug', 'research', 'deploy', 'superagent']
|
|
||||||
const WEB_COMMANDS = ['search', 'read', 'scrape', 'crawl']
|
|
||||||
const ALL_COMMANDS = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS]
|
|
||||||
|
|
||||||
const caretPos = mentionMenu.getCaretPos()
|
|
||||||
const activeSlash = mentionMenu.getActiveSlashQueryAtPosition(caretPos, message)
|
|
||||||
const query = activeSlash?.query.trim().toLowerCase() || ''
|
|
||||||
const showAggregatedView = query.length > 0
|
|
||||||
|
|
||||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
||||||
e.preventDefault()
|
|
||||||
|
|
||||||
if (mentionMenu.openSubmenuFor === 'Web') {
|
|
||||||
// Navigate in Web submenu
|
|
||||||
const last = WEB_COMMANDS.length - 1
|
|
||||||
mentionMenu.setSubmenuActiveIndex((prev) => {
|
|
||||||
const next =
|
|
||||||
e.key === 'ArrowDown'
|
|
||||||
? prev >= last
|
|
||||||
? 0
|
|
||||||
: prev + 1
|
|
||||||
: prev <= 0
|
|
||||||
? last
|
|
||||||
: prev - 1
|
|
||||||
requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(next))
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
} else if (showAggregatedView) {
|
|
||||||
// Navigate in filtered view
|
|
||||||
const filtered = ALL_COMMANDS.filter((cmd) => cmd.includes(query))
|
|
||||||
const last = Math.max(0, filtered.length - 1)
|
|
||||||
mentionMenu.setSubmenuActiveIndex((prev) => {
|
|
||||||
if (filtered.length === 0) return 0
|
|
||||||
const next =
|
|
||||||
e.key === 'ArrowDown'
|
|
||||||
? prev >= last
|
|
||||||
? 0
|
|
||||||
: prev + 1
|
|
||||||
: prev <= 0
|
|
||||||
? last
|
|
||||||
: prev - 1
|
|
||||||
requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(next))
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
// Navigate in folder view (top-level + Web folder)
|
|
||||||
const totalItems = TOP_LEVEL_COMMANDS.length + 1 // +1 for Web folder
|
|
||||||
const last = totalItems - 1
|
|
||||||
mentionMenu.setMentionActiveIndex((prev) => {
|
|
||||||
const next =
|
|
||||||
e.key === 'ArrowDown'
|
|
||||||
? prev >= last
|
|
||||||
? 0
|
|
||||||
: prev + 1
|
|
||||||
: prev <= 0
|
|
||||||
? last
|
|
||||||
: prev - 1
|
|
||||||
requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(next))
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Arrow right to enter Web submenu
|
|
||||||
if (e.key === 'ArrowRight') {
|
|
||||||
e.preventDefault()
|
|
||||||
if (!showAggregatedView && !mentionMenu.openSubmenuFor) {
|
|
||||||
// Check if Web folder is selected (it's after all top-level commands)
|
|
||||||
if (mentionMenu.mentionActiveIndex === TOP_LEVEL_COMMANDS.length) {
|
|
||||||
mentionMenu.setOpenSubmenuFor('Web')
|
|
||||||
mentionMenu.setSubmenuActiveIndex(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Arrow left to exit submenu
|
|
||||||
if (e.key === 'ArrowLeft') {
|
|
||||||
e.preventDefault()
|
|
||||||
if (mentionMenu.openSubmenuFor) {
|
|
||||||
mentionMenu.setOpenSubmenuFor(null)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Arrow navigation in mention menu
|
// Arrow navigation in mention menu
|
||||||
if (mentionKeyboard.handleArrowNavigation(e)) return
|
if (mentionKeyboard.handleArrowNavigation(e)) return
|
||||||
if (mentionKeyboard.handleArrowRight(e)) return
|
if (mentionKeyboard.handleArrowRight(e)) return
|
||||||
@@ -508,42 +392,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
|||||||
// Enter key handling
|
// Enter key handling
|
||||||
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
|
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (showSlashMenu) {
|
|
||||||
const TOP_LEVEL_COMMANDS = ['fast', 'plan', 'debug', 'research', 'deploy', 'superagent']
|
|
||||||
const WEB_COMMANDS = ['search', 'read', 'scrape', 'crawl']
|
|
||||||
const ALL_COMMANDS = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS]
|
|
||||||
|
|
||||||
const caretPos = mentionMenu.getCaretPos()
|
|
||||||
const activeSlash = mentionMenu.getActiveSlashQueryAtPosition(caretPos, message)
|
|
||||||
const query = activeSlash?.query.trim().toLowerCase() || ''
|
|
||||||
const showAggregatedView = query.length > 0
|
|
||||||
|
|
||||||
if (mentionMenu.openSubmenuFor === 'Web') {
|
|
||||||
// Select from Web submenu
|
|
||||||
const selectedCommand =
|
|
||||||
WEB_COMMANDS[mentionMenu.submenuActiveIndex] || WEB_COMMANDS[0]
|
|
||||||
handleSlashCommandSelect(selectedCommand)
|
|
||||||
} else if (showAggregatedView) {
|
|
||||||
// Select from filtered view
|
|
||||||
const filtered = ALL_COMMANDS.filter((cmd) => cmd.includes(query))
|
|
||||||
if (filtered.length > 0) {
|
|
||||||
const selectedCommand = filtered[mentionMenu.submenuActiveIndex] || filtered[0]
|
|
||||||
handleSlashCommandSelect(selectedCommand)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Folder navigation view
|
|
||||||
const selectedIndex = mentionMenu.mentionActiveIndex
|
|
||||||
if (selectedIndex < TOP_LEVEL_COMMANDS.length) {
|
|
||||||
// Top-level command selected
|
|
||||||
handleSlashCommandSelect(TOP_LEVEL_COMMANDS[selectedIndex])
|
|
||||||
} else if (selectedIndex === TOP_LEVEL_COMMANDS.length) {
|
|
||||||
// Web folder selected - open it
|
|
||||||
mentionMenu.setOpenSubmenuFor('Web')
|
|
||||||
mentionMenu.setSubmenuActiveIndex(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!mentionMenu.showMentionMenu) {
|
if (!mentionMenu.showMentionMenu) {
|
||||||
handleSubmit()
|
handleSubmit()
|
||||||
} else {
|
} else {
|
||||||
@@ -621,15 +469,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[mentionMenu, mentionKeyboard, handleSubmit, message.length, mentionTokensWithContext]
|
||||||
mentionMenu,
|
|
||||||
mentionKeyboard,
|
|
||||||
handleSubmit,
|
|
||||||
handleSlashCommandSelect,
|
|
||||||
message,
|
|
||||||
mentionTokensWithContext,
|
|
||||||
showSlashMenu,
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleInputChange = useCallback(
|
const handleInputChange = useCallback(
|
||||||
@@ -641,14 +481,9 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
|||||||
if (disableMentions) return
|
if (disableMentions) return
|
||||||
|
|
||||||
const caret = e.target.selectionStart ?? newValue.length
|
const caret = e.target.selectionStart ?? newValue.length
|
||||||
|
const active = mentionMenu.getActiveMentionQueryAtPosition(caret, newValue)
|
||||||
|
|
||||||
// Check for @ mention trigger
|
if (active) {
|
||||||
const activeMention = mentionMenu.getActiveMentionQueryAtPosition(caret, newValue)
|
|
||||||
// Check for / slash command trigger
|
|
||||||
const activeSlash = mentionMenu.getActiveSlashQueryAtPosition(caret, newValue)
|
|
||||||
|
|
||||||
if (activeMention) {
|
|
||||||
setShowSlashMenu(false)
|
|
||||||
mentionMenu.setShowMentionMenu(true)
|
mentionMenu.setShowMentionMenu(true)
|
||||||
mentionMenu.setInAggregated(false)
|
mentionMenu.setInAggregated(false)
|
||||||
if (mentionMenu.openSubmenuFor) {
|
if (mentionMenu.openSubmenuFor) {
|
||||||
@@ -657,17 +492,10 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
|||||||
mentionMenu.setMentionActiveIndex(0)
|
mentionMenu.setMentionActiveIndex(0)
|
||||||
mentionMenu.setSubmenuActiveIndex(0)
|
mentionMenu.setSubmenuActiveIndex(0)
|
||||||
}
|
}
|
||||||
} else if (activeSlash) {
|
|
||||||
mentionMenu.setShowMentionMenu(false)
|
|
||||||
mentionMenu.setOpenSubmenuFor(null)
|
|
||||||
mentionMenu.setSubmenuQueryStart(null)
|
|
||||||
setShowSlashMenu(true)
|
|
||||||
mentionMenu.setSubmenuActiveIndex(0)
|
|
||||||
} else {
|
} else {
|
||||||
mentionMenu.setShowMentionMenu(false)
|
mentionMenu.setShowMentionMenu(false)
|
||||||
mentionMenu.setOpenSubmenuFor(null)
|
mentionMenu.setOpenSubmenuFor(null)
|
||||||
mentionMenu.setSubmenuQueryStart(null)
|
mentionMenu.setSubmenuQueryStart(null)
|
||||||
setShowSlashMenu(false)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setMessage, mentionMenu, disableMentions]
|
[setMessage, mentionMenu, disableMentions]
|
||||||
@@ -714,32 +542,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
|||||||
mentionMenu.setSubmenuActiveIndex(0)
|
mentionMenu.setSubmenuActiveIndex(0)
|
||||||
}, [disabled, isLoading, mentionMenu, message, setMessage])
|
}, [disabled, isLoading, mentionMenu, message, setMessage])
|
||||||
|
|
||||||
const handleOpenSlashMenu = useCallback(() => {
|
|
||||||
if (disabled || isLoading) return
|
|
||||||
const textarea = mentionMenu.textareaRef.current
|
|
||||||
if (!textarea) return
|
|
||||||
textarea.focus()
|
|
||||||
const pos = textarea.selectionStart ?? message.length
|
|
||||||
const needsSpaceBefore = pos > 0 && !/\s/.test(message.charAt(pos - 1))
|
|
||||||
|
|
||||||
const insertText = needsSpaceBefore ? ' /' : '/'
|
|
||||||
const start = textarea.selectionStart ?? message.length
|
|
||||||
const end = textarea.selectionEnd ?? message.length
|
|
||||||
const before = message.slice(0, start)
|
|
||||||
const after = message.slice(end)
|
|
||||||
const next = `${before}${insertText}${after}`
|
|
||||||
setMessage(next)
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
const newPos = before.length + insertText.length
|
|
||||||
textarea.setSelectionRange(newPos, newPos)
|
|
||||||
textarea.focus()
|
|
||||||
}, 0)
|
|
||||||
|
|
||||||
setShowSlashMenu(true)
|
|
||||||
mentionMenu.setSubmenuActiveIndex(0)
|
|
||||||
}, [disabled, isLoading, mentionMenu, message, setMessage])
|
|
||||||
|
|
||||||
const canSubmit = message.trim().length > 0 && !disabled && !isLoading
|
const canSubmit = message.trim().length > 0 && !disabled && !isLoading
|
||||||
const showAbortButton = isLoading && onAbort
|
const showAbortButton = isLoading && onAbort
|
||||||
|
|
||||||
@@ -841,20 +643,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
|||||||
<AtSign className='h-3 w-3' strokeWidth={1.75} />
|
<AtSign className='h-3 w-3' strokeWidth={1.75} />
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
<Badge
|
|
||||||
variant='outline'
|
|
||||||
onClick={handleOpenSlashMenu}
|
|
||||||
title='Insert /'
|
|
||||||
className={cn(
|
|
||||||
'cursor-pointer rounded-[6px] p-[4.5px]',
|
|
||||||
(disabled || isLoading) && 'cursor-not-allowed'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className='flex h-3 w-3 items-center justify-center font-medium text-[11px] leading-none'>
|
|
||||||
/
|
|
||||||
</span>
|
|
||||||
</Badge>
|
|
||||||
|
|
||||||
{/* Selected Context Pills */}
|
{/* Selected Context Pills */}
|
||||||
<ContextPills
|
<ContextPills
|
||||||
contexts={contextManagement.selectedContexts}
|
contexts={contextManagement.selectedContexts}
|
||||||
@@ -929,18 +717,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
|||||||
/>,
|
/>,
|
||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Slash Menu Portal */}
|
|
||||||
{!disableMentions &&
|
|
||||||
showSlashMenu &&
|
|
||||||
createPortal(
|
|
||||||
<SlashMenu
|
|
||||||
mentionMenu={mentionMenu}
|
|
||||||
message={message}
|
|
||||||
onSelectCommand={handleSlashCommandSelect}
|
|
||||||
/>,
|
|
||||||
document.body
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom Row: Mode Selector + Model Selector + Attach Button + Send Button */}
|
{/* Bottom Row: Mode Selector + Model Selector + Attach Button + Send Button */}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useCallback, useMemo, useRef } from 'react'
|
import { memo, useMemo, useRef } from 'react'
|
||||||
import { RepeatIcon, SplitIcon } from 'lucide-react'
|
import { RepeatIcon, SplitIcon } from 'lucide-react'
|
||||||
import { Handle, type NodeProps, Position, useReactFlow } from 'reactflow'
|
import { Handle, type NodeProps, Position, useReactFlow } from 'reactflow'
|
||||||
import { Button, Trash } from '@/components/emcn'
|
import { Button, Trash } from '@/components/emcn'
|
||||||
@@ -8,7 +8,6 @@ import { type DiffStatus, hasDiffStatus } from '@/lib/workflows/diff/types'
|
|||||||
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||||
import { usePanelEditorStore } from '@/stores/panel'
|
import { usePanelEditorStore } from '@/stores/panel'
|
||||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global styles for subflow nodes (loop and parallel containers).
|
* Global styles for subflow nodes (loop and parallel containers).
|
||||||
@@ -52,8 +51,6 @@ export interface SubflowNodeData {
|
|||||||
isPreviewSelected?: boolean
|
isPreviewSelected?: boolean
|
||||||
kind: 'loop' | 'parallel'
|
kind: 'loop' | 'parallel'
|
||||||
name?: string
|
name?: string
|
||||||
/** The ID of the group this subflow belongs to */
|
|
||||||
groupId?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,9 +62,8 @@ export interface SubflowNodeData {
|
|||||||
* @returns Rendered subflow node component
|
* @returns Rendered subflow node component
|
||||||
*/
|
*/
|
||||||
export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeData>) => {
|
export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeData>) => {
|
||||||
const { getNodes, setNodes } = useReactFlow()
|
const { getNodes } = useReactFlow()
|
||||||
const { collaborativeBatchRemoveBlocks } = useCollaborativeWorkflow()
|
const { collaborativeBatchRemoveBlocks } = useCollaborativeWorkflow()
|
||||||
const { getGroups } = useWorkflowStore()
|
|
||||||
const blockRef = useRef<HTMLDivElement>(null)
|
const blockRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const currentWorkflow = useCurrentWorkflow()
|
const currentWorkflow = useCurrentWorkflow()
|
||||||
@@ -144,57 +140,10 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
|
|||||||
diffStatus === 'edited' && 'ring-[var(--warning)]'
|
diffStatus === 'edited' && 'ring-[var(--warning)]'
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* Expands selection to include all group members on mouse down.
|
|
||||||
* This ensures that when a user starts dragging a subflow in a group,
|
|
||||||
* all other blocks in the group are also selected and will move together.
|
|
||||||
*/
|
|
||||||
const handleGroupMouseDown = useCallback(
|
|
||||||
(e: React.MouseEvent) => {
|
|
||||||
// Only process left mouse button clicks
|
|
||||||
if (e.button !== 0) return
|
|
||||||
|
|
||||||
const groupId = data.groupId
|
|
||||||
if (!groupId) return
|
|
||||||
|
|
||||||
const groups = getGroups()
|
|
||||||
const group = groups[groupId]
|
|
||||||
if (!group || group.blockIds.length <= 1) return
|
|
||||||
|
|
||||||
const groupBlockIds = new Set(group.blockIds)
|
|
||||||
const allNodes = getNodes()
|
|
||||||
|
|
||||||
// Check if all group members are already selected
|
|
||||||
const allSelected = [...groupBlockIds].every((blockId) =>
|
|
||||||
allNodes.find((n) => n.id === blockId && n.selected)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (allSelected) return
|
|
||||||
|
|
||||||
// Expand selection to include all group members
|
|
||||||
setNodes((nodes) =>
|
|
||||||
nodes.map((n) => {
|
|
||||||
const isInGroup = groupBlockIds.has(n.id)
|
|
||||||
const isThisBlock = n.id === id
|
|
||||||
return {
|
|
||||||
...n,
|
|
||||||
selected: isInGroup ? true : n.selected,
|
|
||||||
data: {
|
|
||||||
...n.data,
|
|
||||||
// Mark as grouped selection if in group but not the directly clicked block
|
|
||||||
isGroupedSelection: isInGroup && !isThisBlock && !n.selected,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
},
|
|
||||||
[id, data.groupId, getNodes, setNodes, getGroups]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SubflowNodeStyles />
|
<SubflowNodeStyles />
|
||||||
<div className='group relative' onMouseDown={handleGroupMouseDown}>
|
<div className='group relative'>
|
||||||
<div
|
<div
|
||||||
ref={blockRef}
|
ref={blockRef}
|
||||||
onClick={() => setCurrentBlockId(id)}
|
onClick={() => setCurrentBlockId(id)}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export const ActionBar = memo(
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'-top-[46px] absolute right-0 z-[100]',
|
'-top-[46px] absolute right-0',
|
||||||
'flex flex-row items-center',
|
'flex flex-row items-center',
|
||||||
'opacity-0 transition-opacity duration-200 group-hover:opacity-100',
|
'opacity-0 transition-opacity duration-200 group-hover:opacity-100',
|
||||||
'gap-[5px] rounded-[10px] bg-[var(--surface-4)] p-[5px]'
|
'gap-[5px] rounded-[10px] bg-[var(--surface-4)] p-[5px]'
|
||||||
|
|||||||
@@ -12,10 +12,6 @@ export interface WorkflowBlockProps {
|
|||||||
isPreview?: boolean
|
isPreview?: boolean
|
||||||
/** Whether this block is selected in preview mode */
|
/** Whether this block is selected in preview mode */
|
||||||
isPreviewSelected?: boolean
|
isPreviewSelected?: boolean
|
||||||
/** Whether this block is selected as part of a group (not directly clicked) */
|
|
||||||
isGroupedSelection?: boolean
|
|
||||||
/** The ID of the group this block belongs to */
|
|
||||||
groupId?: string
|
|
||||||
subBlockValues?: Record<string, any>
|
subBlockValues?: Record<string, any>
|
||||||
blockState?: any
|
blockState?: any
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
import { Handle, type NodeProps, Position, useReactFlow, useUpdateNodeInternals } from 'reactflow'
|
import { Handle, type NodeProps, Position, useUpdateNodeInternals } from 'reactflow'
|
||||||
import { Badge, Tooltip } from '@/components/emcn'
|
import { Badge, Tooltip } from '@/components/emcn'
|
||||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
@@ -915,65 +915,8 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
|||||||
const userPermissions = useUserPermissionsContext()
|
const userPermissions = useUserPermissionsContext()
|
||||||
const isWorkflowSelector = type === 'workflow' || type === 'workflow_input'
|
const isWorkflowSelector = type === 'workflow' || type === 'workflow_input'
|
||||||
|
|
||||||
const isGroupedSelection = data.isGroupedSelection ?? false
|
|
||||||
|
|
||||||
// Get React Flow methods for group selection expansion
|
|
||||||
const { getNodes, setNodes } = useReactFlow()
|
|
||||||
const { getGroups } = useWorkflowStore()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Expands selection to include all group members on mouse down.
|
|
||||||
* This ensures that when a user starts dragging a block in a group,
|
|
||||||
* all other blocks in the group are also selected and will move together.
|
|
||||||
*/
|
|
||||||
const handleGroupMouseDown = useCallback(
|
|
||||||
(e: React.MouseEvent) => {
|
|
||||||
// Only process left mouse button clicks
|
|
||||||
if (e.button !== 0) return
|
|
||||||
|
|
||||||
const groupId = data.groupId
|
|
||||||
if (!groupId) return
|
|
||||||
|
|
||||||
const groups = getGroups()
|
|
||||||
const group = groups[groupId]
|
|
||||||
if (!group || group.blockIds.length <= 1) return
|
|
||||||
|
|
||||||
const groupBlockIds = new Set(group.blockIds)
|
|
||||||
const allNodes = getNodes()
|
|
||||||
|
|
||||||
// Check if all group members are already selected
|
|
||||||
const allSelected = [...groupBlockIds].every((blockId) =>
|
|
||||||
allNodes.find((n) => n.id === blockId && n.selected)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (allSelected) return
|
|
||||||
|
|
||||||
// Expand selection to include all group members
|
|
||||||
setNodes((nodes) =>
|
|
||||||
nodes.map((n) => {
|
|
||||||
const isInGroup = groupBlockIds.has(n.id)
|
|
||||||
const isThisBlock = n.id === id
|
|
||||||
return {
|
|
||||||
...n,
|
|
||||||
selected: isInGroup ? true : n.selected,
|
|
||||||
data: {
|
|
||||||
...n.data,
|
|
||||||
// Mark as grouped selection if in group but not the directly clicked block
|
|
||||||
isGroupedSelection: isInGroup && !isThisBlock && !n.selected,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
},
|
|
||||||
[id, data.groupId, getNodes, setNodes, getGroups]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className='group relative'>
|
||||||
className='group relative'
|
|
||||||
data-grouped-selection={isGroupedSelection ? 'true' : undefined}
|
|
||||||
onMouseDown={handleGroupMouseDown}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ interface UseBlockVisualProps {
|
|||||||
export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVisualProps) {
|
export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVisualProps) {
|
||||||
const isPreview = data.isPreview ?? false
|
const isPreview = data.isPreview ?? false
|
||||||
const isPreviewSelected = data.isPreviewSelected ?? false
|
const isPreviewSelected = data.isPreviewSelected ?? false
|
||||||
const isGroupedSelection = data.isGroupedSelection ?? false
|
|
||||||
|
|
||||||
const currentWorkflow = useCurrentWorkflow()
|
const currentWorkflow = useCurrentWorkflow()
|
||||||
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
|
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
|
||||||
@@ -65,18 +64,8 @@ export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVis
|
|||||||
diffStatus: isPreview ? undefined : diffStatus,
|
diffStatus: isPreview ? undefined : diffStatus,
|
||||||
runPathStatus,
|
runPathStatus,
|
||||||
isPreviewSelection: isPreview && isPreviewSelected,
|
isPreviewSelection: isPreview && isPreviewSelected,
|
||||||
isGroupedSelection: !isPreview && isGroupedSelection,
|
|
||||||
}),
|
}),
|
||||||
[
|
[isActive, isPending, isDeletedBlock, diffStatus, runPathStatus, isPreview, isPreviewSelected]
|
||||||
isActive,
|
|
||||||
isPending,
|
|
||||||
isDeletedBlock,
|
|
||||||
diffStatus,
|
|
||||||
runPathStatus,
|
|
||||||
isPreview,
|
|
||||||
isPreviewSelected,
|
|
||||||
isGroupedSelection,
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ export function useCanvasContextMenu({ blocks, getNodes }: UseCanvasContextMenuP
|
|||||||
const block = blocks[n.id]
|
const block = blocks[n.id]
|
||||||
const parentId = block?.data?.parentId
|
const parentId = block?.data?.parentId
|
||||||
const parentType = parentId ? blocks[parentId]?.type : undefined
|
const parentType = parentId ? blocks[parentId]?.type : undefined
|
||||||
const groupId = block?.data?.groupId
|
|
||||||
return {
|
return {
|
||||||
id: n.id,
|
id: n.id,
|
||||||
type: block?.type || '',
|
type: block?.type || '',
|
||||||
@@ -43,7 +42,6 @@ export function useCanvasContextMenu({ blocks, getNodes }: UseCanvasContextMenuP
|
|||||||
horizontalHandles: block?.horizontalHandles ?? false,
|
horizontalHandles: block?.horizontalHandles ?? false,
|
||||||
parentId,
|
parentId,
|
||||||
parentType,
|
parentType,
|
||||||
groupId,
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
[blocks]
|
[blocks]
|
||||||
@@ -51,22 +49,14 @@ export function useCanvasContextMenu({ blocks, getNodes }: UseCanvasContextMenuP
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle right-click on a node (block)
|
* Handle right-click on a node (block)
|
||||||
* If the node is part of a multiselection, include all selected nodes.
|
|
||||||
* If the node is not selected, just use that node.
|
|
||||||
*/
|
*/
|
||||||
const handleNodeContextMenu = useCallback(
|
const handleNodeContextMenu = useCallback(
|
||||||
(event: React.MouseEvent, node: Node) => {
|
(event: React.MouseEvent, node: Node) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
|
||||||
// Get all currently selected nodes
|
const selectedNodes = getNodes().filter((n) => n.selected)
|
||||||
const allNodes = getNodes()
|
const nodesToUse = selectedNodes.some((n) => n.id === node.id) ? selectedNodes : [node]
|
||||||
const selectedNodes = allNodes.filter((n) => n.selected)
|
|
||||||
|
|
||||||
// If the right-clicked node is already selected, use all selected nodes
|
|
||||||
// Otherwise, just use the right-clicked node
|
|
||||||
const isNodeSelected = selectedNodes.some((n) => n.id === node.id)
|
|
||||||
const nodesToUse = isNodeSelected && selectedNodes.length > 0 ? selectedNodes : [node]
|
|
||||||
|
|
||||||
setPosition({ x: event.clientX, y: event.clientY })
|
setPosition({ x: event.clientX, y: event.clientY })
|
||||||
setSelectedBlocks(nodesToBlockInfos(nodesToUse))
|
setSelectedBlocks(nodesToBlockInfos(nodesToUse))
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export interface BlockRingOptions {
|
|||||||
diffStatus: BlockDiffStatus
|
diffStatus: BlockDiffStatus
|
||||||
runPathStatus: BlockRunPathStatus
|
runPathStatus: BlockRunPathStatus
|
||||||
isPreviewSelection?: boolean
|
isPreviewSelection?: boolean
|
||||||
isGroupedSelection?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,15 +21,8 @@ export function getBlockRingStyles(options: BlockRingOptions): {
|
|||||||
hasRing: boolean
|
hasRing: boolean
|
||||||
ringClassName: string
|
ringClassName: string
|
||||||
} {
|
} {
|
||||||
const {
|
const { isActive, isPending, isDeletedBlock, diffStatus, runPathStatus, isPreviewSelection } =
|
||||||
isActive,
|
options
|
||||||
isPending,
|
|
||||||
isDeletedBlock,
|
|
||||||
diffStatus,
|
|
||||||
runPathStatus,
|
|
||||||
isPreviewSelection,
|
|
||||||
isGroupedSelection,
|
|
||||||
} = options
|
|
||||||
|
|
||||||
const hasRing =
|
const hasRing =
|
||||||
isActive ||
|
isActive ||
|
||||||
@@ -38,24 +30,17 @@ export function getBlockRingStyles(options: BlockRingOptions): {
|
|||||||
diffStatus === 'new' ||
|
diffStatus === 'new' ||
|
||||||
diffStatus === 'edited' ||
|
diffStatus === 'edited' ||
|
||||||
isDeletedBlock ||
|
isDeletedBlock ||
|
||||||
!!runPathStatus ||
|
!!runPathStatus
|
||||||
!!isGroupedSelection
|
|
||||||
|
|
||||||
const ringClassName = cn(
|
const ringClassName = cn(
|
||||||
// Grouped selection: more transparent ring for blocks selected as part of a group
|
|
||||||
// Using rgba with the brand-secondary color (#33b4ff) at 40% opacity
|
|
||||||
isGroupedSelection &&
|
|
||||||
!isActive &&
|
|
||||||
'ring-[2px] ring-[rgba(51,180,255,0.4)]',
|
|
||||||
// Preview selection: static blue ring (standard thickness, no animation)
|
// Preview selection: static blue ring (standard thickness, no animation)
|
||||||
isActive && isPreviewSelection && 'ring-[1.75px] ring-[var(--brand-secondary)]',
|
isActive && isPreviewSelection && 'ring-[1.75px] ring-[var(--brand-secondary)]',
|
||||||
// Executing block: pulsing success ring with prominent thickness
|
// Executing block: pulsing success ring with prominent thickness
|
||||||
isActive &&
|
isActive &&
|
||||||
!isPreviewSelection &&
|
!isPreviewSelection &&
|
||||||
!isGroupedSelection &&
|
|
||||||
'ring-[3.5px] ring-[var(--border-success)] animate-ring-pulse',
|
'ring-[3.5px] ring-[var(--border-success)] animate-ring-pulse',
|
||||||
// Non-active states use standard ring utilities (except grouped selection which has its own)
|
// Non-active states use standard ring utilities
|
||||||
!isActive && hasRing && !isGroupedSelection && 'ring-[1.75px]',
|
!isActive && hasRing && 'ring-[1.75px]',
|
||||||
// Pending state: warning ring
|
// Pending state: warning ring
|
||||||
!isActive && isPending && 'ring-[var(--warning)]',
|
!isActive && isPending && 'ring-[var(--warning)]',
|
||||||
// Deleted state (highest priority after active/pending)
|
// Deleted state (highest priority after active/pending)
|
||||||
|
|||||||
@@ -264,14 +264,12 @@ const WorkflowContent = React.memo(() => {
|
|||||||
const canUndo = undoRedoStack.undo.length > 0
|
const canUndo = undoRedoStack.undo.length > 0
|
||||||
const canRedo = undoRedoStack.redo.length > 0
|
const canRedo = undoRedoStack.redo.length > 0
|
||||||
|
|
||||||
const { updateNodeDimensions, setDragStartPosition, getDragStartPosition, getGroups } =
|
const { updateNodeDimensions, setDragStartPosition, getDragStartPosition } = useWorkflowStore(
|
||||||
useWorkflowStore(
|
useShallow((state) => ({
|
||||||
useShallow((state) => ({
|
updateNodeDimensions: state.updateNodeDimensions,
|
||||||
updateNodeDimensions: state.updateNodeDimensions,
|
setDragStartPosition: state.setDragStartPosition,
|
||||||
setDragStartPosition: state.setDragStartPosition,
|
getDragStartPosition: state.getDragStartPosition,
|
||||||
getDragStartPosition: state.getDragStartPosition,
|
}))
|
||||||
getGroups: state.getGroups,
|
|
||||||
}))
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const copilotCleanup = useCopilotStore((state) => state.cleanup)
|
const copilotCleanup = useCopilotStore((state) => state.cleanup)
|
||||||
@@ -363,14 +361,6 @@ const WorkflowContent = React.memo(() => {
|
|||||||
new Map()
|
new Map()
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* Stores original positions and parentIds for nodes temporarily parented during group drag.
|
|
||||||
* Key: node ID, Value: { originalPosition, originalParentId }
|
|
||||||
*/
|
|
||||||
const groupDragTempParentsRef = useRef<
|
|
||||||
Map<string, { originalPosition: { x: number; y: number }; originalParentId?: string }>
|
|
||||||
>(new Map())
|
|
||||||
|
|
||||||
/** Stores node IDs to select on next derivedNodes sync (for paste/duplicate operations). */
|
/** Stores node IDs to select on next derivedNodes sync (for paste/duplicate operations). */
|
||||||
const pendingSelectionRef = useRef<Set<string> | null>(null)
|
const pendingSelectionRef = useRef<Set<string> | null>(null)
|
||||||
|
|
||||||
@@ -468,8 +458,6 @@ const WorkflowContent = React.memo(() => {
|
|||||||
collaborativeBatchRemoveBlocks,
|
collaborativeBatchRemoveBlocks,
|
||||||
collaborativeBatchToggleBlockEnabled,
|
collaborativeBatchToggleBlockEnabled,
|
||||||
collaborativeBatchToggleBlockHandles,
|
collaborativeBatchToggleBlockHandles,
|
||||||
collaborativeGroupBlocks,
|
|
||||||
collaborativeUngroupBlocks,
|
|
||||||
undo,
|
undo,
|
||||||
redo,
|
redo,
|
||||||
} = useCollaborativeWorkflow()
|
} = useCollaborativeWorkflow()
|
||||||
@@ -794,35 +782,6 @@ const WorkflowContent = React.memo(() => {
|
|||||||
collaborativeBatchToggleBlockHandles(blockIds)
|
collaborativeBatchToggleBlockHandles(blockIds)
|
||||||
}, [contextMenuBlocks, collaborativeBatchToggleBlockHandles])
|
}, [contextMenuBlocks, collaborativeBatchToggleBlockHandles])
|
||||||
|
|
||||||
const handleContextGroupBlocks = useCallback(() => {
|
|
||||||
const blockIds = contextMenuBlocks.map((block) => block.id)
|
|
||||||
if (blockIds.length >= 2) {
|
|
||||||
// Validate that all blocks share the same parent (or all have no parent)
|
|
||||||
// Blocks inside a subflow cannot be grouped with blocks outside that subflow
|
|
||||||
const parentIds = contextMenuBlocks.map((block) => block.parentId || null)
|
|
||||||
const uniqueParentIds = new Set(parentIds)
|
|
||||||
if (uniqueParentIds.size > 1) {
|
|
||||||
addNotification({
|
|
||||||
level: 'error',
|
|
||||||
message: 'Cannot group blocks from different subflows',
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
collaborativeGroupBlocks(blockIds)
|
|
||||||
}
|
|
||||||
}, [contextMenuBlocks, collaborativeGroupBlocks, addNotification])
|
|
||||||
|
|
||||||
const handleContextUngroupBlocks = useCallback(() => {
|
|
||||||
// Find the first block with a groupId
|
|
||||||
const groupedBlock = contextMenuBlocks.find((block) => block.groupId)
|
|
||||||
if (!groupedBlock?.groupId) return
|
|
||||||
|
|
||||||
// The block's groupId is the group we want to ungroup
|
|
||||||
// This is the direct group the block belongs to, which is the "top level" from the user's perspective
|
|
||||||
// (the most recently created group that contains this block)
|
|
||||||
collaborativeUngroupBlocks(groupedBlock.groupId)
|
|
||||||
}, [contextMenuBlocks, collaborativeUngroupBlocks])
|
|
||||||
|
|
||||||
const handleContextRemoveFromSubflow = useCallback(() => {
|
const handleContextRemoveFromSubflow = useCallback(() => {
|
||||||
const blocksToRemove = contextMenuBlocks.filter(
|
const blocksToRemove = contextMenuBlocks.filter(
|
||||||
(block) => block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel')
|
(block) => block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel')
|
||||||
@@ -1947,7 +1906,6 @@ const WorkflowContent = React.memo(() => {
|
|||||||
name: block.name,
|
name: block.name,
|
||||||
isActive,
|
isActive,
|
||||||
isPending,
|
isPending,
|
||||||
groupId: block.data?.groupId,
|
|
||||||
},
|
},
|
||||||
// Include dynamic dimensions for container resizing calculations (must match rendered size)
|
// Include dynamic dimensions for container resizing calculations (must match rendered size)
|
||||||
// Both note and workflow blocks calculate dimensions deterministically via useBlockDimensions
|
// Both note and workflow blocks calculate dimensions deterministically via useBlockDimensions
|
||||||
@@ -2102,56 +2060,16 @@ const WorkflowContent = React.memo(() => {
|
|||||||
window.removeEventListener('remove-from-subflow', handleRemoveFromSubflow as EventListener)
|
window.removeEventListener('remove-from-subflow', handleRemoveFromSubflow as EventListener)
|
||||||
}, [blocks, edgesForDisplay, getNodeAbsolutePosition, collaborativeBatchUpdateParent])
|
}, [blocks, edgesForDisplay, getNodeAbsolutePosition, collaborativeBatchUpdateParent])
|
||||||
|
|
||||||
/** Handles node changes - applies changes and resolves parent-child selection conflicts.
|
/** Handles node changes - applies changes and resolves parent-child selection conflicts. */
|
||||||
* Also expands selection to include all group members when a grouped block is selected.
|
|
||||||
*/
|
|
||||||
const onNodesChange = useCallback(
|
const onNodesChange = useCallback(
|
||||||
(changes: NodeChange[]) => {
|
(changes: NodeChange[]) => {
|
||||||
setDisplayNodes((nds) => {
|
setDisplayNodes((nds) => {
|
||||||
let updated = applyNodeChanges(changes, nds)
|
const updated = applyNodeChanges(changes, nds)
|
||||||
const hasSelectionChange = changes.some((c) => c.type === 'select')
|
const hasSelectionChange = changes.some((c) => c.type === 'select')
|
||||||
|
return hasSelectionChange ? resolveParentChildSelectionConflicts(updated, blocks) : updated
|
||||||
if (hasSelectionChange) {
|
|
||||||
// Expand selection to include all group members
|
|
||||||
const groups = getGroups()
|
|
||||||
const selectedNodeIds = new Set(updated.filter((n) => n.selected).map((n) => n.id))
|
|
||||||
const groupsToInclude = new Set<string>()
|
|
||||||
|
|
||||||
// Find all groups that have at least one selected member
|
|
||||||
selectedNodeIds.forEach((nodeId) => {
|
|
||||||
const groupId = blocks[nodeId]?.data?.groupId
|
|
||||||
if (groupId && groups[groupId]) {
|
|
||||||
groupsToInclude.add(groupId)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Add all blocks from those groups to the selection
|
|
||||||
if (groupsToInclude.size > 0) {
|
|
||||||
const expandedNodeIds = new Set(selectedNodeIds)
|
|
||||||
groupsToInclude.forEach((groupId) => {
|
|
||||||
const group = groups[groupId]
|
|
||||||
if (group) {
|
|
||||||
group.blockIds.forEach((blockId) => expandedNodeIds.add(blockId))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Update nodes to include expanded selection
|
|
||||||
if (expandedNodeIds.size > selectedNodeIds.size) {
|
|
||||||
updated = updated.map((n) => ({
|
|
||||||
...n,
|
|
||||||
selected: expandedNodeIds.has(n.id) ? true : n.selected,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve parent-child conflicts
|
|
||||||
updated = resolveParentChildSelectionConflicts(updated, blocks)
|
|
||||||
}
|
|
||||||
|
|
||||||
return updated
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[blocks, getGroups]
|
[blocks]
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2612,55 +2530,9 @@ const WorkflowContent = React.memo(() => {
|
|||||||
parentId: currentParentId,
|
parentId: currentParentId,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Expand selection to include all group members before capturing positions
|
|
||||||
const groups = getGroups()
|
|
||||||
const allNodes = getNodes()
|
|
||||||
|
|
||||||
// Find the group of the dragged node
|
|
||||||
const draggedBlockGroupId = blocks[node.id]?.data?.groupId
|
|
||||||
|
|
||||||
// If the dragged node is in a group, expand selection to include all group members
|
|
||||||
if (draggedBlockGroupId && groups[draggedBlockGroupId]) {
|
|
||||||
const group = groups[draggedBlockGroupId]
|
|
||||||
const groupBlockIds = new Set(group.blockIds)
|
|
||||||
|
|
||||||
// Check if we need to expand selection
|
|
||||||
const currentSelectedIds = new Set(allNodes.filter((n) => n.selected).map((n) => n.id))
|
|
||||||
const needsExpansion = [...groupBlockIds].some((id) => !currentSelectedIds.has(id))
|
|
||||||
|
|
||||||
if (needsExpansion) {
|
|
||||||
setNodes((nodes) =>
|
|
||||||
nodes.map((n) => {
|
|
||||||
const isInGroup = groupBlockIds.has(n.id)
|
|
||||||
const isDirectlyDragged = n.id === node.id
|
|
||||||
return {
|
|
||||||
...n,
|
|
||||||
selected: isInGroup ? true : n.selected,
|
|
||||||
data: {
|
|
||||||
...n.data,
|
|
||||||
// Mark as grouped selection if in group but not the directly dragged node
|
|
||||||
isGroupedSelection: isInGroup && !isDirectlyDragged && !n.selected,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capture all selected nodes' positions for multi-node undo/redo
|
// Capture all selected nodes' positions for multi-node undo/redo
|
||||||
// Re-get nodes after potential selection expansion
|
const allNodes = getNodes()
|
||||||
const updatedNodes = getNodes()
|
const selectedNodes = allNodes.filter((n) => n.selected)
|
||||||
const selectedNodes = updatedNodes.filter((n) => {
|
|
||||||
// Always include the dragged node
|
|
||||||
if (n.id === node.id) return true
|
|
||||||
// Include node if it's selected OR if it's in the same group as the dragged node
|
|
||||||
if (n.selected) return true
|
|
||||||
if (draggedBlockGroupId && groups[draggedBlockGroupId]) {
|
|
||||||
return groups[draggedBlockGroupId].blockIds.includes(n.id)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
|
|
||||||
multiNodeDragStartRef.current.clear()
|
multiNodeDragStartRef.current.clear()
|
||||||
selectedNodes.forEach((n) => {
|
selectedNodes.forEach((n) => {
|
||||||
const block = blocks[n.id]
|
const block = blocks[n.id]
|
||||||
@@ -2672,63 +2544,8 @@ const WorkflowContent = React.memo(() => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Set up temporary parent-child relationships for group members
|
|
||||||
// This leverages React Flow's built-in parent-child drag behavior
|
|
||||||
// BUT: Only do this if NOT all group members are already selected
|
|
||||||
// If all are selected, React Flow's native multiselect drag will handle it
|
|
||||||
groupDragTempParentsRef.current.clear()
|
|
||||||
if (draggedBlockGroupId && groups[draggedBlockGroupId]) {
|
|
||||||
const group = groups[draggedBlockGroupId]
|
|
||||||
if (group.blockIds.length > 1) {
|
|
||||||
// Check if all group members are already selected
|
|
||||||
const allGroupMembersSelected = group.blockIds.every((blockId) =>
|
|
||||||
updatedNodes.find((n) => n.id === blockId && n.selected)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Only use temporary parent approach if NOT all members are selected
|
|
||||||
// (i.e., when click-and-dragging on an unselected grouped block)
|
|
||||||
if (!allGroupMembersSelected) {
|
|
||||||
// Get the dragged node's absolute position for calculating relative positions
|
|
||||||
const draggedNodeAbsPos = getNodeAbsolutePosition(node.id)
|
|
||||||
|
|
||||||
setNodes((nodes) =>
|
|
||||||
nodes.map((n) => {
|
|
||||||
// Skip the dragged node - it becomes the temporary parent
|
|
||||||
if (n.id === node.id) return n
|
|
||||||
|
|
||||||
// Only process nodes in the same group
|
|
||||||
if (group.blockIds.includes(n.id)) {
|
|
||||||
// Store original position and parentId for restoration later
|
|
||||||
groupDragTempParentsRef.current.set(n.id, {
|
|
||||||
originalPosition: { ...n.position },
|
|
||||||
originalParentId: n.parentId,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Get this node's absolute position
|
|
||||||
const nodeAbsPos = getNodeAbsolutePosition(n.id)
|
|
||||||
|
|
||||||
// Calculate position relative to the dragged node
|
|
||||||
const relativePosition = {
|
|
||||||
x: nodeAbsPos.x - draggedNodeAbsPos.x,
|
|
||||||
y: nodeAbsPos.y - draggedNodeAbsPos.y,
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...n,
|
|
||||||
parentId: node.id, // Temporarily make this a child of the dragged node
|
|
||||||
position: relativePosition,
|
|
||||||
extent: undefined, // Remove extent constraint during drag
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[blocks, setDragStartPosition, getNodes, setNodes, getGroups, potentialParentId, setPotentialParentId, getNodeAbsolutePosition]
|
[blocks, setDragStartPosition, getNodes, potentialParentId, setPotentialParentId]
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Handles node drag stop to establish parent-child relationships. */
|
/** Handles node drag stop to establish parent-child relationships. */
|
||||||
@@ -2736,93 +2553,13 @@ const WorkflowContent = React.memo(() => {
|
|||||||
(_event: React.MouseEvent, node: any) => {
|
(_event: React.MouseEvent, node: any) => {
|
||||||
clearDragHighlights()
|
clearDragHighlights()
|
||||||
|
|
||||||
// Compute absolute positions for group members before restoring parentIds
|
|
||||||
// We need to do this first because getNodes() will return stale data after setNodes
|
|
||||||
const computedGroupPositions = new Map<string, { x: number; y: number }>()
|
|
||||||
const draggedBlockGroupId = blocks[node.id]?.data?.groupId
|
|
||||||
|
|
||||||
if (groupDragTempParentsRef.current.size > 0) {
|
|
||||||
const draggedNodeAbsPos = getNodeAbsolutePosition(node.id)
|
|
||||||
const currentNodes = getNodes()
|
|
||||||
|
|
||||||
// Compute absolute positions for all temporarily parented nodes
|
|
||||||
for (const [nodeId, _tempData] of groupDragTempParentsRef.current) {
|
|
||||||
const nodeData = currentNodes.find((n) => n.id === nodeId)
|
|
||||||
if (nodeData) {
|
|
||||||
// The node's current position is relative to the dragged node
|
|
||||||
computedGroupPositions.set(nodeId, {
|
|
||||||
x: draggedNodeAbsPos.x + nodeData.position.x,
|
|
||||||
y: draggedNodeAbsPos.y + nodeData.position.y,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also store the dragged node's absolute position
|
|
||||||
computedGroupPositions.set(node.id, draggedNodeAbsPos)
|
|
||||||
|
|
||||||
// Restore temporary parent-child relationships
|
|
||||||
setNodes((nodes) =>
|
|
||||||
nodes.map((n) => {
|
|
||||||
const tempData = groupDragTempParentsRef.current.get(n.id)
|
|
||||||
if (tempData) {
|
|
||||||
const absolutePosition = computedGroupPositions.get(n.id) || n.position
|
|
||||||
|
|
||||||
return {
|
|
||||||
...n,
|
|
||||||
parentId: tempData.originalParentId,
|
|
||||||
position: absolutePosition,
|
|
||||||
extent: tempData.originalParentId ? ('parent' as const) : undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
})
|
|
||||||
)
|
|
||||||
groupDragTempParentsRef.current.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all selected nodes to update their positions too
|
// Get all selected nodes to update their positions too
|
||||||
const allNodes = getNodes()
|
const allNodes = getNodes()
|
||||||
let selectedNodes = allNodes.filter((n) => n.selected)
|
const selectedNodes = allNodes.filter((n) => n.selected)
|
||||||
|
|
||||||
// If the dragged node is in a group, include all group members
|
// If multiple nodes are selected, update all their positions
|
||||||
if (draggedBlockGroupId) {
|
|
||||||
const groups = getGroups()
|
|
||||||
const group = groups[draggedBlockGroupId]
|
|
||||||
if (group && group.blockIds.length > 1) {
|
|
||||||
const groupBlockIds = new Set(group.blockIds)
|
|
||||||
// Include the dragged node and all group members that aren't already selected
|
|
||||||
const groupNodes = allNodes.filter(
|
|
||||||
(n) => groupBlockIds.has(n.id) && !selectedNodes.some((sn) => sn.id === n.id)
|
|
||||||
)
|
|
||||||
selectedNodes = [...selectedNodes, ...groupNodes]
|
|
||||||
// Also ensure the dragged node is included
|
|
||||||
if (!selectedNodes.some((n) => n.id === node.id)) {
|
|
||||||
const draggedNode = allNodes.find((n) => n.id === node.id)
|
|
||||||
if (draggedNode) {
|
|
||||||
selectedNodes = [...selectedNodes, draggedNode]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If multiple nodes are selected (or in a group), update all their positions
|
|
||||||
if (selectedNodes.length > 1) {
|
if (selectedNodes.length > 1) {
|
||||||
// Use pre-computed positions for group members, otherwise use computeClampedPositionUpdates
|
const positionUpdates = computeClampedPositionUpdates(selectedNodes, blocks, allNodes)
|
||||||
let positionUpdates: Array<{ id: string; position: { x: number; y: number } }>
|
|
||||||
|
|
||||||
if (computedGroupPositions.size > 0) {
|
|
||||||
// For group drags, use the pre-computed absolute positions
|
|
||||||
positionUpdates = selectedNodes.map((n) => {
|
|
||||||
const precomputedPos = computedGroupPositions.get(n.id)
|
|
||||||
if (precomputedPos) {
|
|
||||||
return { id: n.id, position: precomputedPos }
|
|
||||||
}
|
|
||||||
// For non-group members, use current position
|
|
||||||
return { id: n.id, position: n.position }
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
positionUpdates = computeClampedPositionUpdates(selectedNodes, blocks, allNodes)
|
|
||||||
}
|
|
||||||
collaborativeBatchUpdatePositions(positionUpdates, {
|
collaborativeBatchUpdatePositions(positionUpdates, {
|
||||||
previousPositions: multiNodeDragStartRef.current,
|
previousPositions: multiNodeDragStartRef.current,
|
||||||
})
|
})
|
||||||
@@ -3106,7 +2843,6 @@ const WorkflowContent = React.memo(() => {
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
getNodes,
|
getNodes,
|
||||||
setNodes,
|
|
||||||
dragStartParentId,
|
dragStartParentId,
|
||||||
potentialParentId,
|
potentialParentId,
|
||||||
updateNodeParent,
|
updateNodeParent,
|
||||||
@@ -3125,7 +2861,6 @@ const WorkflowContent = React.memo(() => {
|
|||||||
activeWorkflowId,
|
activeWorkflowId,
|
||||||
collaborativeBatchUpdatePositions,
|
collaborativeBatchUpdatePositions,
|
||||||
collaborativeBatchUpdateParent,
|
collaborativeBatchUpdateParent,
|
||||||
getGroups,
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -3433,81 +3168,19 @@ const WorkflowContent = React.memo(() => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles node click to select the node in ReactFlow.
|
* Handles node click to select the node in ReactFlow.
|
||||||
* When clicking on a grouped block, also selects all other blocks in the group.
|
|
||||||
* Grouped blocks are marked with isGroupedSelection for different visual styling.
|
|
||||||
* Parent-child conflict resolution happens automatically in onNodesChange.
|
* Parent-child conflict resolution happens automatically in onNodesChange.
|
||||||
*/
|
*/
|
||||||
const handleNodeClick = useCallback(
|
const handleNodeClick = useCallback(
|
||||||
(event: React.MouseEvent, node: Node) => {
|
(event: React.MouseEvent, node: Node) => {
|
||||||
const isMultiSelect = event.shiftKey || event.metaKey || event.ctrlKey
|
const isMultiSelect = event.shiftKey || event.metaKey || event.ctrlKey
|
||||||
const groups = getGroups()
|
setNodes((nodes) =>
|
||||||
|
nodes.map((n) => ({
|
||||||
// Track which nodes are directly clicked vs. group-expanded
|
...n,
|
||||||
const directlySelectedIds = new Set<string>()
|
selected: isMultiSelect ? (n.id === node.id ? true : n.selected) : n.id === node.id,
|
||||||
|
}))
|
||||||
setNodes((nodes) => {
|
)
|
||||||
// First, calculate the base selection
|
|
||||||
let updatedNodes = nodes.map((n) => {
|
|
||||||
const isDirectlySelected = isMultiSelect
|
|
||||||
? n.id === node.id
|
|
||||||
? true
|
|
||||||
: n.selected
|
|
||||||
: n.id === node.id
|
|
||||||
if (isDirectlySelected) {
|
|
||||||
directlySelectedIds.add(n.id)
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...n,
|
|
||||||
selected: isDirectlySelected,
|
|
||||||
data: {
|
|
||||||
...n.data,
|
|
||||||
isGroupedSelection: false, // Reset grouped selection flag
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Expand selection to include all group members
|
|
||||||
const selectedNodeIds = new Set(updatedNodes.filter((n) => n.selected).map((n) => n.id))
|
|
||||||
const groupsToInclude = new Set<string>()
|
|
||||||
|
|
||||||
// Find all groups that have at least one selected member
|
|
||||||
selectedNodeIds.forEach((nodeId) => {
|
|
||||||
const groupId = blocks[nodeId]?.data?.groupId
|
|
||||||
if (groupId && groups[groupId]) {
|
|
||||||
groupsToInclude.add(groupId)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Add all blocks from those groups to the selection
|
|
||||||
if (groupsToInclude.size > 0) {
|
|
||||||
const expandedNodeIds = new Set(selectedNodeIds)
|
|
||||||
groupsToInclude.forEach((groupId) => {
|
|
||||||
const group = groups[groupId]
|
|
||||||
if (group) {
|
|
||||||
group.blockIds.forEach((blockId) => expandedNodeIds.add(blockId))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Update nodes with expanded selection, marking group-expanded nodes
|
|
||||||
if (expandedNodeIds.size > selectedNodeIds.size) {
|
|
||||||
updatedNodes = updatedNodes.map((n) => {
|
|
||||||
const isGroupExpanded = expandedNodeIds.has(n.id) && !directlySelectedIds.has(n.id)
|
|
||||||
return {
|
|
||||||
...n,
|
|
||||||
selected: expandedNodeIds.has(n.id) ? true : n.selected,
|
|
||||||
data: {
|
|
||||||
...n.data,
|
|
||||||
isGroupedSelection: isGroupExpanded,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return updatedNodes
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
[setNodes, blocks, getGroups]
|
[setNodes]
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Handles edge selection with container context tracking and Shift-click multi-selection. */
|
/** Handles edge selection with container context tracking and Shift-click multi-selection. */
|
||||||
@@ -3742,8 +3415,6 @@ const WorkflowContent = React.memo(() => {
|
|||||||
onRemoveFromSubflow={handleContextRemoveFromSubflow}
|
onRemoveFromSubflow={handleContextRemoveFromSubflow}
|
||||||
onOpenEditor={handleContextOpenEditor}
|
onOpenEditor={handleContextOpenEditor}
|
||||||
onRename={handleContextRename}
|
onRename={handleContextRename}
|
||||||
onGroupBlocks={handleContextGroupBlocks}
|
|
||||||
onUngroupBlocks={handleContextUngroupBlocks}
|
|
||||||
hasClipboard={hasClipboard()}
|
hasClipboard={hasClipboard()}
|
||||||
showRemoveFromSubflow={contextMenuBlocks.some(
|
showRemoveFromSubflow={contextMenuBlocks.some(
|
||||||
(b) => b.parentId && (b.parentType === 'loop' || b.parentType === 'parallel')
|
(b) => b.parentId && (b.parentType === 'loop' || b.parentType === 'parallel')
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ export const ScheduleBlock: BlockConfig = {
|
|||||||
{ label: 'Melbourne (UTC+10)', id: 'Australia/Melbourne' },
|
{ label: 'Melbourne (UTC+10)', id: 'Australia/Melbourne' },
|
||||||
{ label: 'Auckland (UTC+12)', id: 'Pacific/Auckland' },
|
{ label: 'Auckland (UTC+12)', id: 'Pacific/Auckland' },
|
||||||
],
|
],
|
||||||
value: () => Intl.DateTimeFormat().resolvedOptions().timeZone,
|
value: () => 'UTC',
|
||||||
required: false,
|
required: false,
|
||||||
mode: 'trigger',
|
mode: 'trigger',
|
||||||
condition: { field: 'scheduleType', value: ['minutes', 'hourly'], not: true },
|
condition: { field: 'scheduleType', value: ['minutes', 'hourly'], not: true },
|
||||||
|
|||||||
@@ -424,35 +424,6 @@ export function useCollaborativeWorkflow() {
|
|||||||
logger.info('Successfully applied batch-update-parent from remote user')
|
logger.info('Successfully applied batch-update-parent from remote user')
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case BLOCKS_OPERATIONS.GROUP_BLOCKS: {
|
|
||||||
const { blockIds, groupId } = payload
|
|
||||||
logger.info('Received group-blocks from remote user', {
|
|
||||||
userId,
|
|
||||||
groupId,
|
|
||||||
blockCount: (blockIds || []).length,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (blockIds && blockIds.length > 0 && groupId) {
|
|
||||||
workflowStore.groupBlocks(blockIds, groupId)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('Successfully applied group-blocks from remote user')
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case BLOCKS_OPERATIONS.UNGROUP_BLOCKS: {
|
|
||||||
const { groupId } = payload
|
|
||||||
logger.info('Received ungroup-blocks from remote user', {
|
|
||||||
userId,
|
|
||||||
groupId,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (groupId) {
|
|
||||||
workflowStore.ungroupBlocks(groupId)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('Successfully applied ungroup-blocks from remote user')
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1613,83 +1584,6 @@ export function useCollaborativeWorkflow() {
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
const collaborativeGroupBlocks = useCallback(
|
|
||||||
(blockIds: string[]) => {
|
|
||||||
if (!isInActiveRoom()) {
|
|
||||||
logger.debug('Skipping group blocks - not in active workflow')
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (blockIds.length < 2) {
|
|
||||||
logger.debug('Cannot group fewer than 2 blocks')
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const groupId = crypto.randomUUID()
|
|
||||||
|
|
||||||
const operationId = crypto.randomUUID()
|
|
||||||
|
|
||||||
addToQueue({
|
|
||||||
id: operationId,
|
|
||||||
operation: {
|
|
||||||
operation: BLOCKS_OPERATIONS.GROUP_BLOCKS,
|
|
||||||
target: OPERATION_TARGETS.BLOCKS,
|
|
||||||
payload: { blockIds, groupId },
|
|
||||||
},
|
|
||||||
workflowId: activeWorkflowId || '',
|
|
||||||
userId: session?.user?.id || 'unknown',
|
|
||||||
})
|
|
||||||
|
|
||||||
workflowStore.groupBlocks(blockIds, groupId)
|
|
||||||
|
|
||||||
undoRedo.recordGroupBlocks(blockIds, groupId)
|
|
||||||
|
|
||||||
logger.info('Grouped blocks collaboratively', { groupId, blockCount: blockIds.length })
|
|
||||||
return groupId
|
|
||||||
},
|
|
||||||
[addToQueue, activeWorkflowId, session?.user?.id, isInActiveRoom, workflowStore, undoRedo]
|
|
||||||
)
|
|
||||||
|
|
||||||
const collaborativeUngroupBlocks = useCallback(
|
|
||||||
(groupId: string) => {
|
|
||||||
if (!isInActiveRoom()) {
|
|
||||||
logger.debug('Skipping ungroup blocks - not in active workflow')
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
const groups = workflowStore.getGroups()
|
|
||||||
const group = groups[groupId]
|
|
||||||
|
|
||||||
if (!group) {
|
|
||||||
logger.warn('Cannot ungroup - group not found', { groupId })
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
const blockIds = [...group.blockIds]
|
|
||||||
|
|
||||||
const operationId = crypto.randomUUID()
|
|
||||||
|
|
||||||
addToQueue({
|
|
||||||
id: operationId,
|
|
||||||
operation: {
|
|
||||||
operation: BLOCKS_OPERATIONS.UNGROUP_BLOCKS,
|
|
||||||
target: OPERATION_TARGETS.BLOCKS,
|
|
||||||
payload: { groupId, blockIds },
|
|
||||||
},
|
|
||||||
workflowId: activeWorkflowId || '',
|
|
||||||
userId: session?.user?.id || 'unknown',
|
|
||||||
})
|
|
||||||
|
|
||||||
workflowStore.ungroupBlocks(groupId)
|
|
||||||
|
|
||||||
undoRedo.recordUngroupBlocks(groupId, blockIds)
|
|
||||||
|
|
||||||
logger.info('Ungrouped blocks collaboratively', { groupId, blockCount: blockIds.length })
|
|
||||||
return blockIds
|
|
||||||
},
|
|
||||||
[addToQueue, activeWorkflowId, session?.user?.id, isInActiveRoom, workflowStore, undoRedo]
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Connection status
|
// Connection status
|
||||||
isConnected,
|
isConnected,
|
||||||
@@ -1728,10 +1622,6 @@ export function useCollaborativeWorkflow() {
|
|||||||
collaborativeUpdateIterationCount,
|
collaborativeUpdateIterationCount,
|
||||||
collaborativeUpdateIterationCollection,
|
collaborativeUpdateIterationCollection,
|
||||||
|
|
||||||
// Collaborative block group operations
|
|
||||||
collaborativeGroupBlocks,
|
|
||||||
collaborativeUngroupBlocks,
|
|
||||||
|
|
||||||
// Direct access to stores for non-collaborative operations
|
// Direct access to stores for non-collaborative operations
|
||||||
workflowStore,
|
workflowStore,
|
||||||
subBlockStore,
|
subBlockStore,
|
||||||
|
|||||||
@@ -22,9 +22,7 @@ import {
|
|||||||
type BatchToggleHandlesOperation,
|
type BatchToggleHandlesOperation,
|
||||||
type BatchUpdateParentOperation,
|
type BatchUpdateParentOperation,
|
||||||
createOperationEntry,
|
createOperationEntry,
|
||||||
type GroupBlocksOperation,
|
|
||||||
runWithUndoRedoRecordingSuspended,
|
runWithUndoRedoRecordingSuspended,
|
||||||
type UngroupBlocksOperation,
|
|
||||||
type UpdateParentOperation,
|
type UpdateParentOperation,
|
||||||
useUndoRedoStore,
|
useUndoRedoStore,
|
||||||
} from '@/stores/undo-redo'
|
} from '@/stores/undo-redo'
|
||||||
@@ -876,46 +874,6 @@ export function useUndoRedo() {
|
|||||||
})
|
})
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case UNDO_REDO_OPERATIONS.GROUP_BLOCKS: {
|
|
||||||
// Undoing group = ungroup (inverse is ungroup operation)
|
|
||||||
const inverseOp = entry.inverse as unknown as UngroupBlocksOperation
|
|
||||||
const { groupId } = inverseOp.data
|
|
||||||
|
|
||||||
addToQueue({
|
|
||||||
id: opId,
|
|
||||||
operation: {
|
|
||||||
operation: BLOCKS_OPERATIONS.UNGROUP_BLOCKS,
|
|
||||||
target: OPERATION_TARGETS.BLOCKS,
|
|
||||||
payload: { groupId, blockIds: inverseOp.data.blockIds },
|
|
||||||
},
|
|
||||||
workflowId: activeWorkflowId,
|
|
||||||
userId,
|
|
||||||
})
|
|
||||||
|
|
||||||
workflowStore.ungroupBlocks(groupId)
|
|
||||||
logger.debug('Undid group blocks', { groupId })
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case UNDO_REDO_OPERATIONS.UNGROUP_BLOCKS: {
|
|
||||||
// Undoing ungroup = re-group (inverse is group operation)
|
|
||||||
const inverseOp = entry.inverse as unknown as GroupBlocksOperation
|
|
||||||
const { groupId, blockIds } = inverseOp.data
|
|
||||||
|
|
||||||
addToQueue({
|
|
||||||
id: opId,
|
|
||||||
operation: {
|
|
||||||
operation: BLOCKS_OPERATIONS.GROUP_BLOCKS,
|
|
||||||
target: OPERATION_TARGETS.BLOCKS,
|
|
||||||
payload: { groupId, blockIds },
|
|
||||||
},
|
|
||||||
workflowId: activeWorkflowId,
|
|
||||||
userId,
|
|
||||||
})
|
|
||||||
|
|
||||||
workflowStore.groupBlocks(blockIds, groupId)
|
|
||||||
logger.debug('Undid ungroup blocks', { groupId })
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case UNDO_REDO_OPERATIONS.APPLY_DIFF: {
|
case UNDO_REDO_OPERATIONS.APPLY_DIFF: {
|
||||||
const applyDiffInverse = entry.inverse as any
|
const applyDiffInverse = entry.inverse as any
|
||||||
const { baselineSnapshot } = applyDiffInverse.data
|
const { baselineSnapshot } = applyDiffInverse.data
|
||||||
@@ -1524,46 +1482,6 @@ export function useUndoRedo() {
|
|||||||
})
|
})
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case UNDO_REDO_OPERATIONS.GROUP_BLOCKS: {
|
|
||||||
// Redo group = group again
|
|
||||||
const groupOp = entry.operation as GroupBlocksOperation
|
|
||||||
const { groupId, blockIds } = groupOp.data
|
|
||||||
|
|
||||||
addToQueue({
|
|
||||||
id: opId,
|
|
||||||
operation: {
|
|
||||||
operation: BLOCKS_OPERATIONS.GROUP_BLOCKS,
|
|
||||||
target: OPERATION_TARGETS.BLOCKS,
|
|
||||||
payload: { groupId, blockIds },
|
|
||||||
},
|
|
||||||
workflowId: activeWorkflowId,
|
|
||||||
userId,
|
|
||||||
})
|
|
||||||
|
|
||||||
workflowStore.groupBlocks(blockIds, groupId)
|
|
||||||
logger.debug('Redid group blocks', { groupId })
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case UNDO_REDO_OPERATIONS.UNGROUP_BLOCKS: {
|
|
||||||
// Redo ungroup = ungroup again
|
|
||||||
const ungroupOp = entry.operation as UngroupBlocksOperation
|
|
||||||
const { groupId } = ungroupOp.data
|
|
||||||
|
|
||||||
addToQueue({
|
|
||||||
id: opId,
|
|
||||||
operation: {
|
|
||||||
operation: BLOCKS_OPERATIONS.UNGROUP_BLOCKS,
|
|
||||||
target: OPERATION_TARGETS.BLOCKS,
|
|
||||||
payload: { groupId, blockIds: ungroupOp.data.blockIds },
|
|
||||||
},
|
|
||||||
workflowId: activeWorkflowId,
|
|
||||||
userId,
|
|
||||||
})
|
|
||||||
|
|
||||||
workflowStore.ungroupBlocks(groupId)
|
|
||||||
logger.debug('Redid ungroup blocks', { groupId })
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case UNDO_REDO_OPERATIONS.APPLY_DIFF: {
|
case UNDO_REDO_OPERATIONS.APPLY_DIFF: {
|
||||||
// Redo apply-diff means re-applying the proposed state with diff markers
|
// Redo apply-diff means re-applying the proposed state with diff markers
|
||||||
const applyDiffOp = entry.operation as any
|
const applyDiffOp = entry.operation as any
|
||||||
@@ -1875,66 +1793,6 @@ export function useUndoRedo() {
|
|||||||
[activeWorkflowId, userId, undoRedoStore]
|
[activeWorkflowId, userId, undoRedoStore]
|
||||||
)
|
)
|
||||||
|
|
||||||
const recordGroupBlocks = useCallback(
|
|
||||||
(blockIds: string[], groupId: string) => {
|
|
||||||
if (!activeWorkflowId || blockIds.length === 0) return
|
|
||||||
|
|
||||||
const operation: GroupBlocksOperation = {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
type: UNDO_REDO_OPERATIONS.GROUP_BLOCKS,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
workflowId: activeWorkflowId,
|
|
||||||
userId,
|
|
||||||
data: { groupId, blockIds },
|
|
||||||
}
|
|
||||||
|
|
||||||
const inverse: UngroupBlocksOperation = {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
type: UNDO_REDO_OPERATIONS.UNGROUP_BLOCKS,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
workflowId: activeWorkflowId,
|
|
||||||
userId,
|
|
||||||
data: { groupId, blockIds },
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry = createOperationEntry(operation, inverse)
|
|
||||||
undoRedoStore.push(activeWorkflowId, userId, entry)
|
|
||||||
|
|
||||||
logger.debug('Recorded group blocks', { groupId, blockCount: blockIds.length })
|
|
||||||
},
|
|
||||||
[activeWorkflowId, userId, undoRedoStore]
|
|
||||||
)
|
|
||||||
|
|
||||||
const recordUngroupBlocks = useCallback(
|
|
||||||
(groupId: string, blockIds: string[], parentGroupId?: string) => {
|
|
||||||
if (!activeWorkflowId || blockIds.length === 0) return
|
|
||||||
|
|
||||||
const operation: UngroupBlocksOperation = {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
type: UNDO_REDO_OPERATIONS.UNGROUP_BLOCKS,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
workflowId: activeWorkflowId,
|
|
||||||
userId,
|
|
||||||
data: { groupId, blockIds, parentGroupId },
|
|
||||||
}
|
|
||||||
|
|
||||||
const inverse: GroupBlocksOperation = {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
type: UNDO_REDO_OPERATIONS.GROUP_BLOCKS,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
workflowId: activeWorkflowId,
|
|
||||||
userId,
|
|
||||||
data: { groupId, blockIds },
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry = createOperationEntry(operation, inverse)
|
|
||||||
undoRedoStore.push(activeWorkflowId, userId, entry)
|
|
||||||
|
|
||||||
logger.debug('Recorded ungroup blocks', { groupId, blockCount: blockIds.length })
|
|
||||||
},
|
|
||||||
[activeWorkflowId, userId, undoRedoStore]
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
recordBatchAddBlocks,
|
recordBatchAddBlocks,
|
||||||
recordBatchRemoveBlocks,
|
recordBatchRemoveBlocks,
|
||||||
@@ -1948,8 +1806,6 @@ export function useUndoRedo() {
|
|||||||
recordApplyDiff,
|
recordApplyDiff,
|
||||||
recordAcceptDiff,
|
recordAcceptDiff,
|
||||||
recordRejectDiff,
|
recordRejectDiff,
|
||||||
recordGroupBlocks,
|
|
||||||
recordUngroupBlocks,
|
|
||||||
undo,
|
undo,
|
||||||
redo,
|
redo,
|
||||||
getStackSizes,
|
getStackSizes,
|
||||||
|
|||||||
@@ -99,7 +99,6 @@ export interface SendMessageRequest {
|
|||||||
workflowId?: string
|
workflowId?: string
|
||||||
executionId?: string
|
executionId?: string
|
||||||
}>
|
}>
|
||||||
commands?: string[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
GetBlockConfigInput,
|
GetBlockConfigInput,
|
||||||
GetBlockConfigResult,
|
GetBlockConfigResult,
|
||||||
} from '@/lib/copilot/tools/shared/schemas'
|
} from '@/lib/copilot/tools/shared/schemas'
|
||||||
import { getBlock } from '@/blocks/registry'
|
|
||||||
|
|
||||||
interface GetBlockConfigArgs {
|
interface GetBlockConfigArgs {
|
||||||
blockType: string
|
blockType: string
|
||||||
@@ -40,9 +39,7 @@ export class GetBlockConfigClientTool extends BaseClientTool {
|
|||||||
},
|
},
|
||||||
getDynamicText: (params, state) => {
|
getDynamicText: (params, state) => {
|
||||||
if (params?.blockType && typeof params.blockType === 'string') {
|
if (params?.blockType && typeof params.blockType === 'string') {
|
||||||
// Look up the block config to get the human-readable name
|
const blockName = params.blockType.replace(/_/g, ' ')
|
||||||
const blockConfig = getBlock(params.blockType)
|
|
||||||
const blockName = (blockConfig?.name ?? params.blockType.replace(/_/g, ' ')).toLowerCase()
|
|
||||||
const opSuffix = params.operation ? ` (${params.operation})` : ''
|
const opSuffix = params.operation ? ` (${params.operation})` : ''
|
||||||
|
|
||||||
switch (state) {
|
switch (state) {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
GetBlockOptionsInput,
|
GetBlockOptionsInput,
|
||||||
GetBlockOptionsResult,
|
GetBlockOptionsResult,
|
||||||
} from '@/lib/copilot/tools/shared/schemas'
|
} from '@/lib/copilot/tools/shared/schemas'
|
||||||
import { getBlock } from '@/blocks/registry'
|
|
||||||
|
|
||||||
interface GetBlockOptionsArgs {
|
interface GetBlockOptionsArgs {
|
||||||
blockId: string
|
blockId: string
|
||||||
@@ -38,9 +37,7 @@ export class GetBlockOptionsClientTool extends BaseClientTool {
|
|||||||
},
|
},
|
||||||
getDynamicText: (params, state) => {
|
getDynamicText: (params, state) => {
|
||||||
if (params?.blockId && typeof params.blockId === 'string') {
|
if (params?.blockId && typeof params.blockId === 'string') {
|
||||||
// Look up the block config to get the human-readable name
|
const blockName = params.blockId.replace(/_/g, ' ')
|
||||||
const blockConfig = getBlock(params.blockId)
|
|
||||||
const blockName = (blockConfig?.name ?? params.blockId.replace(/_/g, ' ')).toLowerCase()
|
|
||||||
|
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case ClientToolCallState.success:
|
case ClientToolCallState.success:
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import './other/make-api-request'
|
|||||||
import './other/plan'
|
import './other/plan'
|
||||||
import './other/research'
|
import './other/research'
|
||||||
import './other/sleep'
|
import './other/sleep'
|
||||||
import './other/superagent'
|
|
||||||
import './other/test'
|
import './other/test'
|
||||||
import './other/tour'
|
import './other/tour'
|
||||||
import './other/workflow'
|
import './other/workflow'
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
import { Globe, Loader2, MinusCircle, XCircle } from 'lucide-react'
|
|
||||||
import {
|
|
||||||
BaseClientTool,
|
|
||||||
type BaseClientToolMetadata,
|
|
||||||
ClientToolCallState,
|
|
||||||
} from '@/lib/copilot/tools/client/base-tool'
|
|
||||||
|
|
||||||
export class CrawlWebsiteClientTool extends BaseClientTool {
|
|
||||||
static readonly id = 'crawl_website'
|
|
||||||
|
|
||||||
constructor(toolCallId: string) {
|
|
||||||
super(toolCallId, CrawlWebsiteClientTool.id, CrawlWebsiteClientTool.metadata)
|
|
||||||
}
|
|
||||||
|
|
||||||
static readonly metadata: BaseClientToolMetadata = {
|
|
||||||
displayNames: {
|
|
||||||
[ClientToolCallState.generating]: { text: 'Crawling website', icon: Loader2 },
|
|
||||||
[ClientToolCallState.pending]: { text: 'Crawling website', icon: Loader2 },
|
|
||||||
[ClientToolCallState.executing]: { text: 'Crawling website', icon: Loader2 },
|
|
||||||
[ClientToolCallState.success]: { text: 'Crawled website', icon: Globe },
|
|
||||||
[ClientToolCallState.error]: { text: 'Failed to crawl website', icon: XCircle },
|
|
||||||
[ClientToolCallState.aborted]: { text: 'Aborted crawling website', icon: MinusCircle },
|
|
||||||
[ClientToolCallState.rejected]: { text: 'Skipped crawling website', icon: MinusCircle },
|
|
||||||
},
|
|
||||||
interrupt: undefined,
|
|
||||||
getDynamicText: (params, state) => {
|
|
||||||
if (params?.url && typeof params.url === 'string') {
|
|
||||||
const url = params.url
|
|
||||||
const truncated = url.length > 50 ? `${url.slice(0, 50)}...` : url
|
|
||||||
|
|
||||||
switch (state) {
|
|
||||||
case ClientToolCallState.success:
|
|
||||||
return `Crawled ${truncated}`
|
|
||||||
case ClientToolCallState.executing:
|
|
||||||
case ClientToolCallState.generating:
|
|
||||||
case ClientToolCallState.pending:
|
|
||||||
return `Crawling ${truncated}`
|
|
||||||
case ClientToolCallState.error:
|
|
||||||
return `Failed to crawl ${truncated}`
|
|
||||||
case ClientToolCallState.aborted:
|
|
||||||
return `Aborted crawling ${truncated}`
|
|
||||||
case ClientToolCallState.rejected:
|
|
||||||
return `Skipped crawling ${truncated}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
async execute(): Promise<void> {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { FileText, Loader2, MinusCircle, XCircle } from 'lucide-react'
|
|
||||||
import {
|
|
||||||
BaseClientTool,
|
|
||||||
type BaseClientToolMetadata,
|
|
||||||
ClientToolCallState,
|
|
||||||
} from '@/lib/copilot/tools/client/base-tool'
|
|
||||||
|
|
||||||
export class GetPageContentsClientTool extends BaseClientTool {
|
|
||||||
static readonly id = 'get_page_contents'
|
|
||||||
|
|
||||||
constructor(toolCallId: string) {
|
|
||||||
super(toolCallId, GetPageContentsClientTool.id, GetPageContentsClientTool.metadata)
|
|
||||||
}
|
|
||||||
|
|
||||||
static readonly metadata: BaseClientToolMetadata = {
|
|
||||||
displayNames: {
|
|
||||||
[ClientToolCallState.generating]: { text: 'Getting page contents', icon: Loader2 },
|
|
||||||
[ClientToolCallState.pending]: { text: 'Getting page contents', icon: Loader2 },
|
|
||||||
[ClientToolCallState.executing]: { text: 'Getting page contents', icon: Loader2 },
|
|
||||||
[ClientToolCallState.success]: { text: 'Retrieved page contents', icon: FileText },
|
|
||||||
[ClientToolCallState.error]: { text: 'Failed to get page contents', icon: XCircle },
|
|
||||||
[ClientToolCallState.aborted]: { text: 'Aborted getting page contents', icon: MinusCircle },
|
|
||||||
[ClientToolCallState.rejected]: { text: 'Skipped getting page contents', icon: MinusCircle },
|
|
||||||
},
|
|
||||||
interrupt: undefined,
|
|
||||||
getDynamicText: (params, state) => {
|
|
||||||
if (params?.urls && Array.isArray(params.urls) && params.urls.length > 0) {
|
|
||||||
const firstUrl = String(params.urls[0])
|
|
||||||
const truncated = firstUrl.length > 40 ? `${firstUrl.slice(0, 40)}...` : firstUrl
|
|
||||||
const count = params.urls.length
|
|
||||||
|
|
||||||
switch (state) {
|
|
||||||
case ClientToolCallState.success:
|
|
||||||
return count > 1 ? `Retrieved ${count} pages` : `Retrieved ${truncated}`
|
|
||||||
case ClientToolCallState.executing:
|
|
||||||
case ClientToolCallState.generating:
|
|
||||||
case ClientToolCallState.pending:
|
|
||||||
return count > 1 ? `Getting ${count} pages` : `Getting ${truncated}`
|
|
||||||
case ClientToolCallState.error:
|
|
||||||
return count > 1 ? `Failed to get ${count} pages` : `Failed to get ${truncated}`
|
|
||||||
case ClientToolCallState.aborted:
|
|
||||||
return count > 1 ? `Aborted getting ${count} pages` : `Aborted getting ${truncated}`
|
|
||||||
case ClientToolCallState.rejected:
|
|
||||||
return count > 1 ? `Skipped getting ${count} pages` : `Skipped getting ${truncated}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
async execute(): Promise<void> {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { Globe, Loader2, MinusCircle, XCircle } from 'lucide-react'
|
|
||||||
import {
|
|
||||||
BaseClientTool,
|
|
||||||
type BaseClientToolMetadata,
|
|
||||||
ClientToolCallState,
|
|
||||||
} from '@/lib/copilot/tools/client/base-tool'
|
|
||||||
|
|
||||||
export class ScrapePageClientTool extends BaseClientTool {
|
|
||||||
static readonly id = 'scrape_page'
|
|
||||||
|
|
||||||
constructor(toolCallId: string) {
|
|
||||||
super(toolCallId, ScrapePageClientTool.id, ScrapePageClientTool.metadata)
|
|
||||||
}
|
|
||||||
|
|
||||||
static readonly metadata: BaseClientToolMetadata = {
|
|
||||||
displayNames: {
|
|
||||||
[ClientToolCallState.generating]: { text: 'Scraping page', icon: Loader2 },
|
|
||||||
[ClientToolCallState.pending]: { text: 'Scraping page', icon: Loader2 },
|
|
||||||
[ClientToolCallState.executing]: { text: 'Scraping page', icon: Loader2 },
|
|
||||||
[ClientToolCallState.success]: { text: 'Scraped page', icon: Globe },
|
|
||||||
[ClientToolCallState.error]: { text: 'Failed to scrape page', icon: XCircle },
|
|
||||||
[ClientToolCallState.aborted]: { text: 'Aborted scraping page', icon: MinusCircle },
|
|
||||||
[ClientToolCallState.rejected]: { text: 'Skipped scraping page', icon: MinusCircle },
|
|
||||||
},
|
|
||||||
interrupt: undefined,
|
|
||||||
getDynamicText: (params, state) => {
|
|
||||||
if (params?.url && typeof params.url === 'string') {
|
|
||||||
const url = params.url
|
|
||||||
const truncated = url.length > 50 ? `${url.slice(0, 50)}...` : url
|
|
||||||
|
|
||||||
switch (state) {
|
|
||||||
case ClientToolCallState.success:
|
|
||||||
return `Scraped ${truncated}`
|
|
||||||
case ClientToolCallState.executing:
|
|
||||||
case ClientToolCallState.generating:
|
|
||||||
case ClientToolCallState.pending:
|
|
||||||
return `Scraping ${truncated}`
|
|
||||||
case ClientToolCallState.error:
|
|
||||||
return `Failed to scrape ${truncated}`
|
|
||||||
case ClientToolCallState.aborted:
|
|
||||||
return `Aborted scraping ${truncated}`
|
|
||||||
case ClientToolCallState.rejected:
|
|
||||||
return `Skipped scraping ${truncated}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
async execute(): Promise<void> {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,19 @@
|
|||||||
|
import { createLogger } from '@sim/logger'
|
||||||
import { Globe, Loader2, MinusCircle, XCircle } from 'lucide-react'
|
import { Globe, Loader2, MinusCircle, XCircle } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
BaseClientTool,
|
BaseClientTool,
|
||||||
type BaseClientToolMetadata,
|
type BaseClientToolMetadata,
|
||||||
ClientToolCallState,
|
ClientToolCallState,
|
||||||
} from '@/lib/copilot/tools/client/base-tool'
|
} from '@/lib/copilot/tools/client/base-tool'
|
||||||
|
import { ExecuteResponseSuccessSchema } from '@/lib/copilot/tools/shared/schemas'
|
||||||
|
|
||||||
|
interface SearchOnlineArgs {
|
||||||
|
query: string
|
||||||
|
num?: number
|
||||||
|
type?: string
|
||||||
|
gl?: string
|
||||||
|
hl?: string
|
||||||
|
}
|
||||||
|
|
||||||
export class SearchOnlineClientTool extends BaseClientTool {
|
export class SearchOnlineClientTool extends BaseClientTool {
|
||||||
static readonly id = 'search_online'
|
static readonly id = 'search_online'
|
||||||
@@ -22,7 +32,6 @@ export class SearchOnlineClientTool extends BaseClientTool {
|
|||||||
[ClientToolCallState.rejected]: { text: 'Skipped online search', icon: MinusCircle },
|
[ClientToolCallState.rejected]: { text: 'Skipped online search', icon: MinusCircle },
|
||||||
[ClientToolCallState.aborted]: { text: 'Aborted online search', icon: XCircle },
|
[ClientToolCallState.aborted]: { text: 'Aborted online search', icon: XCircle },
|
||||||
},
|
},
|
||||||
interrupt: undefined,
|
|
||||||
getDynamicText: (params, state) => {
|
getDynamicText: (params, state) => {
|
||||||
if (params?.query && typeof params.query === 'string') {
|
if (params?.query && typeof params.query === 'string') {
|
||||||
const query = params.query
|
const query = params.query
|
||||||
@@ -47,7 +56,28 @@ export class SearchOnlineClientTool extends BaseClientTool {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
async execute(): Promise<void> {
|
async execute(args?: SearchOnlineArgs): Promise<void> {
|
||||||
return
|
const logger = createLogger('SearchOnlineClientTool')
|
||||||
|
try {
|
||||||
|
this.setState(ClientToolCallState.executing)
|
||||||
|
const res = await fetch('/api/copilot/execute-copilot-server-tool', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ toolName: 'search_online', payload: args || {} }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const txt = await res.text().catch(() => '')
|
||||||
|
throw new Error(txt || `Server error (${res.status})`)
|
||||||
|
}
|
||||||
|
const json = await res.json()
|
||||||
|
const parsed = ExecuteResponseSuccessSchema.parse(json)
|
||||||
|
this.setState(ClientToolCallState.success)
|
||||||
|
await this.markToolComplete(200, 'Online search complete', parsed.result)
|
||||||
|
this.setState(ClientToolCallState.success)
|
||||||
|
} catch (e: any) {
|
||||||
|
logger.error('execute failed', { message: e?.message })
|
||||||
|
this.setState(ClientToolCallState.error)
|
||||||
|
await this.markToolComplete(500, e?.message || 'Search failed')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import { Loader2, Sparkles, XCircle } from 'lucide-react'
|
|
||||||
import {
|
|
||||||
BaseClientTool,
|
|
||||||
type BaseClientToolMetadata,
|
|
||||||
ClientToolCallState,
|
|
||||||
} from '@/lib/copilot/tools/client/base-tool'
|
|
||||||
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
|
|
||||||
|
|
||||||
interface SuperagentArgs {
|
|
||||||
instruction: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Superagent tool that spawns a powerful subagent for complex tasks.
|
|
||||||
* This tool auto-executes and the actual work is done by the superagent.
|
|
||||||
* The subagent's output is streamed as nested content under this tool call.
|
|
||||||
*/
|
|
||||||
export class SuperagentClientTool extends BaseClientTool {
|
|
||||||
static readonly id = 'superagent'
|
|
||||||
|
|
||||||
constructor(toolCallId: string) {
|
|
||||||
super(toolCallId, SuperagentClientTool.id, SuperagentClientTool.metadata)
|
|
||||||
}
|
|
||||||
|
|
||||||
static readonly metadata: BaseClientToolMetadata = {
|
|
||||||
displayNames: {
|
|
||||||
[ClientToolCallState.generating]: { text: 'Superagent working', icon: Loader2 },
|
|
||||||
[ClientToolCallState.pending]: { text: 'Superagent working', icon: Loader2 },
|
|
||||||
[ClientToolCallState.executing]: { text: 'Superagent working', icon: Loader2 },
|
|
||||||
[ClientToolCallState.success]: { text: 'Superagent completed', icon: Sparkles },
|
|
||||||
[ClientToolCallState.error]: { text: 'Superagent failed', icon: XCircle },
|
|
||||||
[ClientToolCallState.rejected]: { text: 'Superagent skipped', icon: XCircle },
|
|
||||||
[ClientToolCallState.aborted]: { text: 'Superagent aborted', icon: XCircle },
|
|
||||||
},
|
|
||||||
uiConfig: {
|
|
||||||
subagent: {
|
|
||||||
streamingLabel: 'Superagent working',
|
|
||||||
completedLabel: 'Superagent completed',
|
|
||||||
shouldCollapse: true,
|
|
||||||
outputArtifacts: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute the superagent tool.
|
|
||||||
* This just marks the tool as executing - the actual work is done server-side
|
|
||||||
* by the superagent, and its output is streamed as subagent events.
|
|
||||||
*/
|
|
||||||
async execute(_args?: SuperagentArgs): Promise<void> {
|
|
||||||
this.setState(ClientToolCallState.executing)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register UI config at module load
|
|
||||||
registerToolUIConfig(SuperagentClientTool.id, SuperagentClientTool.metadata.uiConfig!)
|
|
||||||
@@ -16,61 +16,9 @@ import type { BlockState } from '@/stores/workflows/workflow/types'
|
|||||||
|
|
||||||
const logger = createLogger('AutoLayout')
|
const logger = createLogger('AutoLayout')
|
||||||
|
|
||||||
/** Default block dimensions for layout calculations */
|
|
||||||
const DEFAULT_BLOCK_WIDTH = 250
|
|
||||||
const DEFAULT_BLOCK_HEIGHT = 100
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Identifies groups from blocks and calculates their bounding boxes.
|
|
||||||
* Returns a map of groupId to group info including bounding box and member block IDs.
|
|
||||||
*/
|
|
||||||
function identifyGroups(blocks: Record<string, BlockState>): Map<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
blockIds: string[]
|
|
||||||
bounds: { minX: number; minY: number; maxX: number; maxY: number }
|
|
||||||
}
|
|
||||||
> {
|
|
||||||
const groups = new Map<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
blockIds: string[]
|
|
||||||
bounds: { minX: number; minY: number; maxX: number; maxY: number }
|
|
||||||
}
|
|
||||||
>()
|
|
||||||
|
|
||||||
// Group blocks by their groupId
|
|
||||||
for (const [blockId, block] of Object.entries(blocks)) {
|
|
||||||
const groupId = block.data?.groupId
|
|
||||||
if (!groupId) continue
|
|
||||||
|
|
||||||
if (!groups.has(groupId)) {
|
|
||||||
groups.set(groupId, {
|
|
||||||
blockIds: [],
|
|
||||||
bounds: { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const group = groups.get(groupId)!
|
|
||||||
group.blockIds.push(blockId)
|
|
||||||
|
|
||||||
// Update bounding box
|
|
||||||
const blockWidth = block.data?.width ?? DEFAULT_BLOCK_WIDTH
|
|
||||||
const blockHeight = block.data?.height ?? block.height ?? DEFAULT_BLOCK_HEIGHT
|
|
||||||
|
|
||||||
group.bounds.minX = Math.min(group.bounds.minX, block.position.x)
|
|
||||||
group.bounds.minY = Math.min(group.bounds.minY, block.position.y)
|
|
||||||
group.bounds.maxX = Math.max(group.bounds.maxX, block.position.x + blockWidth)
|
|
||||||
group.bounds.maxY = Math.max(group.bounds.maxY, block.position.y + blockHeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
return groups
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies automatic layout to all blocks in a workflow.
|
* Applies automatic layout to all blocks in a workflow.
|
||||||
* Positions blocks in layers based on their connections (edges).
|
* Positions blocks in layers based on their connections (edges).
|
||||||
* Groups are treated as single units and laid out together.
|
|
||||||
*/
|
*/
|
||||||
export function applyAutoLayout(
|
export function applyAutoLayout(
|
||||||
blocks: Record<string, BlockState>,
|
blocks: Record<string, BlockState>,
|
||||||
@@ -88,11 +36,6 @@ export function applyAutoLayout(
|
|||||||
const horizontalSpacing = options.horizontalSpacing ?? DEFAULT_HORIZONTAL_SPACING
|
const horizontalSpacing = options.horizontalSpacing ?? DEFAULT_HORIZONTAL_SPACING
|
||||||
const verticalSpacing = options.verticalSpacing ?? DEFAULT_VERTICAL_SPACING
|
const verticalSpacing = options.verticalSpacing ?? DEFAULT_VERTICAL_SPACING
|
||||||
|
|
||||||
// Identify groups and their bounding boxes
|
|
||||||
const groups = identifyGroups(blocksCopy)
|
|
||||||
|
|
||||||
logger.info('Identified block groups for layout', { groupCount: groups.size })
|
|
||||||
|
|
||||||
// Pre-calculate container dimensions by laying out their children (bottom-up)
|
// Pre-calculate container dimensions by laying out their children (bottom-up)
|
||||||
// This ensures accurate widths/heights before root-level layout
|
// This ensures accurate widths/heights before root-level layout
|
||||||
prepareContainerDimensions(
|
prepareContainerDimensions(
|
||||||
@@ -106,112 +49,19 @@ export function applyAutoLayout(
|
|||||||
const { root: rootBlockIds } = getBlocksByParent(blocksCopy)
|
const { root: rootBlockIds } = getBlocksByParent(blocksCopy)
|
||||||
const layoutRootIds = filterLayoutEligibleBlockIds(rootBlockIds, blocksCopy)
|
const layoutRootIds = filterLayoutEligibleBlockIds(rootBlockIds, blocksCopy)
|
||||||
|
|
||||||
// For groups, we need to:
|
|
||||||
// 1. Create virtual blocks representing each group
|
|
||||||
// 2. Replace grouped blocks with their group's virtual block
|
|
||||||
// 3. Layout the virtual blocks + ungrouped blocks
|
|
||||||
// 4. Apply position deltas to grouped blocks
|
|
||||||
|
|
||||||
// Track which blocks are in groups at root level
|
|
||||||
const groupedRootBlockIds = new Set<string>()
|
|
||||||
const groupRepresentatives = new Map<string, string>() // groupId -> representative blockId
|
|
||||||
|
|
||||||
// Store ORIGINAL positions of all grouped blocks before any modifications
|
|
||||||
const originalBlockPositions = new Map<string, { x: number; y: number }>()
|
|
||||||
for (const [_groupId, group] of groups) {
|
|
||||||
for (const blockId of group.blockIds) {
|
|
||||||
if (blocksCopy[blockId]) {
|
|
||||||
originalBlockPositions.set(blockId, { ...blocksCopy[blockId].position })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [groupId, group] of groups) {
|
|
||||||
// Find if any blocks in this group are at root level
|
|
||||||
const rootGroupBlocks = group.blockIds.filter((id) => layoutRootIds.includes(id))
|
|
||||||
if (rootGroupBlocks.length > 0) {
|
|
||||||
// Mark all blocks in this group as grouped
|
|
||||||
for (const blockId of rootGroupBlocks) {
|
|
||||||
groupedRootBlockIds.add(blockId)
|
|
||||||
}
|
|
||||||
// Use the first block as the group's representative for layout
|
|
||||||
const representativeId = rootGroupBlocks[0]
|
|
||||||
groupRepresentatives.set(groupId, representativeId)
|
|
||||||
|
|
||||||
// Update the representative block's dimensions to match the group's bounding box
|
|
||||||
const bounds = group.bounds
|
|
||||||
const groupWidth = bounds.maxX - bounds.minX
|
|
||||||
const groupHeight = bounds.maxY - bounds.minY
|
|
||||||
|
|
||||||
blocksCopy[representativeId] = {
|
|
||||||
...blocksCopy[representativeId],
|
|
||||||
data: {
|
|
||||||
...blocksCopy[representativeId].data,
|
|
||||||
width: groupWidth,
|
|
||||||
height: groupHeight,
|
|
||||||
},
|
|
||||||
// Position at the group's top-left corner
|
|
||||||
position: { x: bounds.minX, y: bounds.minY },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the blocks to layout: ungrouped blocks + group representatives
|
|
||||||
const rootBlocks: Record<string, BlockState> = {}
|
const rootBlocks: Record<string, BlockState> = {}
|
||||||
for (const id of layoutRootIds) {
|
for (const id of layoutRootIds) {
|
||||||
// Skip grouped blocks that aren't representatives
|
rootBlocks[id] = blocksCopy[id]
|
||||||
if (groupedRootBlockIds.has(id)) {
|
|
||||||
// Only include if this is a group representative
|
|
||||||
for (const [groupId, repId] of groupRepresentatives) {
|
|
||||||
if (repId === id) {
|
|
||||||
rootBlocks[id] = blocksCopy[id]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
rootBlocks[id] = blocksCopy[id]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remap edges: edges involving grouped blocks should connect to the representative
|
const rootEdges = edges.filter(
|
||||||
const blockToGroup = new Map<string, string>() // blockId -> groupId
|
(edge) => layoutRootIds.includes(edge.source) && layoutRootIds.includes(edge.target)
|
||||||
for (const [groupId, group] of groups) {
|
)
|
||||||
for (const blockId of group.blockIds) {
|
|
||||||
blockToGroup.set(blockId, groupId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const layoutBlockIds = new Set(Object.keys(rootBlocks))
|
|
||||||
const rootEdges = edges
|
|
||||||
.map((edge) => {
|
|
||||||
let source = edge.source
|
|
||||||
let target = edge.target
|
|
||||||
|
|
||||||
// Remap source if it's in a group
|
|
||||||
const sourceGroupId = blockToGroup.get(source)
|
|
||||||
if (sourceGroupId && groupRepresentatives.has(sourceGroupId)) {
|
|
||||||
source = groupRepresentatives.get(sourceGroupId)!
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remap target if it's in a group
|
|
||||||
const targetGroupId = blockToGroup.get(target)
|
|
||||||
if (targetGroupId && groupRepresentatives.has(targetGroupId)) {
|
|
||||||
target = groupRepresentatives.get(targetGroupId)!
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...edge, source, target }
|
|
||||||
})
|
|
||||||
.filter((edge) => layoutBlockIds.has(edge.source) && layoutBlockIds.has(edge.target))
|
|
||||||
|
|
||||||
// Calculate subflow depths before laying out root blocks
|
// Calculate subflow depths before laying out root blocks
|
||||||
|
// This ensures blocks connected to subflow ends are positioned correctly
|
||||||
const subflowDepths = calculateSubflowDepths(blocksCopy, edges, assignLayers)
|
const subflowDepths = calculateSubflowDepths(blocksCopy, edges, assignLayers)
|
||||||
|
|
||||||
// Store old positions for groups to calculate deltas
|
|
||||||
const oldGroupPositions = new Map<string, { x: number; y: number }>()
|
|
||||||
for (const [groupId, repId] of groupRepresentatives) {
|
|
||||||
oldGroupPositions.set(groupId, { ...blocksCopy[repId].position })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(rootBlocks).length > 0) {
|
if (Object.keys(rootBlocks).length > 0) {
|
||||||
const { nodes } = layoutBlocksCore(rootBlocks, rootEdges, {
|
const { nodes } = layoutBlocksCore(rootBlocks, rootEdges, {
|
||||||
isContainer: false,
|
isContainer: false,
|
||||||
@@ -219,49 +69,15 @@ export function applyAutoLayout(
|
|||||||
subflowDepths,
|
subflowDepths,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Apply positions to ungrouped blocks and group representatives
|
|
||||||
for (const node of nodes.values()) {
|
for (const node of nodes.values()) {
|
||||||
blocksCopy[node.id].position = node.position
|
blocksCopy[node.id].position = node.position
|
||||||
}
|
}
|
||||||
|
|
||||||
// For each group, calculate the delta and apply to ALL blocks in the group
|
|
||||||
for (const [groupId, repId] of groupRepresentatives) {
|
|
||||||
const oldGroupTopLeft = oldGroupPositions.get(groupId)!
|
|
||||||
const newGroupTopLeft = blocksCopy[repId].position
|
|
||||||
const deltaX = newGroupTopLeft.x - oldGroupTopLeft.x
|
|
||||||
const deltaY = newGroupTopLeft.y - oldGroupTopLeft.y
|
|
||||||
|
|
||||||
const group = groups.get(groupId)!
|
|
||||||
// Apply delta to ALL blocks in the group using their ORIGINAL positions
|
|
||||||
for (const blockId of group.blockIds) {
|
|
||||||
if (layoutRootIds.includes(blockId)) {
|
|
||||||
const originalPos = originalBlockPositions.get(blockId)
|
|
||||||
if (originalPos) {
|
|
||||||
blocksCopy[blockId].position = {
|
|
||||||
x: originalPos.x + deltaX,
|
|
||||||
y: originalPos.y + deltaY,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore the representative's original dimensions
|
|
||||||
const originalBlock = blocks[repId]
|
|
||||||
if (originalBlock) {
|
|
||||||
blocksCopy[repId].data = {
|
|
||||||
...blocksCopy[repId].data,
|
|
||||||
width: originalBlock.data?.width,
|
|
||||||
height: originalBlock.data?.height,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
layoutContainers(blocksCopy, edges, options)
|
layoutContainers(blocksCopy, edges, options)
|
||||||
|
|
||||||
logger.info('Auto layout completed successfully', {
|
logger.info('Auto layout completed successfully', {
|
||||||
blockCount: Object.keys(blocksCopy).length,
|
blockCount: Object.keys(blocksCopy).length,
|
||||||
groupCount: groups.size,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -26,53 +26,9 @@ export interface TargetedLayoutOptions extends LayoutOptions {
|
|||||||
horizontalSpacing?: number
|
horizontalSpacing?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Identifies block groups from the blocks' groupId data.
|
|
||||||
* Returns a map of groupId to array of block IDs in that group.
|
|
||||||
*/
|
|
||||||
function identifyBlockGroups(blocks: Record<string, BlockState>): Map<string, string[]> {
|
|
||||||
const groups = new Map<string, string[]>()
|
|
||||||
|
|
||||||
for (const [blockId, block] of Object.entries(blocks)) {
|
|
||||||
const groupId = block.data?.groupId
|
|
||||||
if (!groupId) continue
|
|
||||||
|
|
||||||
if (!groups.has(groupId)) {
|
|
||||||
groups.set(groupId, [])
|
|
||||||
}
|
|
||||||
groups.get(groupId)!.push(blockId)
|
|
||||||
}
|
|
||||||
|
|
||||||
return groups
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Expands changed block IDs to include all blocks in the same group.
|
|
||||||
* If any block in a group changed, all blocks in that group should be treated as changed.
|
|
||||||
*/
|
|
||||||
function expandChangedToGroups(
|
|
||||||
changedBlockIds: string[],
|
|
||||||
blockGroups: Map<string, string[]>,
|
|
||||||
blocks: Record<string, BlockState>
|
|
||||||
): Set<string> {
|
|
||||||
const expandedSet = new Set(changedBlockIds)
|
|
||||||
|
|
||||||
for (const blockId of changedBlockIds) {
|
|
||||||
const groupId = blocks[blockId]?.data?.groupId
|
|
||||||
if (groupId && blockGroups.has(groupId)) {
|
|
||||||
for (const groupBlockId of blockGroups.get(groupId)!) {
|
|
||||||
expandedSet.add(groupBlockId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return expandedSet
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies targeted layout to only reposition changed blocks.
|
* Applies targeted layout to only reposition changed blocks.
|
||||||
* Unchanged blocks act as anchors to preserve existing layout.
|
* Unchanged blocks act as anchors to preserve existing layout.
|
||||||
* Blocks in groups are moved together as a unit.
|
|
||||||
*/
|
*/
|
||||||
export function applyTargetedLayout(
|
export function applyTargetedLayout(
|
||||||
blocks: Record<string, BlockState>,
|
blocks: Record<string, BlockState>,
|
||||||
@@ -89,14 +45,9 @@ export function applyTargetedLayout(
|
|||||||
return blocks
|
return blocks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const changedSet = new Set(changedBlockIds)
|
||||||
const blocksCopy: Record<string, BlockState> = JSON.parse(JSON.stringify(blocks))
|
const blocksCopy: Record<string, BlockState> = JSON.parse(JSON.stringify(blocks))
|
||||||
|
|
||||||
// Identify block groups
|
|
||||||
const blockGroups = identifyBlockGroups(blocksCopy)
|
|
||||||
|
|
||||||
// Expand changed set to include all blocks in affected groups
|
|
||||||
const changedSet = expandChangedToGroups(changedBlockIds, blockGroups, blocksCopy)
|
|
||||||
|
|
||||||
// Pre-calculate container dimensions by laying out their children (bottom-up)
|
// Pre-calculate container dimensions by laying out their children (bottom-up)
|
||||||
// This ensures accurate widths/heights before root-level layout
|
// This ensures accurate widths/heights before root-level layout
|
||||||
prepareContainerDimensions(
|
prepareContainerDimensions(
|
||||||
@@ -120,8 +71,7 @@ export function applyTargetedLayout(
|
|||||||
changedSet,
|
changedSet,
|
||||||
verticalSpacing,
|
verticalSpacing,
|
||||||
horizontalSpacing,
|
horizontalSpacing,
|
||||||
subflowDepths,
|
subflowDepths
|
||||||
blockGroups
|
|
||||||
)
|
)
|
||||||
|
|
||||||
for (const [parentId, childIds] of groups.children.entries()) {
|
for (const [parentId, childIds] of groups.children.entries()) {
|
||||||
@@ -133,8 +83,7 @@ export function applyTargetedLayout(
|
|||||||
changedSet,
|
changedSet,
|
||||||
verticalSpacing,
|
verticalSpacing,
|
||||||
horizontalSpacing,
|
horizontalSpacing,
|
||||||
subflowDepths,
|
subflowDepths
|
||||||
blockGroups
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +92,6 @@ export function applyTargetedLayout(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Layouts a group of blocks (either root level or within a container)
|
* Layouts a group of blocks (either root level or within a container)
|
||||||
* Blocks in block groups are moved together as a unit.
|
|
||||||
*/
|
*/
|
||||||
function layoutGroup(
|
function layoutGroup(
|
||||||
parentId: string | null,
|
parentId: string | null,
|
||||||
@@ -153,8 +101,7 @@ function layoutGroup(
|
|||||||
changedSet: Set<string>,
|
changedSet: Set<string>,
|
||||||
verticalSpacing: number,
|
verticalSpacing: number,
|
||||||
horizontalSpacing: number,
|
horizontalSpacing: number,
|
||||||
subflowDepths: Map<string, number>,
|
subflowDepths: Map<string, number>
|
||||||
blockGroups: Map<string, string[]>
|
|
||||||
): void {
|
): void {
|
||||||
if (childIds.length === 0) return
|
if (childIds.length === 0) return
|
||||||
|
|
||||||
@@ -194,7 +141,7 @@ function layoutGroup(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store old positions for anchor calculation and group delta tracking
|
// Store old positions for anchor calculation
|
||||||
const oldPositions = new Map<string, { x: number; y: number }>()
|
const oldPositions = new Map<string, { x: number; y: number }>()
|
||||||
for (const id of layoutEligibleChildIds) {
|
for (const id of layoutEligibleChildIds) {
|
||||||
const block = blocks[id]
|
const block = blocks[id]
|
||||||
@@ -238,47 +185,14 @@ function layoutGroup(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track which groups have already had their deltas applied
|
|
||||||
const processedGroups = new Set<string>()
|
|
||||||
|
|
||||||
// Apply new positions only to blocks that need layout
|
// Apply new positions only to blocks that need layout
|
||||||
for (const id of needsLayout) {
|
for (const id of needsLayout) {
|
||||||
const block = blocks[id]
|
const block = blocks[id]
|
||||||
const newPos = layoutPositions.get(id)
|
const newPos = layoutPositions.get(id)
|
||||||
if (!block || !newPos) continue
|
if (!block || !newPos) continue
|
||||||
|
block.position = {
|
||||||
const groupId = block.data?.groupId
|
x: newPos.x + offsetX,
|
||||||
|
y: newPos.y + offsetY,
|
||||||
// If this block is in a group, move all blocks in the group together
|
|
||||||
if (groupId && blockGroups.has(groupId) && !processedGroups.has(groupId)) {
|
|
||||||
processedGroups.add(groupId)
|
|
||||||
|
|
||||||
// Calculate the delta for this block (the one that needs layout)
|
|
||||||
const oldPos = oldPositions.get(id)
|
|
||||||
if (oldPos) {
|
|
||||||
const deltaX = newPos.x + offsetX - oldPos.x
|
|
||||||
const deltaY = newPos.y + offsetY - oldPos.y
|
|
||||||
|
|
||||||
// Apply delta to ALL blocks in the group using their original positions
|
|
||||||
for (const groupBlockId of blockGroups.get(groupId)!) {
|
|
||||||
const groupBlock = blocks[groupBlockId]
|
|
||||||
if (groupBlock && layoutEligibleChildIds.includes(groupBlockId)) {
|
|
||||||
const groupOriginalPos = oldPositions.get(groupBlockId)
|
|
||||||
if (groupOriginalPos) {
|
|
||||||
groupBlock.position = {
|
|
||||||
x: groupOriginalPos.x + deltaX,
|
|
||||||
y: groupOriginalPos.y + deltaY,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (!groupId) {
|
|
||||||
// Non-grouped block - apply position normally
|
|
||||||
block.position = {
|
|
||||||
x: newPos.x + offsetX,
|
|
||||||
y: newPos.y + offsetY,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,18 +41,11 @@ export function isContainerType(blockType: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if a block should be excluded from autolayout.
|
* Checks if a block should be excluded from autolayout
|
||||||
* Note blocks are excluded unless they are part of a group.
|
|
||||||
*/
|
*/
|
||||||
export function shouldSkipAutoLayout(block?: BlockState, isInGroup?: boolean): boolean {
|
export function shouldSkipAutoLayout(block?: BlockState): boolean {
|
||||||
if (!block) return true
|
if (!block) return true
|
||||||
// If the block type is normally excluded (e.g., note), but it's in a group, include it
|
return AUTO_LAYOUT_EXCLUDED_TYPES.has(block.type)
|
||||||
if (AUTO_LAYOUT_EXCLUDED_TYPES.has(block.type)) {
|
|
||||||
// Check if block is in a group - if so, include it in layout
|
|
||||||
const blockIsInGroup = isInGroup ?? !!block.data?.groupId
|
|
||||||
return !blockIsInGroup
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1174,6 +1174,5 @@ export function stripWorkflowDiffMarkers(state: WorkflowState): WorkflowState {
|
|||||||
edges: structuredClone(state.edges || []),
|
edges: structuredClone(state.edges || []),
|
||||||
loops: structuredClone(state.loops || {}),
|
loops: structuredClone(state.loops || {}),
|
||||||
parallels: structuredClone(state.parallels || {}),
|
parallels: structuredClone(state.parallels || {}),
|
||||||
groups: structuredClone(state.groups || {}),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ export const BLOCKS_OPERATIONS = {
|
|||||||
BATCH_TOGGLE_ENABLED: 'batch-toggle-enabled',
|
BATCH_TOGGLE_ENABLED: 'batch-toggle-enabled',
|
||||||
BATCH_TOGGLE_HANDLES: 'batch-toggle-handles',
|
BATCH_TOGGLE_HANDLES: 'batch-toggle-handles',
|
||||||
BATCH_UPDATE_PARENT: 'batch-update-parent',
|
BATCH_UPDATE_PARENT: 'batch-update-parent',
|
||||||
GROUP_BLOCKS: 'group-blocks',
|
|
||||||
UNGROUP_BLOCKS: 'ungroup-blocks',
|
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type BlocksOperation = (typeof BLOCKS_OPERATIONS)[keyof typeof BLOCKS_OPERATIONS]
|
export type BlocksOperation = (typeof BLOCKS_OPERATIONS)[keyof typeof BLOCKS_OPERATIONS]
|
||||||
@@ -89,8 +87,6 @@ export const UNDO_REDO_OPERATIONS = {
|
|||||||
APPLY_DIFF: 'apply-diff',
|
APPLY_DIFF: 'apply-diff',
|
||||||
ACCEPT_DIFF: 'accept-diff',
|
ACCEPT_DIFF: 'accept-diff',
|
||||||
REJECT_DIFF: 'reject-diff',
|
REJECT_DIFF: 'reject-diff',
|
||||||
GROUP_BLOCKS: 'group-blocks',
|
|
||||||
UNGROUP_BLOCKS: 'ungroup-blocks',
|
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type UndoRedoOperation = (typeof UNDO_REDO_OPERATIONS)[keyof typeof UNDO_REDO_OPERATIONS]
|
export type UndoRedoOperation = (typeof UNDO_REDO_OPERATIONS)[keyof typeof UNDO_REDO_OPERATIONS]
|
||||||
|
|||||||
@@ -810,104 +810,6 @@ async function handleBlocksOperationTx(
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case BLOCKS_OPERATIONS.GROUP_BLOCKS: {
|
|
||||||
const { blockIds, groupId } = payload
|
|
||||||
if (!Array.isArray(blockIds) || blockIds.length === 0 || !groupId) {
|
|
||||||
logger.debug('Invalid payload for group blocks operation')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Grouping ${blockIds.length} blocks into group ${groupId} in workflow ${workflowId}`)
|
|
||||||
|
|
||||||
// Update blocks: set groupId and push to groupStack
|
|
||||||
for (const blockId of blockIds) {
|
|
||||||
const [currentBlock] = await tx
|
|
||||||
.select({ data: workflowBlocks.data })
|
|
||||||
.from(workflowBlocks)
|
|
||||||
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (!currentBlock) {
|
|
||||||
logger.warn(`Block ${blockId} not found for grouping`)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentData = (currentBlock?.data || {}) as Record<string, any>
|
|
||||||
const currentStack = Array.isArray(currentData.groupStack) ? currentData.groupStack : []
|
|
||||||
const updatedData = {
|
|
||||||
...currentData,
|
|
||||||
groupId,
|
|
||||||
groupStack: [...currentStack, groupId],
|
|
||||||
}
|
|
||||||
|
|
||||||
await tx
|
|
||||||
.update(workflowBlocks)
|
|
||||||
.set({
|
|
||||||
data: updatedData,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug(`Grouped ${blockIds.length} blocks into group ${groupId}`)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case BLOCKS_OPERATIONS.UNGROUP_BLOCKS: {
|
|
||||||
const { groupId, blockIds } = payload
|
|
||||||
if (!groupId || !Array.isArray(blockIds)) {
|
|
||||||
logger.debug('Invalid payload for ungroup blocks operation')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Ungrouping ${blockIds.length} blocks from group ${groupId} in workflow ${workflowId}`)
|
|
||||||
|
|
||||||
// Update blocks: pop from groupStack and set groupId to the previous level
|
|
||||||
for (const blockId of blockIds) {
|
|
||||||
const [currentBlock] = await tx
|
|
||||||
.select({ data: workflowBlocks.data })
|
|
||||||
.from(workflowBlocks)
|
|
||||||
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (!currentBlock) {
|
|
||||||
logger.warn(`Block ${blockId} not found for ungrouping`)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentData = (currentBlock?.data || {}) as Record<string, any>
|
|
||||||
const currentStack = Array.isArray(currentData.groupStack) ? [...currentData.groupStack] : []
|
|
||||||
|
|
||||||
// Pop the current groupId from the stack
|
|
||||||
if (currentStack.length > 0 && currentStack[currentStack.length - 1] === groupId) {
|
|
||||||
currentStack.pop()
|
|
||||||
}
|
|
||||||
|
|
||||||
// The new groupId is the top of the remaining stack, or undefined if empty
|
|
||||||
const newGroupId = currentStack.length > 0 ? currentStack[currentStack.length - 1] : undefined
|
|
||||||
|
|
||||||
let updatedData: Record<string, any>
|
|
||||||
if (newGroupId) {
|
|
||||||
updatedData = { ...currentData, groupId: newGroupId, groupStack: currentStack }
|
|
||||||
} else {
|
|
||||||
// Remove groupId and groupStack if stack is empty
|
|
||||||
const { groupId: _removed, groupStack: _removedStack, ...restData } = currentData
|
|
||||||
updatedData = restData
|
|
||||||
}
|
|
||||||
|
|
||||||
await tx
|
|
||||||
.update(workflowBlocks)
|
|
||||||
.set({
|
|
||||||
data: updatedData,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug(`Ungrouped ${blockIds.length} blocks from group ${groupId}`)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unsupported blocks operation: ${operation}`)
|
throw new Error(`Unsupported blocks operation: ${operation}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -465,70 +465,6 @@ export function setupOperationsHandlers(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
target === OPERATION_TARGETS.BLOCKS &&
|
|
||||||
operation === BLOCKS_OPERATIONS.GROUP_BLOCKS
|
|
||||||
) {
|
|
||||||
await persistWorkflowOperation(workflowId, {
|
|
||||||
operation,
|
|
||||||
target,
|
|
||||||
payload,
|
|
||||||
timestamp: operationTimestamp,
|
|
||||||
userId: session.userId,
|
|
||||||
})
|
|
||||||
|
|
||||||
room.lastModified = Date.now()
|
|
||||||
|
|
||||||
socket.to(workflowId).emit('workflow-operation', {
|
|
||||||
operation,
|
|
||||||
target,
|
|
||||||
payload,
|
|
||||||
timestamp: operationTimestamp,
|
|
||||||
senderId: socket.id,
|
|
||||||
userId: session.userId,
|
|
||||||
userName: session.userName,
|
|
||||||
metadata: { workflowId, operationId: crypto.randomUUID() },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (operationId) {
|
|
||||||
socket.emit('operation-confirmed', { operationId, serverTimestamp: Date.now() })
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
target === OPERATION_TARGETS.BLOCKS &&
|
|
||||||
operation === BLOCKS_OPERATIONS.UNGROUP_BLOCKS
|
|
||||||
) {
|
|
||||||
await persistWorkflowOperation(workflowId, {
|
|
||||||
operation,
|
|
||||||
target,
|
|
||||||
payload,
|
|
||||||
timestamp: operationTimestamp,
|
|
||||||
userId: session.userId,
|
|
||||||
})
|
|
||||||
|
|
||||||
room.lastModified = Date.now()
|
|
||||||
|
|
||||||
socket.to(workflowId).emit('workflow-operation', {
|
|
||||||
operation,
|
|
||||||
target,
|
|
||||||
payload,
|
|
||||||
timestamp: operationTimestamp,
|
|
||||||
senderId: socket.id,
|
|
||||||
userId: session.userId,
|
|
||||||
userName: session.userName,
|
|
||||||
metadata: { workflowId, operationId: crypto.randomUUID() },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (operationId) {
|
|
||||||
socket.emit('operation-confirmed', { operationId, serverTimestamp: Date.now() })
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (target === OPERATION_TARGETS.EDGES && operation === EDGES_OPERATIONS.BATCH_ADD_EDGES) {
|
if (target === OPERATION_TARGETS.EDGES && operation === EDGES_OPERATIONS.BATCH_ADD_EDGES) {
|
||||||
await persistWorkflowOperation(workflowId, {
|
await persistWorkflowOperation(workflowId, {
|
||||||
operation,
|
operation,
|
||||||
|
|||||||
@@ -30,8 +30,6 @@ const WRITE_OPERATIONS: string[] = [
|
|||||||
BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED,
|
BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED,
|
||||||
BLOCKS_OPERATIONS.BATCH_TOGGLE_HANDLES,
|
BLOCKS_OPERATIONS.BATCH_TOGGLE_HANDLES,
|
||||||
BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT,
|
BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT,
|
||||||
BLOCKS_OPERATIONS.GROUP_BLOCKS,
|
|
||||||
BLOCKS_OPERATIONS.UNGROUP_BLOCKS,
|
|
||||||
// Edge operations
|
// Edge operations
|
||||||
EDGE_OPERATIONS.ADD,
|
EDGE_OPERATIONS.ADD,
|
||||||
EDGE_OPERATIONS.REMOVE,
|
EDGE_OPERATIONS.REMOVE,
|
||||||
|
|||||||
@@ -221,30 +221,6 @@ export const BatchUpdateParentSchema = z.object({
|
|||||||
operationId: z.string().optional(),
|
operationId: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const GroupBlocksSchema = z.object({
|
|
||||||
operation: z.literal(BLOCKS_OPERATIONS.GROUP_BLOCKS),
|
|
||||||
target: z.literal(OPERATION_TARGETS.BLOCKS),
|
|
||||||
payload: z.object({
|
|
||||||
blockIds: z.array(z.string()),
|
|
||||||
groupId: z.string(),
|
|
||||||
name: z.string().optional(),
|
|
||||||
}),
|
|
||||||
timestamp: z.number(),
|
|
||||||
operationId: z.string().optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const UngroupBlocksSchema = z.object({
|
|
||||||
operation: z.literal(BLOCKS_OPERATIONS.UNGROUP_BLOCKS),
|
|
||||||
target: z.literal(OPERATION_TARGETS.BLOCKS),
|
|
||||||
payload: z.object({
|
|
||||||
groupId: z.string(),
|
|
||||||
blockIds: z.array(z.string()),
|
|
||||||
parentGroupId: z.string().optional(),
|
|
||||||
}),
|
|
||||||
timestamp: z.number(),
|
|
||||||
operationId: z.string().optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const WorkflowOperationSchema = z.union([
|
export const WorkflowOperationSchema = z.union([
|
||||||
BlockOperationSchema,
|
BlockOperationSchema,
|
||||||
BatchPositionUpdateSchema,
|
BatchPositionUpdateSchema,
|
||||||
@@ -253,8 +229,6 @@ export const WorkflowOperationSchema = z.union([
|
|||||||
BatchToggleEnabledSchema,
|
BatchToggleEnabledSchema,
|
||||||
BatchToggleHandlesSchema,
|
BatchToggleHandlesSchema,
|
||||||
BatchUpdateParentSchema,
|
BatchUpdateParentSchema,
|
||||||
GroupBlocksSchema,
|
|
||||||
UngroupBlocksSchema,
|
|
||||||
EdgeOperationSchema,
|
EdgeOperationSchema,
|
||||||
BatchAddEdgesSchema,
|
BatchAddEdgesSchema,
|
||||||
BatchRemoveEdgesSchema,
|
BatchRemoveEdgesSchema,
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ function captureWorkflowSnapshot(): WorkflowState {
|
|||||||
edges: rawState.edges || [],
|
edges: rawState.edges || [],
|
||||||
loops: rawState.loops || {},
|
loops: rawState.loops || {},
|
||||||
parallels: rawState.parallels || {},
|
parallels: rawState.parallels || {},
|
||||||
groups: rawState.groups || {},
|
|
||||||
lastSaved: Date.now(),
|
lastSaved: Date.now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,13 +27,11 @@ import {
|
|||||||
import { NavigateUIClientTool } from '@/lib/copilot/tools/client/navigation/navigate-ui'
|
import { NavigateUIClientTool } from '@/lib/copilot/tools/client/navigation/navigate-ui'
|
||||||
import { AuthClientTool } from '@/lib/copilot/tools/client/other/auth'
|
import { AuthClientTool } from '@/lib/copilot/tools/client/other/auth'
|
||||||
import { CheckoffTodoClientTool } from '@/lib/copilot/tools/client/other/checkoff-todo'
|
import { CheckoffTodoClientTool } from '@/lib/copilot/tools/client/other/checkoff-todo'
|
||||||
import { CrawlWebsiteClientTool } from '@/lib/copilot/tools/client/other/crawl-website'
|
|
||||||
import { CustomToolClientTool } from '@/lib/copilot/tools/client/other/custom-tool'
|
import { CustomToolClientTool } from '@/lib/copilot/tools/client/other/custom-tool'
|
||||||
import { DebugClientTool } from '@/lib/copilot/tools/client/other/debug'
|
import { DebugClientTool } from '@/lib/copilot/tools/client/other/debug'
|
||||||
import { DeployClientTool } from '@/lib/copilot/tools/client/other/deploy'
|
import { DeployClientTool } from '@/lib/copilot/tools/client/other/deploy'
|
||||||
import { EditClientTool } from '@/lib/copilot/tools/client/other/edit'
|
import { EditClientTool } from '@/lib/copilot/tools/client/other/edit'
|
||||||
import { EvaluateClientTool } from '@/lib/copilot/tools/client/other/evaluate'
|
import { EvaluateClientTool } from '@/lib/copilot/tools/client/other/evaluate'
|
||||||
import { GetPageContentsClientTool } from '@/lib/copilot/tools/client/other/get-page-contents'
|
|
||||||
import { InfoClientTool } from '@/lib/copilot/tools/client/other/info'
|
import { InfoClientTool } from '@/lib/copilot/tools/client/other/info'
|
||||||
import { KnowledgeClientTool } from '@/lib/copilot/tools/client/other/knowledge'
|
import { KnowledgeClientTool } from '@/lib/copilot/tools/client/other/knowledge'
|
||||||
import { MakeApiRequestClientTool } from '@/lib/copilot/tools/client/other/make-api-request'
|
import { MakeApiRequestClientTool } from '@/lib/copilot/tools/client/other/make-api-request'
|
||||||
@@ -42,7 +40,6 @@ import { OAuthRequestAccessClientTool } from '@/lib/copilot/tools/client/other/o
|
|||||||
import { PlanClientTool } from '@/lib/copilot/tools/client/other/plan'
|
import { PlanClientTool } from '@/lib/copilot/tools/client/other/plan'
|
||||||
import { RememberDebugClientTool } from '@/lib/copilot/tools/client/other/remember-debug'
|
import { RememberDebugClientTool } from '@/lib/copilot/tools/client/other/remember-debug'
|
||||||
import { ResearchClientTool } from '@/lib/copilot/tools/client/other/research'
|
import { ResearchClientTool } from '@/lib/copilot/tools/client/other/research'
|
||||||
import { ScrapePageClientTool } from '@/lib/copilot/tools/client/other/scrape-page'
|
|
||||||
import { SearchDocumentationClientTool } from '@/lib/copilot/tools/client/other/search-documentation'
|
import { SearchDocumentationClientTool } from '@/lib/copilot/tools/client/other/search-documentation'
|
||||||
import { SearchErrorsClientTool } from '@/lib/copilot/tools/client/other/search-errors'
|
import { SearchErrorsClientTool } from '@/lib/copilot/tools/client/other/search-errors'
|
||||||
import { SearchLibraryDocsClientTool } from '@/lib/copilot/tools/client/other/search-library-docs'
|
import { SearchLibraryDocsClientTool } from '@/lib/copilot/tools/client/other/search-library-docs'
|
||||||
@@ -123,9 +120,6 @@ const CLIENT_TOOL_INSTANTIATORS: Record<string, (id: string) => any> = {
|
|||||||
search_library_docs: (id) => new SearchLibraryDocsClientTool(id),
|
search_library_docs: (id) => new SearchLibraryDocsClientTool(id),
|
||||||
search_patterns: (id) => new SearchPatternsClientTool(id),
|
search_patterns: (id) => new SearchPatternsClientTool(id),
|
||||||
search_errors: (id) => new SearchErrorsClientTool(id),
|
search_errors: (id) => new SearchErrorsClientTool(id),
|
||||||
scrape_page: (id) => new ScrapePageClientTool(id),
|
|
||||||
get_page_contents: (id) => new GetPageContentsClientTool(id),
|
|
||||||
crawl_website: (id) => new CrawlWebsiteClientTool(id),
|
|
||||||
remember_debug: (id) => new RememberDebugClientTool(id),
|
remember_debug: (id) => new RememberDebugClientTool(id),
|
||||||
set_environment_variables: (id) => new SetEnvironmentVariablesClientTool(id),
|
set_environment_variables: (id) => new SetEnvironmentVariablesClientTool(id),
|
||||||
get_credentials: (id) => new GetCredentialsClientTool(id),
|
get_credentials: (id) => new GetCredentialsClientTool(id),
|
||||||
@@ -185,9 +179,6 @@ export const CLASS_TOOL_METADATA: Record<string, BaseClientToolMetadata | undefi
|
|||||||
search_library_docs: (SearchLibraryDocsClientTool as any)?.metadata,
|
search_library_docs: (SearchLibraryDocsClientTool as any)?.metadata,
|
||||||
search_patterns: (SearchPatternsClientTool as any)?.metadata,
|
search_patterns: (SearchPatternsClientTool as any)?.metadata,
|
||||||
search_errors: (SearchErrorsClientTool as any)?.metadata,
|
search_errors: (SearchErrorsClientTool as any)?.metadata,
|
||||||
scrape_page: (ScrapePageClientTool as any)?.metadata,
|
|
||||||
get_page_contents: (GetPageContentsClientTool as any)?.metadata,
|
|
||||||
crawl_website: (CrawlWebsiteClientTool as any)?.metadata,
|
|
||||||
remember_debug: (RememberDebugClientTool as any)?.metadata,
|
remember_debug: (RememberDebugClientTool as any)?.metadata,
|
||||||
set_environment_variables: (SetEnvironmentVariablesClientTool as any)?.metadata,
|
set_environment_variables: (SetEnvironmentVariablesClientTool as any)?.metadata,
|
||||||
get_credentials: (GetCredentialsClientTool as any)?.metadata,
|
get_credentials: (GetCredentialsClientTool as any)?.metadata,
|
||||||
@@ -1223,20 +1214,30 @@ const sseHandlers: Record<string, SSEHandler> = {
|
|||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
// Integration tools: Stay in pending state until user confirms via buttons
|
// Integration tools: Check if auto-allowed, otherwise wait for user confirmation
|
||||||
// This handles tools like google_calendar_*, exa_*, gmail_read, etc. that aren't in the client registry
|
// This handles tools like google_calendar_*, exa_*, etc. that aren't in the client registry
|
||||||
// Only relevant if mode is 'build' (agent)
|
// Only relevant if mode is 'build' (agent)
|
||||||
const { mode, workflowId } = get()
|
const { mode, workflowId, autoAllowedTools } = get()
|
||||||
if (mode === 'build' && workflowId) {
|
if (mode === 'build' && workflowId) {
|
||||||
// Check if tool was NOT found in client registry
|
// Check if tool was NOT found in client registry (def is undefined from above)
|
||||||
const def = name ? getTool(name) : undefined
|
const def = name ? getTool(name) : undefined
|
||||||
const inst = getClientTool(id) as any
|
const inst = getClientTool(id) as any
|
||||||
if (!def && !inst && name) {
|
if (!def && !inst && name) {
|
||||||
// Integration tools stay in pending state until user confirms
|
// Check if this tool is auto-allowed
|
||||||
logger.info('[build mode] Integration tool awaiting user confirmation', {
|
if (autoAllowedTools.includes(name)) {
|
||||||
id,
|
logger.info('[build mode] Integration tool auto-allowed, executing', { id, name })
|
||||||
name,
|
|
||||||
})
|
// Auto-execute the tool
|
||||||
|
setTimeout(() => {
|
||||||
|
get().executeIntegrationTool(id)
|
||||||
|
}, 0)
|
||||||
|
} else {
|
||||||
|
// Integration tools stay in pending state until user confirms
|
||||||
|
logger.info('[build mode] Integration tool awaiting user confirmation', {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1853,7 +1854,7 @@ const subAgentSSEHandlers: Record<string, SSEHandler> = {
|
|||||||
|
|
||||||
updateToolCallWithSubAgentData(context, get, set, parentToolCallId)
|
updateToolCallWithSubAgentData(context, get, set, parentToolCallId)
|
||||||
|
|
||||||
// Execute client tools in parallel (non-blocking) - same pattern as main tool_call handler
|
// Execute client tools (same logic as main tool_call handler)
|
||||||
try {
|
try {
|
||||||
const def = getTool(name)
|
const def = getTool(name)
|
||||||
if (def) {
|
if (def) {
|
||||||
@@ -1862,33 +1863,29 @@ const subAgentSSEHandlers: Record<string, SSEHandler> = {
|
|||||||
? !!def.hasInterrupt(args || {})
|
? !!def.hasInterrupt(args || {})
|
||||||
: !!def.hasInterrupt
|
: !!def.hasInterrupt
|
||||||
if (!hasInterrupt) {
|
if (!hasInterrupt) {
|
||||||
// Auto-execute tools without interrupts - non-blocking
|
// Auto-execute tools without interrupts
|
||||||
const ctx = createExecutionContext({ toolCallId: id, toolName: name })
|
const ctx = createExecutionContext({ toolCallId: id, toolName: name })
|
||||||
Promise.resolve()
|
try {
|
||||||
.then(() => def.execute(ctx, args || {}))
|
await def.execute(ctx, args || {})
|
||||||
.catch((execErr: any) => {
|
} catch (execErr: any) {
|
||||||
logger.error('[SubAgent] Tool execution failed', {
|
logger.error('[SubAgent] Tool execution failed', { id, name, error: execErr?.message })
|
||||||
id,
|
}
|
||||||
name,
|
|
||||||
error: execErr?.message,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback to class-based tools - non-blocking
|
// Fallback to class-based tools
|
||||||
const instance = getClientTool(id)
|
const instance = getClientTool(id)
|
||||||
if (instance) {
|
if (instance) {
|
||||||
const hasInterruptDisplays = !!instance.getInterruptDisplays?.()
|
const hasInterruptDisplays = !!instance.getInterruptDisplays?.()
|
||||||
if (!hasInterruptDisplays) {
|
if (!hasInterruptDisplays) {
|
||||||
Promise.resolve()
|
try {
|
||||||
.then(() => instance.execute(args || {}))
|
await instance.execute(args || {})
|
||||||
.catch((execErr: any) => {
|
} catch (execErr: any) {
|
||||||
logger.error('[SubAgent] Class tool execution failed', {
|
logger.error('[SubAgent] Class tool execution failed', {
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
error: execErr?.message,
|
error: execErr?.message,
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2518,13 +2515,6 @@ export const useCopilotStore = create<CopilotStore>()(
|
|||||||
// Call copilot API
|
// Call copilot API
|
||||||
const apiMode: 'ask' | 'agent' | 'plan' =
|
const apiMode: 'ask' | 'agent' | 'plan' =
|
||||||
mode === 'ask' ? 'ask' : mode === 'plan' ? 'plan' : 'agent'
|
mode === 'ask' ? 'ask' : mode === 'plan' ? 'plan' : 'agent'
|
||||||
|
|
||||||
// Extract slash commands from contexts (lowercase) and filter them out from contexts
|
|
||||||
const commands = contexts
|
|
||||||
?.filter((c) => c.kind === 'slash_command' && 'command' in c)
|
|
||||||
.map((c) => (c as any).command.toLowerCase()) as string[] | undefined
|
|
||||||
const filteredContexts = contexts?.filter((c) => c.kind !== 'slash_command')
|
|
||||||
|
|
||||||
const result = await sendStreamingMessage({
|
const result = await sendStreamingMessage({
|
||||||
message: messageToSend,
|
message: messageToSend,
|
||||||
userMessageId: userMessage.id,
|
userMessageId: userMessage.id,
|
||||||
@@ -2536,8 +2526,7 @@ export const useCopilotStore = create<CopilotStore>()(
|
|||||||
createNewChat: !currentChat,
|
createNewChat: !currentChat,
|
||||||
stream,
|
stream,
|
||||||
fileAttachments,
|
fileAttachments,
|
||||||
contexts: filteredContexts,
|
contexts,
|
||||||
commands: commands?.length ? commands : undefined,
|
|
||||||
abortSignal: abortController.signal,
|
abortSignal: abortController.signal,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -2629,14 +2618,13 @@ export const useCopilotStore = create<CopilotStore>()(
|
|||||||
),
|
),
|
||||||
isSendingMessage: false,
|
isSendingMessage: false,
|
||||||
isAborting: false,
|
isAborting: false,
|
||||||
// Keep abortController so streaming loop can check signal.aborted
|
abortController: null,
|
||||||
// It will be nulled when streaming completes or new message starts
|
|
||||||
}))
|
}))
|
||||||
} else {
|
} else {
|
||||||
set({
|
set({
|
||||||
isSendingMessage: false,
|
isSendingMessage: false,
|
||||||
isAborting: false,
|
isAborting: false,
|
||||||
// Keep abortController so streaming loop can check signal.aborted
|
abortController: null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2665,7 +2653,7 @@ export const useCopilotStore = create<CopilotStore>()(
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
set({ isSendingMessage: false, isAborting: false })
|
set({ isSendingMessage: false, isAborting: false, abortController: null })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -3166,7 +3154,6 @@ export const useCopilotStore = create<CopilotStore>()(
|
|||||||
: msg
|
: msg
|
||||||
),
|
),
|
||||||
isSendingMessage: false,
|
isSendingMessage: false,
|
||||||
isAborting: false,
|
|
||||||
abortController: null,
|
abortController: null,
|
||||||
currentUserMessageId: null,
|
currentUserMessageId: null,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -85,7 +85,6 @@ export type ChatContext =
|
|||||||
| { kind: 'knowledge'; knowledgeId?: string; label: string }
|
| { kind: 'knowledge'; knowledgeId?: string; label: string }
|
||||||
| { kind: 'templates'; templateId?: string; label: string }
|
| { kind: 'templates'; templateId?: string; label: string }
|
||||||
| { kind: 'docs'; label: string }
|
| { kind: 'docs'; label: string }
|
||||||
| { kind: 'slash_command'; command: string; label: string }
|
|
||||||
|
|
||||||
import type { CopilotChat as ApiCopilotChat } from '@/lib/copilot/api'
|
import type { CopilotChat as ApiCopilotChat } from '@/lib/copilot/api'
|
||||||
|
|
||||||
|
|||||||
@@ -126,23 +126,6 @@ export interface RejectDiffOperation extends BaseOperation {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GroupBlocksOperation extends BaseOperation {
|
|
||||||
type: typeof UNDO_REDO_OPERATIONS.GROUP_BLOCKS
|
|
||||||
data: {
|
|
||||||
groupId: string
|
|
||||||
blockIds: string[]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UngroupBlocksOperation extends BaseOperation {
|
|
||||||
type: typeof UNDO_REDO_OPERATIONS.UNGROUP_BLOCKS
|
|
||||||
data: {
|
|
||||||
groupId: string
|
|
||||||
blockIds: string[]
|
|
||||||
parentGroupId?: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Operation =
|
export type Operation =
|
||||||
| BatchAddBlocksOperation
|
| BatchAddBlocksOperation
|
||||||
| BatchRemoveBlocksOperation
|
| BatchRemoveBlocksOperation
|
||||||
@@ -156,8 +139,6 @@ export type Operation =
|
|||||||
| ApplyDiffOperation
|
| ApplyDiffOperation
|
||||||
| AcceptDiffOperation
|
| AcceptDiffOperation
|
||||||
| RejectDiffOperation
|
| RejectDiffOperation
|
||||||
| GroupBlocksOperation
|
|
||||||
| UngroupBlocksOperation
|
|
||||||
|
|
||||||
export interface OperationEntry {
|
export interface OperationEntry {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -6,10 +6,8 @@ import type {
|
|||||||
BatchRemoveBlocksOperation,
|
BatchRemoveBlocksOperation,
|
||||||
BatchRemoveEdgesOperation,
|
BatchRemoveEdgesOperation,
|
||||||
BatchUpdateParentOperation,
|
BatchUpdateParentOperation,
|
||||||
GroupBlocksOperation,
|
|
||||||
Operation,
|
Operation,
|
||||||
OperationEntry,
|
OperationEntry,
|
||||||
UngroupBlocksOperation,
|
|
||||||
} from '@/stores/undo-redo/types'
|
} from '@/stores/undo-redo/types'
|
||||||
|
|
||||||
export function createOperationEntry(operation: Operation, inverse: Operation): OperationEntry {
|
export function createOperationEntry(operation: Operation, inverse: Operation): OperationEntry {
|
||||||
@@ -166,30 +164,6 @@ export function createInverseOperation(operation: Operation): Operation {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
case UNDO_REDO_OPERATIONS.GROUP_BLOCKS: {
|
|
||||||
const op = operation as GroupBlocksOperation
|
|
||||||
return {
|
|
||||||
...operation,
|
|
||||||
type: UNDO_REDO_OPERATIONS.UNGROUP_BLOCKS,
|
|
||||||
data: {
|
|
||||||
groupId: op.data.groupId,
|
|
||||||
blockIds: op.data.blockIds,
|
|
||||||
},
|
|
||||||
} as UngroupBlocksOperation
|
|
||||||
}
|
|
||||||
|
|
||||||
case UNDO_REDO_OPERATIONS.UNGROUP_BLOCKS: {
|
|
||||||
const op = operation as UngroupBlocksOperation
|
|
||||||
return {
|
|
||||||
...operation,
|
|
||||||
type: UNDO_REDO_OPERATIONS.GROUP_BLOCKS,
|
|
||||||
data: {
|
|
||||||
groupId: op.data.groupId,
|
|
||||||
blockIds: op.data.blockIds,
|
|
||||||
},
|
|
||||||
} as GroupBlocksOperation
|
|
||||||
}
|
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
const exhaustiveCheck: never = operation
|
const exhaustiveCheck: never = operation
|
||||||
throw new Error(`Unhandled operation type: ${(exhaustiveCheck as Operation).type}`)
|
throw new Error(`Unhandled operation type: ${(exhaustiveCheck as Operation).type}`)
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ export function cloneWorkflowState(state: WorkflowState): WorkflowState {
|
|||||||
edges: structuredClone(state.edges || []),
|
edges: structuredClone(state.edges || []),
|
||||||
loops: structuredClone(state.loops || {}),
|
loops: structuredClone(state.loops || {}),
|
||||||
parallels: structuredClone(state.parallels || {}),
|
parallels: structuredClone(state.parallels || {}),
|
||||||
groups: structuredClone(state.groups || {}),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -298,26 +298,11 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
|||||||
let workflowState: any
|
let workflowState: any
|
||||||
|
|
||||||
if (workflowData?.state) {
|
if (workflowData?.state) {
|
||||||
const blocks = workflowData.state.blocks || {}
|
|
||||||
|
|
||||||
// Reconstruct groups from blocks' groupId data
|
|
||||||
const reconstructedGroups: Record<string, { id: string; blockIds: string[] }> = {}
|
|
||||||
Object.entries(blocks).forEach(([blockId, block]: [string, any]) => {
|
|
||||||
const groupId = block?.data?.groupId
|
|
||||||
if (groupId) {
|
|
||||||
if (!reconstructedGroups[groupId]) {
|
|
||||||
reconstructedGroups[groupId] = { id: groupId, blockIds: [] }
|
|
||||||
}
|
|
||||||
reconstructedGroups[groupId].blockIds.push(blockId)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
workflowState = {
|
workflowState = {
|
||||||
blocks,
|
blocks: workflowData.state.blocks || {},
|
||||||
edges: workflowData.state.edges || [],
|
edges: workflowData.state.edges || [],
|
||||||
loops: workflowData.state.loops || {},
|
loops: workflowData.state.loops || {},
|
||||||
parallels: workflowData.state.parallels || {},
|
parallels: workflowData.state.parallels || {},
|
||||||
groups: reconstructedGroups,
|
|
||||||
lastSaved: Date.now(),
|
lastSaved: Date.now(),
|
||||||
deploymentStatuses: {},
|
deploymentStatuses: {},
|
||||||
}
|
}
|
||||||
@@ -327,7 +312,6 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
|||||||
edges: [],
|
edges: [],
|
||||||
loops: {},
|
loops: {},
|
||||||
parallels: {},
|
parallels: {},
|
||||||
groups: {},
|
|
||||||
deploymentStatuses: {},
|
deploymentStatuses: {},
|
||||||
lastSaved: Date.now(),
|
lastSaved: Date.now(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,6 @@ const initialState = {
|
|||||||
edges: [],
|
edges: [],
|
||||||
loops: {},
|
loops: {},
|
||||||
parallels: {},
|
parallels: {},
|
||||||
groups: {},
|
|
||||||
lastSaved: undefined,
|
lastSaved: undefined,
|
||||||
deploymentStatuses: {},
|
deploymentStatuses: {},
|
||||||
needsRedeployment: false,
|
needsRedeployment: false,
|
||||||
@@ -578,7 +577,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
|||||||
edges: state.edges,
|
edges: state.edges,
|
||||||
loops: state.loops,
|
loops: state.loops,
|
||||||
parallels: state.parallels,
|
parallels: state.parallels,
|
||||||
groups: state.groups,
|
|
||||||
lastSaved: state.lastSaved,
|
lastSaved: state.lastSaved,
|
||||||
deploymentStatuses: state.deploymentStatuses,
|
deploymentStatuses: state.deploymentStatuses,
|
||||||
needsRedeployment: state.needsRedeployment,
|
needsRedeployment: state.needsRedeployment,
|
||||||
@@ -599,7 +597,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
|||||||
Object.keys(workflowState.parallels || {}).length > 0
|
Object.keys(workflowState.parallels || {}).length > 0
|
||||||
? workflowState.parallels
|
? workflowState.parallels
|
||||||
: generateParallelBlocks(nextBlocks)
|
: generateParallelBlocks(nextBlocks)
|
||||||
const nextGroups = workflowState.groups || state.groups
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
@@ -607,7 +604,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
|||||||
edges: nextEdges,
|
edges: nextEdges,
|
||||||
loops: nextLoops,
|
loops: nextLoops,
|
||||||
parallels: nextParallels,
|
parallels: nextParallels,
|
||||||
groups: nextGroups,
|
|
||||||
deploymentStatuses: workflowState.deploymentStatuses || state.deploymentStatuses,
|
deploymentStatuses: workflowState.deploymentStatuses || state.deploymentStatuses,
|
||||||
needsRedeployment:
|
needsRedeployment:
|
||||||
workflowState.needsRedeployment !== undefined
|
workflowState.needsRedeployment !== undefined
|
||||||
@@ -1337,126 +1333,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
|||||||
getDragStartPosition: () => {
|
getDragStartPosition: () => {
|
||||||
return get().dragStartPosition || null
|
return get().dragStartPosition || null
|
||||||
},
|
},
|
||||||
|
|
||||||
groupBlocks: (blockIds: string[], groupId?: string) => {
|
|
||||||
if (blockIds.length === 0) return ''
|
|
||||||
|
|
||||||
const newGroupId = groupId || crypto.randomUUID()
|
|
||||||
const currentGroups = get().groups || {}
|
|
||||||
const currentBlocks = get().blocks
|
|
||||||
|
|
||||||
// Create the new group with all selected block IDs
|
|
||||||
const updatedGroups = { ...currentGroups }
|
|
||||||
updatedGroups[newGroupId] = {
|
|
||||||
id: newGroupId,
|
|
||||||
blockIds: [...blockIds],
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update blocks: set groupId and push to groupStack
|
|
||||||
const newBlocks = { ...currentBlocks }
|
|
||||||
for (const blockId of blockIds) {
|
|
||||||
if (newBlocks[blockId]) {
|
|
||||||
const currentData = newBlocks[blockId].data || {}
|
|
||||||
const currentStack = Array.isArray(currentData.groupStack) ? currentData.groupStack : []
|
|
||||||
newBlocks[blockId] = {
|
|
||||||
...newBlocks[blockId],
|
|
||||||
data: {
|
|
||||||
...currentData,
|
|
||||||
groupId: newGroupId,
|
|
||||||
groupStack: [...currentStack, newGroupId],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
set({
|
|
||||||
blocks: newBlocks,
|
|
||||||
groups: updatedGroups,
|
|
||||||
})
|
|
||||||
|
|
||||||
get().updateLastSaved()
|
|
||||||
logger.info('Created block group', {
|
|
||||||
groupId: newGroupId,
|
|
||||||
blockCount: blockIds.length,
|
|
||||||
})
|
|
||||||
return newGroupId
|
|
||||||
},
|
|
||||||
|
|
||||||
ungroupBlocks: (groupId: string) => {
|
|
||||||
const currentGroups = get().groups || {}
|
|
||||||
const currentBlocks = get().blocks
|
|
||||||
const group = currentGroups[groupId]
|
|
||||||
|
|
||||||
if (!group) {
|
|
||||||
logger.warn('Attempted to ungroup non-existent group', { groupId })
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
const blockIds = [...group.blockIds]
|
|
||||||
|
|
||||||
// Remove the group from the groups record
|
|
||||||
const updatedGroups = { ...currentGroups }
|
|
||||||
delete updatedGroups[groupId]
|
|
||||||
|
|
||||||
// Update blocks: pop from groupStack and set groupId to the previous level
|
|
||||||
const newBlocks = { ...currentBlocks }
|
|
||||||
for (const blockId of blockIds) {
|
|
||||||
if (newBlocks[blockId]) {
|
|
||||||
const currentData = { ...newBlocks[blockId].data }
|
|
||||||
const currentStack = Array.isArray(currentData.groupStack)
|
|
||||||
? [...currentData.groupStack]
|
|
||||||
: []
|
|
||||||
|
|
||||||
// Pop the current groupId from the stack
|
|
||||||
if (currentStack.length > 0 && currentStack[currentStack.length - 1] === groupId) {
|
|
||||||
currentStack.pop()
|
|
||||||
}
|
|
||||||
|
|
||||||
// The new groupId is the top of the remaining stack, or undefined if empty
|
|
||||||
const newGroupId =
|
|
||||||
currentStack.length > 0 ? currentStack[currentStack.length - 1] : undefined
|
|
||||||
|
|
||||||
if (newGroupId) {
|
|
||||||
currentData.groupId = newGroupId
|
|
||||||
currentData.groupStack = currentStack
|
|
||||||
} else {
|
|
||||||
// Remove groupId and groupStack if stack is empty
|
|
||||||
delete currentData.groupId
|
|
||||||
delete currentData.groupStack
|
|
||||||
}
|
|
||||||
|
|
||||||
newBlocks[blockId] = {
|
|
||||||
...newBlocks[blockId],
|
|
||||||
data: currentData,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
set({
|
|
||||||
blocks: newBlocks,
|
|
||||||
groups: updatedGroups,
|
|
||||||
})
|
|
||||||
|
|
||||||
get().updateLastSaved()
|
|
||||||
logger.info('Ungrouped blocks', {
|
|
||||||
groupId,
|
|
||||||
blockCount: blockIds.length,
|
|
||||||
})
|
|
||||||
return blockIds
|
|
||||||
},
|
|
||||||
|
|
||||||
getGroupBlockIds: (groupId: string) => {
|
|
||||||
const groups = get().groups || {}
|
|
||||||
const group = groups[groupId]
|
|
||||||
|
|
||||||
if (!group) return []
|
|
||||||
|
|
||||||
return [...group.blockIds]
|
|
||||||
},
|
|
||||||
|
|
||||||
getGroups: () => {
|
|
||||||
return get().groups || {}
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
{ name: 'workflow-store' }
|
{ name: 'workflow-store' }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -63,11 +63,6 @@ export interface BlockData {
|
|||||||
|
|
||||||
// Container node type (for ReactFlow node type determination)
|
// Container node type (for ReactFlow node type determination)
|
||||||
type?: string
|
type?: string
|
||||||
|
|
||||||
// Block group membership
|
|
||||||
groupId?: string
|
|
||||||
/** Stack of group IDs for hierarchical grouping (oldest to newest) */
|
|
||||||
groupStack?: string[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BlockLayoutState {
|
export interface BlockLayoutState {
|
||||||
@@ -149,20 +144,6 @@ export interface Variable {
|
|||||||
value: unknown
|
value: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents a group of blocks on the canvas.
|
|
||||||
* Groups can be nested (a group can contain other groups via block membership).
|
|
||||||
* When a block is in a group, it stores the groupId in its data.
|
|
||||||
*/
|
|
||||||
export interface BlockGroup {
|
|
||||||
/** Unique identifier for the group */
|
|
||||||
id: string
|
|
||||||
/** Optional display name for the group */
|
|
||||||
name?: string
|
|
||||||
/** Block IDs that are direct members of this group */
|
|
||||||
blockIds: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DragStartPosition {
|
export interface DragStartPosition {
|
||||||
id: string
|
id: string
|
||||||
x: number
|
x: number
|
||||||
@@ -176,8 +157,6 @@ export interface WorkflowState {
|
|||||||
lastSaved?: number
|
lastSaved?: number
|
||||||
loops: Record<string, Loop>
|
loops: Record<string, Loop>
|
||||||
parallels: Record<string, Parallel>
|
parallels: Record<string, Parallel>
|
||||||
/** Block groups for organizing blocks on the canvas */
|
|
||||||
groups?: Record<string, BlockGroup>
|
|
||||||
lastUpdate?: number
|
lastUpdate?: number
|
||||||
metadata?: {
|
metadata?: {
|
||||||
name?: string
|
name?: string
|
||||||
@@ -264,28 +243,6 @@ export interface WorkflowActions {
|
|||||||
workflowState: WorkflowState,
|
workflowState: WorkflowState,
|
||||||
options?: { updateLastSaved?: boolean }
|
options?: { updateLastSaved?: boolean }
|
||||||
) => void
|
) => void
|
||||||
|
|
||||||
// Block group operations
|
|
||||||
/**
|
|
||||||
* Groups the specified blocks together.
|
|
||||||
* If any blocks are already in a group, they are removed from their current group first.
|
|
||||||
* @returns The new group ID
|
|
||||||
*/
|
|
||||||
groupBlocks: (blockIds: string[], groupId?: string) => string
|
|
||||||
/**
|
|
||||||
* Ungroups a group, removing it and releasing its blocks.
|
|
||||||
* If the group has a parent group, blocks are moved to the parent group.
|
|
||||||
* @returns The block IDs that were in the group
|
|
||||||
*/
|
|
||||||
ungroupBlocks: (groupId: string) => string[]
|
|
||||||
/**
|
|
||||||
* Gets all block IDs in a group, including blocks in nested groups (recursive).
|
|
||||||
*/
|
|
||||||
getGroupBlockIds: (groupId: string, recursive?: boolean) => string[]
|
|
||||||
/**
|
|
||||||
* Gets all groups in the workflow.
|
|
||||||
*/
|
|
||||||
getGroups: () => Record<string, BlockGroup>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WorkflowStore = WorkflowState & WorkflowActions
|
export type WorkflowStore = WorkflowState & WorkflowActions
|
||||||
|
|||||||
Reference in New Issue
Block a user