Compare commits

..

26 Commits

Author SHA1 Message Date
Waleed
5e8c843241 v0.5.59: a2a support, documentation 2026-01-13 13:21:21 -08:00
Waleed
7bf3d73ee6 v0.5.58: export folders, new tools, permissions groups enhancements 2026-01-13 00:56:59 -08:00
Vikhyath Mondreti
7ffc11a738 v0.5.57: subagents, context menu improvements, bug fixes 2026-01-11 11:38:40 -08:00
Waleed
be578e2ed7 v0.5.56: batch operations, access control and permission groups, billing fixes 2026-01-10 00:31:34 -08:00
Waleed
f415e5edc4 v0.5.55: polling groups, bedrock provider, devcontainer fixes, workflow preview enhancements 2026-01-08 23:36:56 -08:00
Waleed
13a6e6c3fa v0.5.54: seo, model blacklist, helm chart updates, fireflies integration, autoconnect improvements, billing fixes 2026-01-07 16:09:45 -08:00
Waleed
f5ab7f21ae v0.5.53: hotkey improvements, added redis fallback, fixes for workflow tool 2026-01-06 23:34:52 -08:00
Waleed
bfb6fffe38 v0.5.52: new port-based router block, combobox expression and variable support 2026-01-06 16:14:10 -08:00
Waleed
4fbec0a43f v0.5.51: triggers, kb, condition block improvements, supabase and grain integration updates 2026-01-06 14:26:46 -08:00
Waleed
585f5e365b v0.5.50: import improvements, ui upgrades, kb styling and performance improvements 2026-01-05 00:35:55 -08:00
Waleed
3792bdd252 v0.5.49: hitl improvements, new email styles, imap trigger, logs context menu (#2672)
* feat(logs-context-menu): consolidated logs utils and types, added logs record context menu (#2659)

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

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

* improvement(emails): links, accounts, preview

* refactor(emails): file structure and wrapper components

* added envvar for personal emails sent, added isHosted gate

* fixed failing tests, added env mock

* fix: removed comment

---------

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

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

* hitl gaps

* deal with trigger worker crashes

* cleanup import strcuture

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

* feat(tools): added support for imap trigger

* feat(imap): added parity, tested

* ack PR comments

* final cleanup

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

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

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

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

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

* feat(admin): routes to manage deployments

* fix naming fo deployed by

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

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

* removed unused params, cleaned up redundant utils

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

* improvement(invite): aligned with rest of app

* fix(invite): error handling

* fix: addressed comments

---------

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

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

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

View File

@@ -76,14 +76,6 @@
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
* Light mode: Warm theme

View File

@@ -97,7 +97,6 @@ const ChatMessageSchema = z.object({
})
)
.optional(),
commands: z.array(z.string()).optional(),
})
/**
@@ -133,7 +132,6 @@ export async function POST(req: NextRequest) {
provider,
conversationId,
contexts,
commands,
} = ChatMessageSchema.parse(body)
// Ensure we have a consistent user message ID for this request
const userMessageIdToUse = userMessageId || crypto.randomUUID()
@@ -464,7 +462,6 @@ export async function POST(req: NextRequest) {
...(integrationTools.length > 0 && { tools: integrationTools }),
...(baseTools.length > 0 && { baseTools }),
...(credentials && { credentials }),
...(commands && commands.length > 0 && { commands }),
}
try {

View File

@@ -29,8 +29,6 @@ export function BlockContextMenu({
onRemoveFromSubflow,
onOpenEditor,
onRename,
onGroupBlocks,
onUngroupBlocks,
hasClipboard = false,
showRemoveFromSubflow = false,
disableEdit = false,
@@ -49,14 +47,6 @@ export function BlockContextMenu({
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 = () => {
if (allEnabled) return 'Disable'
if (allDisabled) return 'Enable'
@@ -151,31 +141,6 @@ export function BlockContextMenu({
</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 */}
{isSingleBlock && <PopoverDivider />}
{isSingleBlock && !isSubflow && (

View File

@@ -24,8 +24,6 @@ export interface ContextMenuBlockInfo {
parentId?: string
/** Parent type ('loop' | 'parallel') if nested */
parentType?: string
/** Group ID if block is in a group */
groupId?: string
}
/**
@@ -52,8 +50,6 @@ export interface BlockContextMenuProps {
onRemoveFromSubflow: () => void
onOpenEditor: () => void
onRename: () => void
onGroupBlocks: () => void
onUngroupBlocks: () => void
/** Whether clipboard has content for pasting */
hasClipboard?: boolean
/** Whether remove from subflow option should be shown */

View File

@@ -1,6 +1,6 @@
import { memo, useCallback, useMemo } from 'react'
import ReactMarkdown from 'react-markdown'
import { type NodeProps, useReactFlow } from 'reactflow'
import type { NodeProps } from 'reactflow'
import remarkGfm from 'remark-gfm'
import { cn } from '@/lib/core/utils/cn'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
@@ -10,7 +10,6 @@ import {
useBlockDimensions,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { ActionBar } from '../workflow-block/components'
import type { WorkflowBlockProps } from '../workflow-block/types'
@@ -199,57 +198,6 @@ export const NoteBlock = memo(function NoteBlock({ id, data }: NodeProps<NoteBlo
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.
* 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],
})
const isGroupedSelection = data.isGroupedSelection ?? false
return (
<div
className='group relative'
data-grouped-selection={isGroupedSelection ? 'true' : undefined}
onMouseDown={handleGroupMouseDown}
>
<div className='group relative'>
<div
className={cn(
'relative z-[20] w-[250px] cursor-default select-none rounded-[8px] border border-[var(--border)] bg-[var(--surface-2)]'

View File

@@ -1,6 +1,6 @@
'use client'
import React, { memo, useCallback, useState } from 'react'
import React, { useEffect, useMemo, useState } from 'react'
import { Check, Copy } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
@@ -28,95 +28,55 @@ const getTextContent = (element: React.ReactNode): string => {
return ''
}
/**
* Maps common language aliases to supported viewer languages
*/
const LANGUAGE_MAP: Record<string, 'javascript' | 'json' | 'python'> = {
js: 'javascript',
javascript: 'javascript',
jsx: 'javascript',
ts: 'javascript',
typescript: 'javascript',
tsx: 'javascript',
json: 'json',
python: 'python',
py: 'python',
code: 'javascript',
// Global layout fixes for markdown content inside the copilot panel
if (typeof document !== 'undefined') {
const styleId = 'copilot-markdown-fix'
if (!document.getElementById(styleId)) {
const style = document.createElement('style')
style.id = styleId
style.textContent = `
/* Prevent any markdown content from expanding beyond the panel */
.copilot-markdown-wrapper,
.copilot-markdown-wrapper * {
max-width: 100% !important;
}
.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
* 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({
href,
children,
}: {
href: string
children: React.ReactNode
}) {
function LinkWithPreview({ href, children }: { href: string; children: React.ReactNode }) {
return (
<Tooltip.Root delayDuration={300}>
<Tooltip.Trigger asChild>
@@ -134,7 +94,7 @@ const LinkWithPreview = memo(function LinkWithPreview({
</Tooltip.Content>
</Tooltip.Root>
)
})
}
/**
* Props for the CopilotMarkdownRenderer component
@@ -144,197 +104,275 @@ interface CopilotMarkdownRendererProps {
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
* 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
* @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 (
<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}>
{content}
</ReactMarkdown>
</div>
)
}
export default memo(CopilotMarkdownRenderer)

View File

@@ -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'
/**
* Minimum delay between characters (fast catch-up mode)
* Character animation delay in milliseconds
*/
const MIN_DELAY = 1
/**
* Maximum delay between characters (when waiting for content)
*/
const MAX_DELAY = 12
/**
* Default delay when streaming normally
*/
const DEFAULT_DELAY = 4
/**
* How far behind (in characters) before we speed up
*/
const CATCH_UP_THRESHOLD = 20
/**
* How close to content before we slow down
*/
const SLOW_DOWN_THRESHOLD = 5
const CHARACTER_DELAY = 3
/**
* 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
*/
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='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]' />
@@ -54,39 +34,9 @@ interface SmoothStreamingTextProps {
isStreaming: boolean
}
/**
* Calculates adaptive delay based on how far behind animation is from actual content
*
* @param displayedLength - Current displayed content length
* @param totalLength - Total available content length
* @returns Delay in milliseconds
*/
function calculateAdaptiveDelay(displayedLength: number, totalLength: number): number {
const charsRemaining = totalLength - displayedLength
if (charsRemaining > CATCH_UP_THRESHOLD) {
// Far behind - speed up to catch up
// Scale from MIN_DELAY to DEFAULT_DELAY based on how far behind
const catchUpFactor = Math.min(1, (charsRemaining - CATCH_UP_THRESHOLD) / 50)
return MIN_DELAY + (DEFAULT_DELAY - MIN_DELAY) * (1 - catchUpFactor)
}
if (charsRemaining <= SLOW_DOWN_THRESHOLD) {
// Close to content edge - slow down to feel natural
// The closer we are, the slower we go (up to MAX_DELAY)
const slowFactor = 1 - charsRemaining / SLOW_DOWN_THRESHOLD
return DEFAULT_DELAY + (MAX_DELAY - DEFAULT_DELAY) * slowFactor
}
// Normal streaming speed
return DEFAULT_DELAY
}
/**
* SmoothStreamingText component displays text with character-by-character animation
* Creates a smooth streaming effect for AI responses with adaptive speed
*
* Uses adaptive pacing: speeds up when catching up, slows down near content edge
* Creates a smooth streaming effect for AI responses
*
* @param props - Component props
* @returns Streaming text with smooth animation
@@ -95,73 +45,74 @@ export const SmoothStreamingText = memo(
({ content, isStreaming }: SmoothStreamingTextProps) => {
const [displayedContent, setDisplayedContent] = useState('')
const contentRef = useRef(content)
const rafRef = useRef<number | null>(null)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const indexRef = useRef(0)
const lastFrameTimeRef = useRef<number>(0)
const streamingStartTimeRef = useRef<number | null>(null)
const isAnimatingRef = useRef(false)
/**
* Handles content streaming animation
* Updates displayed content character by character during streaming
*/
useEffect(() => {
contentRef.current = content
if (content.length === 0) {
setDisplayedContent('')
indexRef.current = 0
streamingStartTimeRef.current = null
return
}
if (isStreaming) {
if (indexRef.current < content.length && !isAnimatingRef.current) {
isAnimatingRef.current = true
lastFrameTimeRef.current = performance.now()
if (streamingStartTimeRef.current === null) {
streamingStartTimeRef.current = Date.now()
}
const animateText = (timestamp: number) => {
if (indexRef.current < content.length) {
const animateText = () => {
const currentContent = contentRef.current
const currentIndex = indexRef.current
const elapsed = timestamp - lastFrameTimeRef.current
// Calculate adaptive delay based on how far behind we are
const delay = calculateAdaptiveDelay(currentIndex, currentContent.length)
if (currentIndex < currentContent.length) {
const chunkSize = 1
const newDisplayed = currentContent.slice(0, currentIndex + chunkSize)
if (elapsed >= delay) {
if (currentIndex < currentContent.length) {
const newDisplayed = currentContent.slice(0, currentIndex + 1)
setDisplayedContent(newDisplayed)
indexRef.current = currentIndex + 1
lastFrameTimeRef.current = timestamp
}
}
setDisplayedContent(newDisplayed)
indexRef.current = currentIndex + chunkSize
if (indexRef.current < currentContent.length) {
rafRef.current = requestAnimationFrame(animateText)
timeoutRef.current = setTimeout(animateText, CHARACTER_DELAY)
} else {
isAnimatingRef.current = false
}
}
rafRef.current = requestAnimationFrame(animateText)
} else if (indexRef.current < content.length && isAnimatingRef.current) {
// Animation already running, it will pick up new content automatically
if (!isAnimatingRef.current) {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
isAnimatingRef.current = true
animateText()
}
}
} else {
// Streaming ended - show full content immediately
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
}
setDisplayedContent(content)
indexRef.current = content.length
isAnimatingRef.current = false
streamingStartTimeRef.current = null
}
return () => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
isAnimatingRef.current = false
}
}, [content, isStreaming])
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} />
</div>
)
@@ -170,6 +121,7 @@ export const SmoothStreamingText = memo(
// Prevent re-renders during streaming unless content actually changed
return (
prevProps.content === nextProps.content && prevProps.isStreaming === nextProps.isStreaming
// markdownComponents is now memoized so no need to compare
)
}
)

View File

@@ -1,6 +1,6 @@
'use client'
import { memo, useEffect, useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
import { ChevronUp } from 'lucide-react'
import CopilotMarkdownRenderer from './markdown-renderer'
@@ -8,151 +8,18 @@ import CopilotMarkdownRenderer from './markdown-renderer'
/**
* Max height for thinking content before internal scrolling kicks in
*/
const THINKING_MAX_HEIGHT = 150
/**
* Height threshold before gradient fade kicks in
*/
const GRADIENT_THRESHOLD = 100
const THINKING_MAX_HEIGHT = 200
/**
* Interval for auto-scroll during streaming (ms)
*/
const SCROLL_INTERVAL = 50
const SCROLL_INTERVAL = 100
/**
* Timer update interval in milliseconds
*/
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
*/
@@ -199,8 +66,8 @@ export function ThinkingBlock({
* Auto-collapses when streaming ends OR when following content arrives
*/
useEffect(() => {
// Collapse if streaming ended, there's following content, or special tags arrived
if (!isStreaming || hasFollowingContent || hasSpecialTags) {
// Collapse if streaming ended or if there's following content (like a tool call)
if (!isStreaming || hasFollowingContent) {
setIsExpanded(false)
userCollapsedRef.current = false
setUserHasScrolledAway(false)
@@ -210,7 +77,7 @@ export function ThinkingBlock({
if (!userCollapsedRef.current && content && content.trim().length > 0) {
setIsExpanded(true)
}
}, [isStreaming, content, hasFollowingContent, hasSpecialTags])
}, [isStreaming, content, hasFollowingContent])
// Reset start time when streaming begins
useEffect(() => {
@@ -246,14 +113,14 @@ export function ThinkingBlock({
const isNearBottom = distanceFromBottom <= 20
const delta = scrollTop - lastScrollTopRef.current
const movedUp = delta < -1
const movedUp = delta < -2
if (movedUp && !isNearBottom) {
setUserHasScrolledAway(true)
}
// Re-stick if user scrolls back to bottom with intent
if (userHasScrolledAway && isNearBottom && delta > 10) {
// Re-stick if user scrolls back to bottom
if (userHasScrolledAway && isNearBottom) {
setUserHasScrolledAway(false)
}
@@ -266,7 +133,7 @@ export function ThinkingBlock({
return () => container.removeEventListener('scroll', handleScroll)
}, [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(() => {
if (!isStreaming || !isExpanded || userHasScrolledAway) return
@@ -274,14 +141,20 @@ export function ThinkingBlock({
const container = scrollContainerRef.current
if (!container) return
programmaticScrollRef.current = true
container.scrollTo({
top: container.scrollHeight,
behavior: 'auto',
})
window.setTimeout(() => {
programmaticScrollRef.current = false
}, 16)
const { scrollTop, scrollHeight, clientHeight } = container
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
const isNearBottom = distanceFromBottom <= 50
if (isNearBottom) {
programmaticScrollRef.current = true
container.scrollTo({
top: container.scrollHeight,
behavior: 'smooth',
})
window.setTimeout(() => {
programmaticScrollRef.current = false
}, 150)
}
}, SCROLL_INTERVAL)
return () => window.clearInterval(intervalId)
@@ -368,11 +241,15 @@ export function ThinkingBlock({
<div
ref={scrollContainerRef}
className={clsx(
'overflow-y-auto transition-all duration-150 ease-out',
isExpanded ? 'mt-1.5 max-h-[150px] opacity-100' : 'max-h-0 opacity-0'
'overflow-y-auto transition-all duration-300 ease-in-out',
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>
)
@@ -404,12 +281,12 @@ export function ThinkingBlock({
<div
ref={scrollContainerRef}
className={clsx(
'overflow-y-auto transition-all duration-150 ease-out',
isExpanded ? 'mt-1.5 max-h-[150px] opacity-100' : 'max-h-0 opacity-0'
'overflow-y-auto transition-all duration-300 ease-in-out',
isExpanded ? 'mt-1.5 max-h-[200px] opacity-100' : 'max-h-0 opacity-0'
)}
>
{/* Completed thinking text - dimmed with markdown */}
<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)]'>
{/* Use markdown renderer for completed content */}
<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} />
</div>
</div>

View File

@@ -187,7 +187,6 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
)
// Memoize content blocks to avoid re-rendering unchanged blocks
// No entrance animations to prevent layout shift
const memoizedContentBlocks = useMemo(() => {
if (!message.contentBlocks || message.contentBlocks.length === 0) {
return null
@@ -206,10 +205,14 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
// Use smooth streaming for the last text block if we're streaming
const shouldUseSmoothing = isStreaming && isLastTextBlock
const blockKey = `text-${index}-${block.timestamp || index}`
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 ? (
<SmoothStreamingText content={cleanBlockContent} isStreaming={isStreaming} />
) : (
@@ -221,33 +224,29 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
if (block.type === 'thinking') {
// Check if there are any blocks after this one (tool calls, text, etc.)
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 (
<div key={blockKey} className='w-full'>
<div key={`thinking-${index}-${block.timestamp || index}`} className='w-full'>
<ThinkingBlock
content={block.content}
isStreaming={isStreaming}
hasFollowingContent={hasFollowingContent}
hasSpecialTags={hasSpecialTags}
/>
</div>
)
}
if (block.type === 'tool_call') {
const blockKey = `tool-${block.toolCall.id}`
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} />
</div>
)
}
return null
})
}, [message.contentBlocks, isStreaming, parsedTags])
}, [message.contentBlocks, isStreaming])
if (isUser) {
return (
@@ -280,7 +279,6 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
onModeChange={setMode}
panelWidth={panelWidth}
clearOnSubmit={false}
initialContexts={message.contexts}
/>
{/* 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)
? ((message as any).contexts as any[])
: []
// Build tokens with their prefixes (@ for mentions, / for commands)
const tokens = contexts
.filter((c) => c?.kind !== 'current_workflow' && c?.label)
.map((c) => {
const prefix = c?.kind === 'slash_command' ? '/' : '@'
return `${prefix}${c.label}`
})
if (!tokens.length) return text
const labels = contexts
.filter((c) => c?.kind !== 'current_workflow')
.map((c) => c?.label)
.filter(Boolean) as string[]
if (!labels.length) return text
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[] = []
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) {
return (
<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}
>
<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 */}
{memoizedContentBlocks}
{/* Streaming indicator always at bottom during streaming */}
{/* Always show streaming indicator at the end while streaming */}
{isStreaming && <StreamingIndicator />}
{message.errorType === 'usage_limit' && (

View File

@@ -497,11 +497,6 @@ const ACTION_VERBS = [
'Accessed',
'Managing',
'Managed',
'Scraping',
'Scraped',
'Crawling',
'Crawled',
'Getting',
] as const
/**
@@ -1066,7 +1061,7 @@ function SubAgentContent({
<div
ref={scrollContainerRef}
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'
)}
>
@@ -1162,10 +1157,10 @@ function SubAgentThinkingContent({
/**
* Subagents that should collapse when done streaming.
* Default behavior is to NOT collapse (stay expanded like edit, superagent, info, etc.).
* Only plan, debug, and research collapse into summary headers.
* Default behavior is to NOT collapse (stay expanded like edit).
* 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.
@@ -1326,7 +1321,7 @@ function SubagentContentRenderer({
<div
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'
)}
>
@@ -1636,8 +1631,10 @@ function WorkflowEditSummary({ toolCall }: { toolCall: CopilotToolCall }) {
* Checks if a tool is an integration tool (server-side executed, not a client tool)
*/
function isIntegrationTool(toolName: string): boolean {
// Any tool NOT in CLASS_TOOL_METADATA is an integration tool (server-side execution)
return !CLASS_TOOL_METADATA[toolName]
// Check if it's NOT a client tool (not in CLASS_TOOL_METADATA and not in registered tools)
const isClientTool = !!CLASS_TOOL_METADATA[toolName]
const isRegisteredTool = !!getRegisteredTools()[toolName]
return !isClientTool && !isRegisteredTool
}
function shouldShowRunSkipButtons(toolCall: CopilotToolCall): boolean {
@@ -1666,9 +1663,16 @@ function shouldShowRunSkipButtons(toolCall: CopilotToolCall): boolean {
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
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
}
@@ -1891,20 +1895,15 @@ function RunSkipButtons({
if (buttonsHidden) return null
// Hide "Always Allow" for integration tools (only show for client tools with interrupts)
const showAlwaysAllow = !isIntegrationTool(toolCall.name)
// Standardized buttons for all interrupt tools: Allow, (Always Allow for client tools only), Skip
// Standardized buttons for all interrupt tools: Allow, Always Allow, Skip
return (
<div className='mt-1.5 flex gap-[6px]'>
<Button onClick={onRun} disabled={isProcessing} variant='tertiary'>
{isProcessing ? 'Allowing...' : 'Allow'}
</Button>
{showAlwaysAllow && (
<Button onClick={onAlwaysAllow} disabled={isProcessing} variant='default'>
{isProcessing ? 'Allowing...' : 'Always Allow'}
</Button>
)}
<Button onClick={onAlwaysAllow} disabled={isProcessing} variant='default'>
{isProcessing ? 'Allowing...' : 'Always Allow'}
</Button>
<Button onClick={onSkip} disabled={isProcessing} variant='default'>
Skip
</Button>
@@ -1970,7 +1969,6 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
'tour',
'info',
'workflow',
'superagent',
]
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 (
<div className='w-full'>
{!hideTextForEditWorkflow && (
<div className={isToolNameClickable ? 'cursor-pointer' : ''} onClick={handleToolNameClick}>
<ShimmerOverlayText
text={displayName}
active={isLoadingState}
isSpecial={isSpecial}
className='font-[470] font-season text-[var(--text-secondary)] text-sm dark:text-[var(--text-muted)]'
/>
</div>
)}
<div className={isToolNameClickable ? 'cursor-pointer' : ''} onClick={handleToolNameClick}>
<ShimmerOverlayText
text={displayName}
active={isLoadingState}
isSpecial={isSpecial}
className='font-[470] font-season text-[var(--text-secondary)] text-sm dark:text-[var(--text-muted)]'
/>
</div>
{isExpandableTool && expanded && <div className='mt-1.5'>{renderPendingDetails()}</div>}
{showRemoveAutoAllow && isAutoAllowed && (
<div className='mt-1.5'>

View File

@@ -3,4 +3,3 @@ export { ContextPills } from './context-pills/context-pills'
export { MentionMenu } from './mention-menu/mention-menu'
export { ModeSelector } from './mode-selector/mode-selector'
export { ModelSelector } from './model-selector/model-selector'
export { SlashMenu } from './slash-menu/slash-menu'

View File

@@ -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>
)
}

View File

@@ -1,11 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import type { ChatContext } from '@/stores/panel'
interface UseContextManagementProps {
/** Current message text */
message: string
/** Initial contexts to populate when editing a message */
initialContexts?: ChatContext[]
}
/**
@@ -15,17 +13,8 @@ interface UseContextManagementProps {
* @param props - Configuration object
* @returns Context state and management functions
*/
export function useContextManagement({ message, initialContexts }: UseContextManagementProps) {
const [selectedContexts, setSelectedContexts] = useState<ChatContext[]>(initialContexts ?? [])
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])
export function useContextManagement({ message }: UseContextManagementProps) {
const [selectedContexts, setSelectedContexts] = useState<ChatContext[]>([])
/**
* Adds a context to the selected contexts list, avoiding duplicates
@@ -74,9 +63,6 @@ export function useContextManagement({ message, initialContexts }: UseContextMan
if (c.kind === 'docs') {
return true // Only one docs context allowed
}
if (c.kind === 'slash_command' && 'command' in context && 'command' in c) {
return c.command === (context as any).command
}
}
return false
@@ -117,8 +103,6 @@ export function useContextManagement({ message, initialContexts }: UseContextMan
return (c as any).executionId !== (contextToRemove as any).executionId
case 'docs':
return false // Remove docs (only one docs context)
case 'slash_command':
return (c as any).command !== (contextToRemove as any).command
default:
return c.label !== contextToRemove.label
}
@@ -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.
*/
useEffect(() => {
@@ -146,16 +130,17 @@ export function useContextManagement({ message, initialContexts }: UseContextMan
setSelectedContexts((prev) => {
if (prev.length === 0) return prev
const filtered = prev.filter((c) => {
if (!c.label) return false
// Check for slash command tokens or mention tokens based on kind
const isSlashCommand = c.kind === 'slash_command'
const prefix = isSlashCommand ? '/' : '@'
const tokenWithSpaces = ` ${prefix}${c.label} `
const tokenAtStart = `${prefix}${c.label} `
// Token can appear with leading space OR at the start of the message
return message.includes(tokenWithSpaces) || message.startsWith(tokenAtStart)
})
const presentLabels = new Set<string>()
const labels = prev.map((c) => c.label).filter(Boolean)
for (const label of labels) {
const token = ` @${label} `
if (message.includes(token)) {
presentLabels.add(label)
}
}
const filtered = prev.filter((c) => !!c.label && presentLabels.has(c.label))
return filtered.length === prev.length ? prev : filtered
})
}, [message])

View File

@@ -70,25 +70,11 @@ export function useMentionMenu({
// Ensure '@' starts a token (start or whitespace before)
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) {
// Only check non-slash_command contexts for mentions
const mentionLabels = selectedContexts
.filter((c) => c.kind !== 'slash_command')
.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 labels = selectedContexts.map((c) => c.label).filter(Boolean) as string[]
for (const label of labels) {
// Space-wrapped token: " @label "
const token = ` @${label} `
let fromIndex = 0
while (fromIndex <= text.length) {
@@ -102,6 +88,7 @@ export function useMentionMenu({
// Check if the @ we found is the @ of this completed token
if (atIndex === atPositionInToken) {
// The @ we found is part of a completed mention
// Don't show menu - user is typing after the completed mention
return null
}
@@ -126,76 +113,6 @@ export function useMentionMenu({
[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
*
@@ -283,10 +200,9 @@ export function useMentionMenu({
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} `
// Always include leading space, avoid duplicate if one exists
const needsLeadingSpace = !before.endsWith(' ')
const insertion = `${needsLeadingSpace ? ' ' : ''}@${label} `
const next = `${before}${insertion}${after}`
onMessageChange(next)
@@ -301,41 +217,6 @@ export function useMentionMenu({
[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
*
@@ -423,12 +304,10 @@ export function useMentionMenu({
// Operations
getCaretPos,
getActiveMentionQueryAtPosition,
getActiveSlashQueryAtPosition,
getSubmenuQuery,
resetActiveMentionQuery,
insertAtCursor,
replaceActiveMentionWith,
replaceActiveSlashWith,
scrollActiveItemIntoView,
closeMentionMenu,
}

View File

@@ -39,7 +39,7 @@ export function useMentionTokens({
setSelectedContexts,
}: 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
*/
@@ -55,19 +55,8 @@ export function useMentionTokens({
const uniqueLabels = Array.from(new Set(labels))
for (const label of uniqueLabels) {
// Find matching context to determine if it's a slash command
const matchingContext = selectedContexts.find((c) => c.label === 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} `
// Space-wrapped token: " @label " (search from start)
const token = ` @${label} `
let fromIndex = 0
while (fromIndex <= message.length) {
const idx = message.indexOf(token, fromIndex)

View File

@@ -21,7 +21,6 @@ import {
MentionMenu,
ModelSelector,
ModeSelector,
SlashMenu,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components'
import { NEAR_TOP_THRESHOLD } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
import {
@@ -68,8 +67,6 @@ interface UserInputProps {
hideModeSelector?: boolean
/** Disable @mention functionality */
disableMentions?: boolean
/** Initial contexts for editing a message with existing context mentions */
initialContexts?: ChatContext[]
}
interface UserInputRef {
@@ -106,7 +103,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
onModelChangeOverride,
hideModeSelector = false,
disableMentions = false,
initialContexts,
},
ref
) => {
@@ -127,7 +123,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
const [isNearTop, setIsNearTop] = useState(false)
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null)
const [inputContainerRef, setInputContainerRef] = useState<HTMLDivElement | null>(null)
const [showSlashMenu, setShowSlashMenu] = useState(false)
// Controlled vs uncontrolled message state
const message = controlledValue !== undefined ? controlledValue : internalMessage
@@ -145,7 +140,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
// Custom hooks - order matters for ref sharing
// Context management (manages selectedContexts state)
const contextManagement = useContextManagement({ message, initialContexts })
const contextManagement = useContextManagement({ message })
// Mention menu
const mentionMenu = useMentionMenu({
@@ -375,131 +370,20 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
}
}, [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(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
// Escape key handling
if (e.key === 'Escape' && (mentionMenu.showMentionMenu || showSlashMenu)) {
if (e.key === 'Escape' && mentionMenu.showMentionMenu) {
e.preventDefault()
if (mentionMenu.openSubmenuFor) {
mentionMenu.setOpenSubmenuFor(null)
mentionMenu.setSubmenuQueryStart(null)
} else {
mentionMenu.closeMentionMenu()
setShowSlashMenu(false)
}
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
if (mentionKeyboard.handleArrowNavigation(e)) return
if (mentionKeyboard.handleArrowRight(e)) return
@@ -508,42 +392,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
// Enter key handling
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
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) {
handleSubmit()
} else {
@@ -621,15 +469,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
}
}
},
[
mentionMenu,
mentionKeyboard,
handleSubmit,
handleSlashCommandSelect,
message,
mentionTokensWithContext,
showSlashMenu,
]
[mentionMenu, mentionKeyboard, handleSubmit, message.length, mentionTokensWithContext]
)
const handleInputChange = useCallback(
@@ -641,14 +481,9 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
if (disableMentions) return
const caret = e.target.selectionStart ?? newValue.length
const active = mentionMenu.getActiveMentionQueryAtPosition(caret, newValue)
// Check for @ mention trigger
const activeMention = mentionMenu.getActiveMentionQueryAtPosition(caret, newValue)
// Check for / slash command trigger
const activeSlash = mentionMenu.getActiveSlashQueryAtPosition(caret, newValue)
if (activeMention) {
setShowSlashMenu(false)
if (active) {
mentionMenu.setShowMentionMenu(true)
mentionMenu.setInAggregated(false)
if (mentionMenu.openSubmenuFor) {
@@ -657,17 +492,10 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
mentionMenu.setMentionActiveIndex(0)
mentionMenu.setSubmenuActiveIndex(0)
}
} else if (activeSlash) {
mentionMenu.setShowMentionMenu(false)
mentionMenu.setOpenSubmenuFor(null)
mentionMenu.setSubmenuQueryStart(null)
setShowSlashMenu(true)
mentionMenu.setSubmenuActiveIndex(0)
} else {
mentionMenu.setShowMentionMenu(false)
mentionMenu.setOpenSubmenuFor(null)
mentionMenu.setSubmenuQueryStart(null)
setShowSlashMenu(false)
}
},
[setMessage, mentionMenu, disableMentions]
@@ -714,32 +542,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
mentionMenu.setSubmenuActiveIndex(0)
}, [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 showAbortButton = isLoading && onAbort
@@ -841,20 +643,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
<AtSign className='h-3 w-3' strokeWidth={1.75} />
</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 */}
<ContextPills
contexts={contextManagement.selectedContexts}
@@ -929,18 +717,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
/>,
document.body
)}
{/* Slash Menu Portal */}
{!disableMentions &&
showSlashMenu &&
createPortal(
<SlashMenu
mentionMenu={mentionMenu}
message={message}
onSelectCommand={handleSlashCommandSelect}
/>,
document.body
)}
</div>
{/* Bottom Row: Mode Selector + Model Selector + Attach Button + Send Button */}

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useMemo, useRef } from 'react'
import { memo, useMemo, useRef } from 'react'
import { RepeatIcon, SplitIcon } from 'lucide-react'
import { Handle, type NodeProps, Position, useReactFlow } from 'reactflow'
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 { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { usePanelEditorStore } from '@/stores/panel'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
/**
* Global styles for subflow nodes (loop and parallel containers).
@@ -52,8 +51,6 @@ export interface SubflowNodeData {
isPreviewSelected?: boolean
kind: 'loop' | 'parallel'
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
*/
export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeData>) => {
const { getNodes, setNodes } = useReactFlow()
const { getNodes } = useReactFlow()
const { collaborativeBatchRemoveBlocks } = useCollaborativeWorkflow()
const { getGroups } = useWorkflowStore()
const blockRef = useRef<HTMLDivElement>(null)
const currentWorkflow = useCurrentWorkflow()
@@ -144,57 +140,10 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
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 (
<>
<SubflowNodeStyles />
<div className='group relative' onMouseDown={handleGroupMouseDown}>
<div className='group relative'>
<div
ref={blockRef}
onClick={() => setCurrentBlockId(id)}

View File

@@ -107,7 +107,7 @@ export const ActionBar = memo(
return (
<div
className={cn(
'-top-[46px] absolute right-0 z-[100]',
'-top-[46px] absolute right-0',
'flex flex-row items-center',
'opacity-0 transition-opacity duration-200 group-hover:opacity-100',
'gap-[5px] rounded-[10px] bg-[var(--surface-4)] p-[5px]'

View File

@@ -12,10 +12,6 @@ export interface WorkflowBlockProps {
isPreview?: boolean
/** Whether this block is selected in preview mode */
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>
blockState?: any
}

View File

@@ -1,7 +1,7 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
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 { getEnv, isTruthy } from '@/lib/core/config/env'
import { cn } from '@/lib/core/utils/cn'
@@ -915,65 +915,8 @@ export const WorkflowBlock = memo(function WorkflowBlock({
const userPermissions = useUserPermissionsContext()
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 (
<div
className='group relative'
data-grouped-selection={isGroupedSelection ? 'true' : undefined}
onMouseDown={handleGroupMouseDown}
>
<div className='group relative'>
<div
ref={contentRef}
onClick={handleClick}

View File

@@ -30,7 +30,6 @@ interface UseBlockVisualProps {
export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVisualProps) {
const isPreview = data.isPreview ?? false
const isPreviewSelected = data.isPreviewSelected ?? false
const isGroupedSelection = data.isGroupedSelection ?? false
const currentWorkflow = useCurrentWorkflow()
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
@@ -65,18 +64,8 @@ export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVis
diffStatus: isPreview ? undefined : diffStatus,
runPathStatus,
isPreviewSelection: isPreview && isPreviewSelected,
isGroupedSelection: !isPreview && isGroupedSelection,
}),
[
isActive,
isPending,
isDeletedBlock,
diffStatus,
runPathStatus,
isPreview,
isPreviewSelected,
isGroupedSelection,
]
[isActive, isPending, isDeletedBlock, diffStatus, runPathStatus, isPreview, isPreviewSelected]
)
return {

View File

@@ -35,7 +35,6 @@ export function useCanvasContextMenu({ blocks, getNodes }: UseCanvasContextMenuP
const block = blocks[n.id]
const parentId = block?.data?.parentId
const parentType = parentId ? blocks[parentId]?.type : undefined
const groupId = block?.data?.groupId
return {
id: n.id,
type: block?.type || '',
@@ -43,7 +42,6 @@ export function useCanvasContextMenu({ blocks, getNodes }: UseCanvasContextMenuP
horizontalHandles: block?.horizontalHandles ?? false,
parentId,
parentType,
groupId,
}
}),
[blocks]
@@ -51,22 +49,14 @@ export function useCanvasContextMenu({ blocks, getNodes }: UseCanvasContextMenuP
/**
* 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(
(event: React.MouseEvent, node: Node) => {
event.preventDefault()
event.stopPropagation()
// Get all currently selected nodes
const allNodes = getNodes()
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]
const selectedNodes = getNodes().filter((n) => n.selected)
const nodesToUse = selectedNodes.some((n) => n.id === node.id) ? selectedNodes : [node]
setPosition({ x: event.clientX, y: event.clientY })
setSelectedBlocks(nodesToBlockInfos(nodesToUse))

View File

@@ -11,7 +11,6 @@ export interface BlockRingOptions {
diffStatus: BlockDiffStatus
runPathStatus: BlockRunPathStatus
isPreviewSelection?: boolean
isGroupedSelection?: boolean
}
/**
@@ -22,15 +21,8 @@ export function getBlockRingStyles(options: BlockRingOptions): {
hasRing: boolean
ringClassName: string
} {
const {
isActive,
isPending,
isDeletedBlock,
diffStatus,
runPathStatus,
isPreviewSelection,
isGroupedSelection,
} = options
const { isActive, isPending, isDeletedBlock, diffStatus, runPathStatus, isPreviewSelection } =
options
const hasRing =
isActive ||
@@ -38,24 +30,17 @@ export function getBlockRingStyles(options: BlockRingOptions): {
diffStatus === 'new' ||
diffStatus === 'edited' ||
isDeletedBlock ||
!!runPathStatus ||
!!isGroupedSelection
!!runPathStatus
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)
isActive && isPreviewSelection && 'ring-[1.75px] ring-[var(--brand-secondary)]',
// Executing block: pulsing success ring with prominent thickness
isActive &&
!isPreviewSelection &&
!isGroupedSelection &&
'ring-[3.5px] ring-[var(--border-success)] animate-ring-pulse',
// Non-active states use standard ring utilities (except grouped selection which has its own)
!isActive && hasRing && !isGroupedSelection && 'ring-[1.75px]',
// Non-active states use standard ring utilities
!isActive && hasRing && 'ring-[1.75px]',
// Pending state: warning ring
!isActive && isPending && 'ring-[var(--warning)]',
// Deleted state (highest priority after active/pending)

View File

@@ -264,14 +264,12 @@ const WorkflowContent = React.memo(() => {
const canUndo = undoRedoStack.undo.length > 0
const canRedo = undoRedoStack.redo.length > 0
const { updateNodeDimensions, setDragStartPosition, getDragStartPosition, getGroups } =
useWorkflowStore(
useShallow((state) => ({
updateNodeDimensions: state.updateNodeDimensions,
setDragStartPosition: state.setDragStartPosition,
getDragStartPosition: state.getDragStartPosition,
getGroups: state.getGroups,
}))
const { updateNodeDimensions, setDragStartPosition, getDragStartPosition } = useWorkflowStore(
useShallow((state) => ({
updateNodeDimensions: state.updateNodeDimensions,
setDragStartPosition: state.setDragStartPosition,
getDragStartPosition: state.getDragStartPosition,
}))
)
const copilotCleanup = useCopilotStore((state) => state.cleanup)
@@ -363,14 +361,6 @@ const WorkflowContent = React.memo(() => {
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). */
const pendingSelectionRef = useRef<Set<string> | null>(null)
@@ -468,8 +458,6 @@ const WorkflowContent = React.memo(() => {
collaborativeBatchRemoveBlocks,
collaborativeBatchToggleBlockEnabled,
collaborativeBatchToggleBlockHandles,
collaborativeGroupBlocks,
collaborativeUngroupBlocks,
undo,
redo,
} = useCollaborativeWorkflow()
@@ -794,35 +782,6 @@ const WorkflowContent = React.memo(() => {
collaborativeBatchToggleBlockHandles(blockIds)
}, [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 blocksToRemove = contextMenuBlocks.filter(
(block) => block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel')
@@ -1947,7 +1906,6 @@ const WorkflowContent = React.memo(() => {
name: block.name,
isActive,
isPending,
groupId: block.data?.groupId,
},
// Include dynamic dimensions for container resizing calculations (must match rendered size)
// 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)
}, [blocks, edgesForDisplay, getNodeAbsolutePosition, collaborativeBatchUpdateParent])
/** 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.
*/
/** Handles node changes - applies changes and resolves parent-child selection conflicts. */
const onNodesChange = useCallback(
(changes: NodeChange[]) => {
setDisplayNodes((nds) => {
let updated = applyNodeChanges(changes, nds)
const updated = applyNodeChanges(changes, nds)
const hasSelectionChange = changes.some((c) => c.type === 'select')
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
return hasSelectionChange ? resolveParentChildSelectionConflicts(updated, blocks) : updated
})
},
[blocks, getGroups]
[blocks]
)
/**
@@ -2612,55 +2530,9 @@ const WorkflowContent = React.memo(() => {
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
// Re-get nodes after potential selection expansion
const updatedNodes = getNodes()
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
})
const allNodes = getNodes()
const selectedNodes = allNodes.filter((n) => n.selected)
multiNodeDragStartRef.current.clear()
selectedNodes.forEach((n) => {
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. */
@@ -2736,93 +2553,13 @@ const WorkflowContent = React.memo(() => {
(_event: React.MouseEvent, node: any) => {
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
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 (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 multiple nodes are selected, update all their positions
if (selectedNodes.length > 1) {
// Use pre-computed positions for group members, otherwise use computeClampedPositionUpdates
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)
}
const positionUpdates = computeClampedPositionUpdates(selectedNodes, blocks, allNodes)
collaborativeBatchUpdatePositions(positionUpdates, {
previousPositions: multiNodeDragStartRef.current,
})
@@ -3106,7 +2843,6 @@ const WorkflowContent = React.memo(() => {
},
[
getNodes,
setNodes,
dragStartParentId,
potentialParentId,
updateNodeParent,
@@ -3125,7 +2861,6 @@ const WorkflowContent = React.memo(() => {
activeWorkflowId,
collaborativeBatchUpdatePositions,
collaborativeBatchUpdateParent,
getGroups,
]
)
@@ -3433,81 +3168,19 @@ const WorkflowContent = React.memo(() => {
/**
* 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.
*/
const handleNodeClick = useCallback(
(event: React.MouseEvent, node: Node) => {
const isMultiSelect = event.shiftKey || event.metaKey || event.ctrlKey
const groups = getGroups()
// Track which nodes are directly clicked vs. group-expanded
const directlySelectedIds = new Set<string>()
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((nodes) =>
nodes.map((n) => ({
...n,
selected: isMultiSelect ? (n.id === node.id ? true : n.selected) : n.id === node.id,
}))
)
},
[setNodes, blocks, getGroups]
[setNodes]
)
/** Handles edge selection with container context tracking and Shift-click multi-selection. */
@@ -3742,8 +3415,6 @@ const WorkflowContent = React.memo(() => {
onRemoveFromSubflow={handleContextRemoveFromSubflow}
onOpenEditor={handleContextOpenEditor}
onRename={handleContextRename}
onGroupBlocks={handleContextGroupBlocks}
onUngroupBlocks={handleContextUngroupBlocks}
hasClipboard={hasClipboard()}
showRemoveFromSubflow={contextMenuBlocks.some(
(b) => b.parentId && (b.parentType === 'loop' || b.parentType === 'parallel')

View File

@@ -172,7 +172,7 @@ export const ScheduleBlock: BlockConfig = {
{ label: 'Melbourne (UTC+10)', id: 'Australia/Melbourne' },
{ label: 'Auckland (UTC+12)', id: 'Pacific/Auckland' },
],
value: () => Intl.DateTimeFormat().resolvedOptions().timeZone,
value: () => 'UTC',
required: false,
mode: 'trigger',
condition: { field: 'scheduleType', value: ['minutes', 'hourly'], not: true },

View File

@@ -424,35 +424,6 @@ export function useCollaborativeWorkflow() {
logger.info('Successfully applied batch-update-parent from remote user')
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) {
@@ -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 {
// Connection status
isConnected,
@@ -1728,10 +1622,6 @@ export function useCollaborativeWorkflow() {
collaborativeUpdateIterationCount,
collaborativeUpdateIterationCollection,
// Collaborative block group operations
collaborativeGroupBlocks,
collaborativeUngroupBlocks,
// Direct access to stores for non-collaborative operations
workflowStore,
subBlockStore,

View File

@@ -22,9 +22,7 @@ import {
type BatchToggleHandlesOperation,
type BatchUpdateParentOperation,
createOperationEntry,
type GroupBlocksOperation,
runWithUndoRedoRecordingSuspended,
type UngroupBlocksOperation,
type UpdateParentOperation,
useUndoRedoStore,
} from '@/stores/undo-redo'
@@ -876,46 +874,6 @@ export function useUndoRedo() {
})
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: {
const applyDiffInverse = entry.inverse as any
const { baselineSnapshot } = applyDiffInverse.data
@@ -1524,46 +1482,6 @@ export function useUndoRedo() {
})
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: {
// Redo apply-diff means re-applying the proposed state with diff markers
const applyDiffOp = entry.operation as any
@@ -1875,66 +1793,6 @@ export function useUndoRedo() {
[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 {
recordBatchAddBlocks,
recordBatchRemoveBlocks,
@@ -1948,8 +1806,6 @@ export function useUndoRedo() {
recordApplyDiff,
recordAcceptDiff,
recordRejectDiff,
recordGroupBlocks,
recordUngroupBlocks,
undo,
redo,
getStackSizes,

View File

@@ -99,7 +99,6 @@ export interface SendMessageRequest {
workflowId?: string
executionId?: string
}>
commands?: string[]
}
/**

View File

@@ -10,7 +10,6 @@ import {
GetBlockConfigInput,
GetBlockConfigResult,
} from '@/lib/copilot/tools/shared/schemas'
import { getBlock } from '@/blocks/registry'
interface GetBlockConfigArgs {
blockType: string
@@ -40,9 +39,7 @@ export class GetBlockConfigClientTool extends BaseClientTool {
},
getDynamicText: (params, state) => {
if (params?.blockType && typeof params.blockType === 'string') {
// Look up the block config to get the human-readable name
const blockConfig = getBlock(params.blockType)
const blockName = (blockConfig?.name ?? params.blockType.replace(/_/g, ' ')).toLowerCase()
const blockName = params.blockType.replace(/_/g, ' ')
const opSuffix = params.operation ? ` (${params.operation})` : ''
switch (state) {

View File

@@ -10,7 +10,6 @@ import {
GetBlockOptionsInput,
GetBlockOptionsResult,
} from '@/lib/copilot/tools/shared/schemas'
import { getBlock } from '@/blocks/registry'
interface GetBlockOptionsArgs {
blockId: string
@@ -38,9 +37,7 @@ export class GetBlockOptionsClientTool extends BaseClientTool {
},
getDynamicText: (params, state) => {
if (params?.blockId && typeof params.blockId === 'string') {
// Look up the block config to get the human-readable name
const blockConfig = getBlock(params.blockId)
const blockName = (blockConfig?.name ?? params.blockId.replace(/_/g, ' ')).toLowerCase()
const blockName = params.blockId.replace(/_/g, ' ')
switch (state) {
case ClientToolCallState.success:

View File

@@ -18,7 +18,6 @@ import './other/make-api-request'
import './other/plan'
import './other/research'
import './other/sleep'
import './other/superagent'
import './other/test'
import './other/tour'
import './other/workflow'

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -1,9 +1,19 @@
import { createLogger } from '@sim/logger'
import { Globe, Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} 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 {
static readonly id = 'search_online'
@@ -22,7 +32,6 @@ export class SearchOnlineClientTool extends BaseClientTool {
[ClientToolCallState.rejected]: { text: 'Skipped online search', icon: MinusCircle },
[ClientToolCallState.aborted]: { text: 'Aborted online search', icon: XCircle },
},
interrupt: undefined,
getDynamicText: (params, state) => {
if (params?.query && typeof params.query === 'string') {
const query = params.query
@@ -47,7 +56,28 @@ export class SearchOnlineClientTool extends BaseClientTool {
},
}
async execute(): Promise<void> {
return
async execute(args?: SearchOnlineArgs): Promise<void> {
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')
}
}
}

View File

@@ -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!)

View File

@@ -16,61 +16,9 @@ import type { BlockState } from '@/stores/workflows/workflow/types'
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.
* Positions blocks in layers based on their connections (edges).
* Groups are treated as single units and laid out together.
*/
export function applyAutoLayout(
blocks: Record<string, BlockState>,
@@ -88,11 +36,6 @@ export function applyAutoLayout(
const horizontalSpacing = options.horizontalSpacing ?? DEFAULT_HORIZONTAL_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)
// This ensures accurate widths/heights before root-level layout
prepareContainerDimensions(
@@ -106,112 +49,19 @@ export function applyAutoLayout(
const { root: rootBlockIds } = getBlocksByParent(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> = {}
for (const id of layoutRootIds) {
// Skip grouped blocks that aren't representatives
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]
}
rootBlocks[id] = blocksCopy[id]
}
// Remap edges: edges involving grouped blocks should connect to the representative
const blockToGroup = new Map<string, string>() // blockId -> groupId
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))
const rootEdges = edges.filter(
(edge) => layoutRootIds.includes(edge.source) && layoutRootIds.includes(edge.target)
)
// Calculate subflow depths before laying out root blocks
// This ensures blocks connected to subflow ends are positioned correctly
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) {
const { nodes } = layoutBlocksCore(rootBlocks, rootEdges, {
isContainer: false,
@@ -219,49 +69,15 @@ export function applyAutoLayout(
subflowDepths,
})
// Apply positions to ungrouped blocks and group representatives
for (const node of nodes.values()) {
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)
logger.info('Auto layout completed successfully', {
blockCount: Object.keys(blocksCopy).length,
groupCount: groups.size,
})
return {

View File

@@ -26,53 +26,9 @@ export interface TargetedLayoutOptions extends LayoutOptions {
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.
* Unchanged blocks act as anchors to preserve existing layout.
* Blocks in groups are moved together as a unit.
*/
export function applyTargetedLayout(
blocks: Record<string, BlockState>,
@@ -89,14 +45,9 @@ export function applyTargetedLayout(
return blocks
}
const changedSet = new Set(changedBlockIds)
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)
// This ensures accurate widths/heights before root-level layout
prepareContainerDimensions(
@@ -120,8 +71,7 @@ export function applyTargetedLayout(
changedSet,
verticalSpacing,
horizontalSpacing,
subflowDepths,
blockGroups
subflowDepths
)
for (const [parentId, childIds] of groups.children.entries()) {
@@ -133,8 +83,7 @@ export function applyTargetedLayout(
changedSet,
verticalSpacing,
horizontalSpacing,
subflowDepths,
blockGroups
subflowDepths
)
}
@@ -143,7 +92,6 @@ export function applyTargetedLayout(
/**
* Layouts a group of blocks (either root level or within a container)
* Blocks in block groups are moved together as a unit.
*/
function layoutGroup(
parentId: string | null,
@@ -153,8 +101,7 @@ function layoutGroup(
changedSet: Set<string>,
verticalSpacing: number,
horizontalSpacing: number,
subflowDepths: Map<string, number>,
blockGroups: Map<string, string[]>
subflowDepths: Map<string, number>
): void {
if (childIds.length === 0) return
@@ -194,7 +141,7 @@ function layoutGroup(
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 }>()
for (const id of layoutEligibleChildIds) {
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
for (const id of needsLayout) {
const block = blocks[id]
const newPos = layoutPositions.get(id)
if (!block || !newPos) continue
const groupId = block.data?.groupId
// 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,
}
block.position = {
x: newPos.x + offsetX,
y: newPos.y + offsetY,
}
}
}

View File

@@ -41,18 +41,11 @@ export function isContainerType(blockType: string): boolean {
}
/**
* Checks if a block should be excluded from autolayout.
* Note blocks are excluded unless they are part of a group.
* Checks if a block should be excluded from autolayout
*/
export function shouldSkipAutoLayout(block?: BlockState, isInGroup?: boolean): boolean {
export function shouldSkipAutoLayout(block?: BlockState): boolean {
if (!block) return true
// If the block type is normally excluded (e.g., note), but it's in a group, include it
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
return AUTO_LAYOUT_EXCLUDED_TYPES.has(block.type)
}
/**

View File

@@ -1174,6 +1174,5 @@ export function stripWorkflowDiffMarkers(state: WorkflowState): WorkflowState {
edges: structuredClone(state.edges || []),
loops: structuredClone(state.loops || {}),
parallels: structuredClone(state.parallels || {}),
groups: structuredClone(state.groups || {}),
}
}

View File

@@ -16,8 +16,6 @@ export const BLOCKS_OPERATIONS = {
BATCH_TOGGLE_ENABLED: 'batch-toggle-enabled',
BATCH_TOGGLE_HANDLES: 'batch-toggle-handles',
BATCH_UPDATE_PARENT: 'batch-update-parent',
GROUP_BLOCKS: 'group-blocks',
UNGROUP_BLOCKS: 'ungroup-blocks',
} as const
export type BlocksOperation = (typeof BLOCKS_OPERATIONS)[keyof typeof BLOCKS_OPERATIONS]
@@ -89,8 +87,6 @@ export const UNDO_REDO_OPERATIONS = {
APPLY_DIFF: 'apply-diff',
ACCEPT_DIFF: 'accept-diff',
REJECT_DIFF: 'reject-diff',
GROUP_BLOCKS: 'group-blocks',
UNGROUP_BLOCKS: 'ungroup-blocks',
} as const
export type UndoRedoOperation = (typeof UNDO_REDO_OPERATIONS)[keyof typeof UNDO_REDO_OPERATIONS]

View File

@@ -810,104 +810,6 @@ async function handleBlocksOperationTx(
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:
throw new Error(`Unsupported blocks operation: ${operation}`)
}

View File

@@ -465,70 +465,6 @@ export function setupOperationsHandlers(
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) {
await persistWorkflowOperation(workflowId, {
operation,

View File

@@ -30,8 +30,6 @@ const WRITE_OPERATIONS: string[] = [
BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED,
BLOCKS_OPERATIONS.BATCH_TOGGLE_HANDLES,
BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT,
BLOCKS_OPERATIONS.GROUP_BLOCKS,
BLOCKS_OPERATIONS.UNGROUP_BLOCKS,
// Edge operations
EDGE_OPERATIONS.ADD,
EDGE_OPERATIONS.REMOVE,

View File

@@ -221,30 +221,6 @@ export const BatchUpdateParentSchema = z.object({
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([
BlockOperationSchema,
BatchPositionUpdateSchema,
@@ -253,8 +229,6 @@ export const WorkflowOperationSchema = z.union([
BatchToggleEnabledSchema,
BatchToggleHandlesSchema,
BatchUpdateParentSchema,
GroupBlocksSchema,
UngroupBlocksSchema,
EdgeOperationSchema,
BatchAddEdgesSchema,
BatchRemoveEdgesSchema,

View File

@@ -25,7 +25,6 @@ function captureWorkflowSnapshot(): WorkflowState {
edges: rawState.edges || [],
loops: rawState.loops || {},
parallels: rawState.parallels || {},
groups: rawState.groups || {},
lastSaved: Date.now(),
}
}

View File

@@ -27,13 +27,11 @@ import {
import { NavigateUIClientTool } from '@/lib/copilot/tools/client/navigation/navigate-ui'
import { AuthClientTool } from '@/lib/copilot/tools/client/other/auth'
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 { DebugClientTool } from '@/lib/copilot/tools/client/other/debug'
import { DeployClientTool } from '@/lib/copilot/tools/client/other/deploy'
import { EditClientTool } from '@/lib/copilot/tools/client/other/edit'
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 { KnowledgeClientTool } from '@/lib/copilot/tools/client/other/knowledge'
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 { RememberDebugClientTool } from '@/lib/copilot/tools/client/other/remember-debug'
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 { SearchErrorsClientTool } from '@/lib/copilot/tools/client/other/search-errors'
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_patterns: (id) => new SearchPatternsClientTool(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),
set_environment_variables: (id) => new SetEnvironmentVariablesClientTool(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_patterns: (SearchPatternsClientTool 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,
set_environment_variables: (SetEnvironmentVariablesClientTool as any)?.metadata,
get_credentials: (GetCredentialsClientTool as any)?.metadata,
@@ -1223,20 +1214,30 @@ const sseHandlers: Record<string, SSEHandler> = {
}
} catch {}
// Integration tools: Stay in pending state until user confirms via buttons
// This handles tools like google_calendar_*, exa_*, gmail_read, etc. that aren't in the client registry
// Integration tools: Check if auto-allowed, otherwise wait for user confirmation
// This handles tools like google_calendar_*, exa_*, etc. that aren't in the client registry
// Only relevant if mode is 'build' (agent)
const { mode, workflowId } = get()
const { mode, workflowId, autoAllowedTools } = get()
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 inst = getClientTool(id) as any
if (!def && !inst && name) {
// Integration tools stay in pending state until user confirms
logger.info('[build mode] Integration tool awaiting user confirmation', {
id,
name,
})
// Check if this tool is auto-allowed
if (autoAllowedTools.includes(name)) {
logger.info('[build mode] Integration tool auto-allowed, executing', { id, 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)
// 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 {
const def = getTool(name)
if (def) {
@@ -1862,33 +1863,29 @@ const subAgentSSEHandlers: Record<string, SSEHandler> = {
? !!def.hasInterrupt(args || {})
: !!def.hasInterrupt
if (!hasInterrupt) {
// Auto-execute tools without interrupts - non-blocking
// Auto-execute tools without interrupts
const ctx = createExecutionContext({ toolCallId: id, toolName: name })
Promise.resolve()
.then(() => def.execute(ctx, args || {}))
.catch((execErr: any) => {
logger.error('[SubAgent] Tool execution failed', {
id,
name,
error: execErr?.message,
})
})
try {
await def.execute(ctx, args || {})
} catch (execErr: any) {
logger.error('[SubAgent] Tool execution failed', { id, name, error: execErr?.message })
}
}
} else {
// Fallback to class-based tools - non-blocking
// Fallback to class-based tools
const instance = getClientTool(id)
if (instance) {
const hasInterruptDisplays = !!instance.getInterruptDisplays?.()
if (!hasInterruptDisplays) {
Promise.resolve()
.then(() => instance.execute(args || {}))
.catch((execErr: any) => {
logger.error('[SubAgent] Class tool execution failed', {
id,
name,
error: execErr?.message,
})
try {
await instance.execute(args || {})
} catch (execErr: any) {
logger.error('[SubAgent] Class tool execution failed', {
id,
name,
error: execErr?.message,
})
}
}
}
}
@@ -2518,13 +2515,6 @@ export const useCopilotStore = create<CopilotStore>()(
// Call copilot API
const apiMode: 'ask' | 'agent' | 'plan' =
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({
message: messageToSend,
userMessageId: userMessage.id,
@@ -2536,8 +2526,7 @@ export const useCopilotStore = create<CopilotStore>()(
createNewChat: !currentChat,
stream,
fileAttachments,
contexts: filteredContexts,
commands: commands?.length ? commands : undefined,
contexts,
abortSignal: abortController.signal,
})
@@ -2629,14 +2618,13 @@ export const useCopilotStore = create<CopilotStore>()(
),
isSendingMessage: false,
isAborting: false,
// Keep abortController so streaming loop can check signal.aborted
// It will be nulled when streaming completes or new message starts
abortController: null,
}))
} else {
set({
isSendingMessage: 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 {
set({ isSendingMessage: false, isAborting: false })
set({ isSendingMessage: false, isAborting: false, abortController: null })
}
},
@@ -3166,7 +3154,6 @@ export const useCopilotStore = create<CopilotStore>()(
: msg
),
isSendingMessage: false,
isAborting: false,
abortController: null,
currentUserMessageId: null,
}))

View File

@@ -85,7 +85,6 @@ export type ChatContext =
| { kind: 'knowledge'; knowledgeId?: string; label: string }
| { kind: 'templates'; templateId?: string; label: string }
| { kind: 'docs'; label: string }
| { kind: 'slash_command'; command: string; label: string }
import type { CopilotChat as ApiCopilotChat } from '@/lib/copilot/api'

View File

@@ -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 =
| BatchAddBlocksOperation
| BatchRemoveBlocksOperation
@@ -156,8 +139,6 @@ export type Operation =
| ApplyDiffOperation
| AcceptDiffOperation
| RejectDiffOperation
| GroupBlocksOperation
| UngroupBlocksOperation
export interface OperationEntry {
id: string

View File

@@ -6,10 +6,8 @@ import type {
BatchRemoveBlocksOperation,
BatchRemoveEdgesOperation,
BatchUpdateParentOperation,
GroupBlocksOperation,
Operation,
OperationEntry,
UngroupBlocksOperation,
} from '@/stores/undo-redo/types'
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: {
const exhaustiveCheck: never = operation
throw new Error(`Unhandled operation type: ${(exhaustiveCheck as Operation).type}`)

View File

@@ -16,7 +16,6 @@ export function cloneWorkflowState(state: WorkflowState): WorkflowState {
edges: structuredClone(state.edges || []),
loops: structuredClone(state.loops || {}),
parallels: structuredClone(state.parallels || {}),
groups: structuredClone(state.groups || {}),
}
}

View File

@@ -298,26 +298,11 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
let workflowState: any
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 = {
blocks,
blocks: workflowData.state.blocks || {},
edges: workflowData.state.edges || [],
loops: workflowData.state.loops || {},
parallels: workflowData.state.parallels || {},
groups: reconstructedGroups,
lastSaved: Date.now(),
deploymentStatuses: {},
}
@@ -327,7 +312,6 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
edges: [],
loops: {},
parallels: {},
groups: {},
deploymentStatuses: {},
lastSaved: Date.now(),
}

View File

@@ -95,7 +95,6 @@ const initialState = {
edges: [],
loops: {},
parallels: {},
groups: {},
lastSaved: undefined,
deploymentStatuses: {},
needsRedeployment: false,
@@ -578,7 +577,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
edges: state.edges,
loops: state.loops,
parallels: state.parallels,
groups: state.groups,
lastSaved: state.lastSaved,
deploymentStatuses: state.deploymentStatuses,
needsRedeployment: state.needsRedeployment,
@@ -599,7 +597,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
Object.keys(workflowState.parallels || {}).length > 0
? workflowState.parallels
: generateParallelBlocks(nextBlocks)
const nextGroups = workflowState.groups || state.groups
return {
...state,
@@ -607,7 +604,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
edges: nextEdges,
loops: nextLoops,
parallels: nextParallels,
groups: nextGroups,
deploymentStatuses: workflowState.deploymentStatuses || state.deploymentStatuses,
needsRedeployment:
workflowState.needsRedeployment !== undefined
@@ -1337,126 +1333,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
getDragStartPosition: () => {
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' }
)

View File

@@ -63,11 +63,6 @@ export interface BlockData {
// Container node type (for ReactFlow node type determination)
type?: string
// Block group membership
groupId?: string
/** Stack of group IDs for hierarchical grouping (oldest to newest) */
groupStack?: string[]
}
export interface BlockLayoutState {
@@ -149,20 +144,6 @@ export interface Variable {
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 {
id: string
x: number
@@ -176,8 +157,6 @@ export interface WorkflowState {
lastSaved?: number
loops: Record<string, Loop>
parallels: Record<string, Parallel>
/** Block groups for organizing blocks on the canvas */
groups?: Record<string, BlockGroup>
lastUpdate?: number
metadata?: {
name?: string
@@ -264,28 +243,6 @@ export interface WorkflowActions {
workflowState: WorkflowState,
options?: { updateLastSaved?: boolean }
) => 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