Compare commits

..

6 Commits

Author SHA1 Message Date
Vikhyath Mondreti
df20d1035b remove branch from unrelated ops 2026-01-13 17:47:32 -08:00
Vikhyath Mondreti
d9d261de1d remove branch field for ones where it's not relevant 2026-01-13 17:46:05 -08:00
Vikhyath Mondreti
2cb4593d81 more test fixes 2026-01-13 17:17:23 -08:00
Vikhyath Mondreti
f04a7b5364 fix tests 2026-01-13 17:16:35 -08:00
Vikhyath Mondreti
00fb84fbde cleanup trigger outputs 2026-01-13 17:14:37 -08:00
Vikhyath Mondreti
ce38024b20 fix(triggers): package lemlist data, cleanup trigger outputs formatting, fix display name issues 2026-01-13 16:23:46 -08:00
75 changed files with 1142 additions and 2602 deletions

View File

@@ -552,6 +552,53 @@ All fields automatically have:
- `mode: 'trigger'` - Only shown in trigger mode
- `condition: { field: 'selectedTriggerId', value: triggerId }` - Only shown when this trigger is selected
## Trigger Outputs & Webhook Input Formatting
### Important: Two Sources of Truth
There are two related but separate concerns:
1. **Trigger `outputs`** - Schema/contract defining what fields SHOULD be available. Used by UI for tag dropdown.
2. **`formatWebhookInput`** - Implementation that transforms raw webhook payload into actual data. Located in `apps/sim/lib/webhooks/utils.server.ts`.
**These MUST be aligned.** The fields returned by `formatWebhookInput` should match what's defined in trigger `outputs`. If they differ:
- Tag dropdown shows fields that don't exist (broken variable resolution)
- Or actual data has fields not shown in dropdown (users can't discover them)
### When to Add a formatWebhookInput Handler
- **Simple providers**: If the raw webhook payload structure already matches your outputs, you don't need a handler. The generic fallback returns `body` directly.
- **Complex providers**: If you need to transform, flatten, extract nested data, compute fields, or handle conditional logic, add a handler.
### Adding a Handler
In `apps/sim/lib/webhooks/utils.server.ts`, add a handler block:
```typescript
if (foundWebhook.provider === '{service}') {
// Transform raw webhook body to match trigger outputs
return {
eventType: body.type,
resourceId: body.data?.id || '',
timestamp: body.created_at,
resource: body.data,
}
}
```
**Key rules:**
- Return fields that match your trigger `outputs` definition exactly
- No wrapper objects like `webhook: { data: ... }` or `{service}: { ... }`
- No duplication (don't spread body AND add individual fields)
- Use `null` for missing optional data, not empty objects with empty strings
### Verify Alignment
Run the alignment checker:
```bash
bunx scripts/check-trigger-alignment.ts {service}
```
## Trigger Outputs
Trigger outputs use the same schema as block outputs (NOT tool outputs).
@@ -649,6 +696,11 @@ export const {service}WebhookTrigger: TriggerConfig = {
- [ ] Added `delete{Service}Webhook` function to `provider-subscriptions.ts`
- [ ] Added provider to `cleanupExternalWebhook` function
### Webhook Input Formatting
- [ ] Added handler in `apps/sim/lib/webhooks/utils.server.ts` (if custom formatting needed)
- [ ] Handler returns fields matching trigger `outputs` exactly
- [ ] Run `bunx scripts/check-trigger-alignment.ts {service}` to verify alignment
### Testing
- [ ] Run `bun run type-check` to verify no TypeScript errors
- [ ] Restart dev server to pick up new triggers

View File

@@ -384,7 +384,7 @@ async function handleMessageSend(
headers,
body: JSON.stringify({
...workflowInput,
triggerType: 'a2a',
triggerType: 'api',
...(useInternalAuth && { workflowId: agent.workflowId }),
}),
signal: AbortSignal.timeout(A2A_DEFAULT_TIMEOUT),
@@ -613,7 +613,7 @@ async function handleMessageStream(
headers,
body: JSON.stringify({
...workflowInput,
triggerType: 'a2a',
triggerType: 'api',
stream: true,
...(useInternalAuth && { workflowId: agent.workflowId }),
}),

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

@@ -27,7 +27,7 @@ import { ExecutionSnapshot } from '@/executor/execution/snapshot'
import type { ExecutionMetadata, IterationContext } from '@/executor/execution/types'
import type { StreamingExecution } from '@/executor/types'
import { Serializer } from '@/serializer'
import { CORE_TRIGGER_TYPES, type CoreTriggerType } from '@/stores/logs/filters/types'
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
const logger = createLogger('WorkflowExecuteAPI')
@@ -109,7 +109,7 @@ type AsyncExecutionParams = {
workflowId: string
userId: string
input: any
triggerType: CoreTriggerType
triggerType: 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' | 'mcp'
}
/**
@@ -253,9 +253,17 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
})
const executionId = uuidv4()
let loggingTriggerType: CoreTriggerType = 'manual'
if (CORE_TRIGGER_TYPES.includes(triggerType as CoreTriggerType)) {
loggingTriggerType = triggerType as CoreTriggerType
type LoggingTriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' | 'mcp'
let loggingTriggerType: LoggingTriggerType = 'manual'
if (
triggerType === 'api' ||
triggerType === 'chat' ||
triggerType === 'webhook' ||
triggerType === 'schedule' ||
triggerType === 'manual' ||
triggerType === 'mcp'
) {
loggingTriggerType = triggerType as LoggingTriggerType
}
const loggingSession = new LoggingSession(
workflowId,

View File

@@ -72,7 +72,6 @@ const TRIGGER_VARIANT_MAP: Record<string, React.ComponentProps<typeof Badge>['va
schedule: 'green',
chat: 'purple',
webhook: 'orange',
a2a: 'teal',
}
interface StatusBadgeProps {

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

@@ -204,8 +204,7 @@ export function A2aDeploy({
const [skillTags, setSkillTags] = useState<string[]>([])
const [language, setLanguage] = useState<CodeLanguage>('curl')
const [useStreamingExample, setUseStreamingExample] = useState(false)
const [urlCopied, setUrlCopied] = useState(false)
const [codeCopied, setCodeCopied] = useState(false)
const [copied, setCopied] = useState(false)
useEffect(() => {
if (existingAgent) {
@@ -452,7 +451,7 @@ export function A2aDeploy({
}
try {
if ((!isDeployed || workflowNeedsRedeployment) && onDeployWorkflow) {
if (!isDeployed && onDeployWorkflow) {
await onDeployWorkflow()
}
@@ -476,7 +475,6 @@ export function A2aDeploy({
}, [
existingAgent,
isDeployed,
workflowNeedsRedeployment,
onDeployWorkflow,
name,
description,
@@ -645,8 +643,8 @@ console.log(data);`
const handleCopyCommand = useCallback(() => {
navigator.clipboard.writeText(getCurlCommand())
setCodeCopied(true)
setTimeout(() => setCodeCopied(false), 2000)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}, [getCurlCommand])
if (isLoading) {
@@ -704,12 +702,12 @@ console.log(data);`
type='button'
onClick={() => {
navigator.clipboard.writeText(endpoint)
setUrlCopied(true)
setTimeout(() => setUrlCopied(false), 2000)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}}
className='-translate-y-1/2 absolute top-1/2 right-2'
>
{urlCopied ? (
{copied ? (
<Check className='h-3 w-3 text-[var(--brand-tertiary-2)]' />
) : (
<Clipboard className='h-3 w-3 text-[var(--text-tertiary)]' />
@@ -717,7 +715,7 @@ console.log(data);`
</button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>{urlCopied ? 'Copied' : 'Copy'}</span>
<span>{copied ? 'Copied' : 'Copy'}</span>
</Tooltip.Content>
</Tooltip.Root>
</div>
@@ -873,15 +871,11 @@ console.log(data);`
aria-label='Copy command'
className='!p-1.5 -my-1.5'
>
{codeCopied ? (
<Check className='h-3 w-3' />
) : (
<Clipboard className='h-3 w-3' />
)}
{copied ? <Check className='h-3 w-3' /> : <Clipboard className='h-3 w-3' />}
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>{codeCopied ? 'Copied' : 'Copy'}</span>
<span>{copied ? 'Copied' : 'Copy'}</span>
</Tooltip.Content>
</Tooltip.Root>
</div>

View File

@@ -2319,8 +2319,6 @@ const WorkflowContent = React.memo(() => {
/**
* Handles connection drag end. Detects if the edge was dropped over a block
* and automatically creates a connection to that block's target handle.
* Only creates a connection if ReactFlow didn't already handle it (e.g., when
* dropping on the block body instead of a handle).
*/
const onConnectEnd = useCallback(
(event: MouseEvent | TouchEvent) => {
@@ -2342,25 +2340,14 @@ const WorkflowContent = React.memo(() => {
// Find node under cursor
const targetNode = findNodeAtPosition(flowPosition)
// Create connection if valid target found AND edge doesn't already exist
// ReactFlow's onConnect fires first when dropping on a handle, so we check
// if that connection already exists to avoid creating duplicates.
// IMPORTANT: We must read directly from the store (not React state) because
// the store update from ReactFlow's onConnect may not have triggered a
// React re-render yet when this callback runs (typically 1-2ms later).
// Create connection if valid target found
if (targetNode && targetNode.id !== source.nodeId) {
const currentEdges = useWorkflowStore.getState().edges
const edgeAlreadyExists = currentEdges.some(
(e) => e.source === source.nodeId && e.target === targetNode.id
)
if (!edgeAlreadyExists) {
onConnect({
source: source.nodeId,
sourceHandle: source.handleId,
target: targetNode.id,
targetHandle: 'target',
})
}
onConnect({
source: source.nodeId,
sourceHandle: source.handleId,
target: targetNode.id,
targetHandle: 'target',
})
}
connectionSourceRef.current = null

View File

@@ -10,7 +10,6 @@ import { getWorkflowById } from '@/lib/workflows/utils'
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
import type { ExecutionMetadata } from '@/executor/execution/types'
import type { ExecutionResult } from '@/executor/types'
import type { CoreTriggerType } from '@/stores/logs/filters/types'
const logger = createLogger('TriggerWorkflowExecution')
@@ -18,7 +17,7 @@ export type WorkflowExecutionPayload = {
workflowId: string
userId: string
input?: any
triggerType?: CoreTriggerType
triggerType?: 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' | 'mcp'
metadata?: Record<string, any>
}

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

@@ -313,6 +313,26 @@ export const getBlock = (type: string): BlockConfig | undefined => {
return registry[normalized]
}
export const getLatestBlock = (baseType: string): BlockConfig | undefined => {
const normalized = baseType.replace(/-/g, '_')
const versionedKeys = Object.keys(registry).filter((key) => {
const match = key.match(new RegExp(`^${normalized}_v(\\d+)$`))
return match !== null
})
if (versionedKeys.length > 0) {
const sorted = versionedKeys.sort((a, b) => {
const versionA = Number.parseInt(a.match(/_v(\d+)$/)?.[1] || '0', 10)
const versionB = Number.parseInt(b.match(/_v(\d+)$/)?.[1] || '0', 10)
return versionB - versionA
})
return registry[sorted[0]]
}
return registry[normalized]
}
export const getBlockByToolName = (toolName: string): BlockConfig | undefined => {
return Object.values(registry).find((block) => block.tools?.access?.includes(toolName))
}

View File

@@ -25,7 +25,6 @@ const badgeVariants = cva(
orange: `${STATUS_BASE} bg-[#fed7aa] text-[#c2410c] dark:bg-[rgba(249,115,22,0.2)] dark:text-[#fdba74]`,
amber: `${STATUS_BASE} bg-[#fde68a] text-[#a16207] dark:bg-[rgba(245,158,11,0.2)] dark:text-[#fcd34d]`,
teal: `${STATUS_BASE} bg-[#99f6e4] text-[#0f766e] dark:bg-[rgba(20,184,166,0.2)] dark:text-[#5eead4]`,
cyan: `${STATUS_BASE} bg-[#a5f3fc] text-[#0e7490] dark:bg-[rgba(14,165,233,0.2)] dark:text-[#7dd3fc]`,
'gray-secondary': `${STATUS_BASE} bg-[var(--surface-4)] text-[var(--text-secondary)]`,
},
size: {
@@ -52,7 +51,6 @@ const STATUS_VARIANTS = [
'orange',
'amber',
'teal',
'cyan',
'gray-secondary',
] as const
@@ -86,7 +84,7 @@ export interface BadgeProps
* Supports two categories of variants:
* - **Bordered**: `default`, `outline` - traditional badges with borders
* - **Status colors**: `green`, `red`, `gray`, `blue`, `blue-secondary`, `purple`,
* `orange`, `amber`, `teal`, `cyan`, `gray-secondary` - borderless colored badges
* `orange`, `amber`, `teal`, `gray-secondary` - borderless colored badges
*
* Status color variants can display a dot indicator via the `dot` prop.
* All variants support an optional `icon` prop for leading icons.

View File

@@ -378,21 +378,10 @@ function buildManualTriggerOutput(
}
function buildIntegrationTriggerOutput(
finalInput: unknown,
_finalInput: unknown,
workflowInput: unknown
): NormalizedBlockOutput {
const base: NormalizedBlockOutput = isPlainObject(workflowInput)
? ({ ...(workflowInput as Record<string, unknown>) } as NormalizedBlockOutput)
: {}
if (isPlainObject(finalInput)) {
Object.assign(base, finalInput as Record<string, unknown>)
base.input = { ...(finalInput as Record<string, unknown>) }
} else {
base.input = finalInput
}
return mergeFilesIntoOutput(base, workflowInput)
return isPlainObject(workflowInput) ? (workflowInput as NormalizedBlockOutput) : {}
}
function extractSubBlocks(block: SerializedBlock): Record<string, unknown> | undefined {

View File

@@ -1,6 +1,5 @@
import { createLogger } from '@sim/logger'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { CoreTriggerType } from '@/stores/logs/filters/types'
const logger = createLogger('NotificationQueries')
@@ -19,7 +18,7 @@ export const notificationKeys = {
type NotificationType = 'webhook' | 'email' | 'slack'
type LogLevel = 'info' | 'error'
type TriggerType = CoreTriggerType
type TriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' | 'mcp'
type AlertRuleType =
| 'consecutive_failures'

View File

@@ -78,16 +78,12 @@ export interface A2AFile {
export function extractFileContent(message: Message): A2AFile[] {
return message.parts
.filter((part): part is FilePart => part.kind === 'file')
.map((part) => {
const file = part.file as unknown as Record<string, unknown>
const uri = (file.url as string) || (file.uri as string)
return {
name: file.name as string | undefined,
mimeType: file.mimeType as string | undefined,
...(uri ? { uri } : {}),
...(file.bytes ? { bytes: file.bytes as string } : {}),
}
})
.map((part) => ({
name: part.file.name,
mimeType: part.file.mimeType,
...('uri' in part.file ? { uri: part.file.uri } : {}),
...('bytes' in part.file ? { bytes: part.file.bytes } : {}),
}))
}
export interface ExecutionFileInput {

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

@@ -1,8 +1,15 @@
import { env } from '@/lib/core/config/env'
import type { CoreTriggerType } from '@/stores/logs/filters/types'
import type { TokenBucketConfig } from './storage'
export type TriggerType = CoreTriggerType | 'form' | 'api-endpoint'
export type TriggerType =
| 'api'
| 'webhook'
| 'schedule'
| 'manual'
| 'chat'
| 'mcp'
| 'form'
| 'api-endpoint'
export type RateLimitCounterType = 'sync' | 'async' | 'api-endpoint'

View File

@@ -7,7 +7,6 @@ import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { RateLimiter } from '@/lib/core/rate-limiter/rate-limiter'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
import type { CoreTriggerType } from '@/stores/logs/filters/types'
const logger = createLogger('ExecutionPreprocessing')
@@ -109,7 +108,7 @@ export interface PreprocessExecutionOptions {
// Required fields
workflowId: string
userId: string // The authenticated user ID
triggerType: CoreTriggerType | 'form'
triggerType: 'manual' | 'api' | 'webhook' | 'schedule' | 'chat' | 'mcp' | 'form'
executionId: string
requestId: string

View File

@@ -1,4 +1,4 @@
import { getBlock } from '@/blocks/registry'
import { getLatestBlock } from '@/blocks/registry'
import { getAllTriggers } from '@/triggers'
export interface TriggerOption {
@@ -38,7 +38,6 @@ export function getTriggerOptions(): TriggerOption[] {
{ value: 'form', label: 'Form', color: '#06b6d4' },
{ value: 'webhook', label: 'Webhook', color: '#ea580c' },
{ value: 'mcp', label: 'MCP', color: '#dc2626' },
{ value: 'a2a', label: 'A2A', color: '#14b8a6' },
]
for (const trigger of triggers) {
@@ -49,22 +48,13 @@ export function getTriggerOptions(): TriggerOption[] {
continue
}
const block = getBlock(provider)
const block = getLatestBlock(provider)
if (block) {
providerMap.set(provider, {
value: provider,
label: block.name, // Use block's display name (e.g., "Slack", "GitHub")
color: block.bgColor || '#6b7280', // Use block's hex color, fallback to gray
})
} else {
const label = formatProviderName(provider)
providerMap.set(provider, {
value: provider,
label,
color: '#6b7280', // gray fallback
})
}
providerMap.set(provider, {
value: provider,
label: block?.name || formatProviderName(provider),
color: block?.bgColor || '#6b7280',
})
}
const integrationOptions = Array.from(providerMap.values()).sort((a, b) =>

File diff suppressed because it is too large Load Diff

View File

@@ -2290,7 +2290,7 @@ describe('hasWorkflowChanged', () => {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
triggerConfig: { value: { event: 'push' } },
model: { value: 'gpt-4' },
webhookId: { value: null },
},
}),
@@ -2302,7 +2302,7 @@ describe('hasWorkflowChanged', () => {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
triggerConfig: { value: { event: 'push' } },
model: { value: 'gpt-4' },
webhookId: { value: 'wh_123456' },
},
}),
@@ -2318,7 +2318,7 @@ describe('hasWorkflowChanged', () => {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
triggerConfig: { value: { event: 'push' } },
model: { value: 'gpt-4' },
triggerPath: { value: '' },
},
}),
@@ -2330,7 +2330,7 @@ describe('hasWorkflowChanged', () => {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
triggerConfig: { value: { event: 'push' } },
model: { value: 'gpt-4' },
triggerPath: { value: '/api/webhooks/abc123' },
},
}),
@@ -2346,7 +2346,7 @@ describe('hasWorkflowChanged', () => {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
triggerConfig: { value: { event: 'push' } },
model: { value: 'gpt-4' },
webhookId: { value: null },
triggerPath: { value: '' },
},
@@ -2359,7 +2359,7 @@ describe('hasWorkflowChanged', () => {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
triggerConfig: { value: { event: 'push' } },
model: { value: 'gpt-4' },
webhookId: { value: 'wh_123456' },
triggerPath: { value: '/api/webhooks/abc123' },
},
@@ -2371,14 +2371,18 @@ describe('hasWorkflowChanged', () => {
})
it.concurrent(
'should detect change when triggerConfig differs but runtime metadata also differs',
'should detect change when actual config differs but runtime metadata also differs',
() => {
// Test that when a real config field changes along with runtime metadata,
// the change is still detected. Using 'model' as the config field since
// triggerConfig is now excluded from comparison (individual trigger fields
// are compared separately).
const deployedState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
triggerConfig: { value: { event: 'push' } },
model: { value: 'gpt-4' },
webhookId: { value: null },
},
}),
@@ -2390,7 +2394,7 @@ describe('hasWorkflowChanged', () => {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
triggerConfig: { value: { event: 'pull_request' } },
model: { value: 'gpt-4o' },
webhookId: { value: 'wh_123456' },
},
}),
@@ -2402,8 +2406,12 @@ describe('hasWorkflowChanged', () => {
)
it.concurrent(
'should not detect change when runtime metadata is added to current state',
'should not detect change when triggerConfig differs (individual fields compared separately)',
() => {
// triggerConfig is excluded from comparison because:
// 1. Individual trigger fields are stored as separate subblocks and compared individually
// 2. The client populates triggerConfig with default values from trigger definitions,
// which aren't present in the deployed state, causing false positive change detection
const deployedState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
@@ -2420,7 +2428,36 @@ describe('hasWorkflowChanged', () => {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
triggerConfig: { value: { event: 'push' } },
triggerConfig: { value: { event: 'pull_request', extraField: true } },
},
}),
},
})
expect(hasWorkflowChanged(currentState, deployedState)).toBe(false)
}
)
it.concurrent(
'should not detect change when runtime metadata is added to current state',
() => {
const deployedState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
model: { value: 'gpt-4' },
},
}),
},
})
const currentState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
model: { value: 'gpt-4' },
webhookId: { value: 'wh_123456' },
triggerPath: { value: '/api/webhooks/abc123' },
},
@@ -2440,7 +2477,7 @@ describe('hasWorkflowChanged', () => {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
triggerConfig: { value: { event: 'push' } },
model: { value: 'gpt-4' },
webhookId: { value: 'wh_old123' },
triggerPath: { value: '/api/webhooks/old' },
},
@@ -2453,7 +2490,7 @@ describe('hasWorkflowChanged', () => {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
triggerConfig: { value: { event: 'push' } },
model: { value: 'gpt-4' },
},
}),
},

View File

@@ -174,15 +174,7 @@ export type TimeRange =
export type LogLevel = 'error' | 'info' | 'running' | 'pending' | 'all' | (string & {})
/** Core trigger types for workflow execution */
export const CORE_TRIGGER_TYPES = [
'manual',
'api',
'schedule',
'chat',
'webhook',
'mcp',
'a2a',
] as const
export const CORE_TRIGGER_TYPES = ['manual', 'api', 'schedule', 'chat', 'webhook', 'mcp'] as const
export type CoreTriggerType = (typeof CORE_TRIGGER_TYPES)[number]

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

@@ -3,7 +3,6 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
import { AGENT, isCustomTool } from '@/executor/constants'
import { useCustomToolsStore } from '@/stores/custom-tools'
import { useEnvironmentStore } from '@/stores/settings/environment'
import { extractErrorMessage } from '@/tools/error-extractors'
import { tools } from '@/tools/registry'
import type { TableRow, ToolConfig, ToolResponse } from '@/tools/types'
@@ -163,22 +162,14 @@ export async function executeRequest(
const externalResponse = await fetch(url, { method, headers, body })
if (!externalResponse.ok) {
let errorData: any
let errorContent
try {
errorData = await externalResponse.json()
errorContent = await externalResponse.json()
} catch (_e) {
try {
errorData = await externalResponse.text()
} catch (_e2) {
errorData = null
}
errorContent = { message: externalResponse.statusText }
}
const error = extractErrorMessage({
status: externalResponse.status,
statusText: externalResponse.statusText,
data: errorData,
})
const error = errorContent.message || `${toolId} API error: ${externalResponse.statusText}`
logger.error(`${toolId} error:`, { error })
throw new Error(error)
}

View File

@@ -96,23 +96,3 @@ export function buildMeetingOutputs(): Record<string, TriggerOutput> {
},
} as Record<string, TriggerOutput>
}
/**
* Build output schema for generic webhook events
*/
export function buildGenericOutputs(): Record<string, TriggerOutput> {
return {
payload: {
type: 'object',
description: 'Raw webhook payload',
},
headers: {
type: 'object',
description: 'Request headers',
},
timestamp: {
type: 'string',
description: 'ISO8601 received timestamp',
},
} as Record<string, TriggerOutput>
}

View File

@@ -1,6 +1,6 @@
import { CirclebackIcon } from '@/components/icons'
import type { TriggerConfig } from '@/triggers/types'
import { buildGenericOutputs, circlebackSetupInstructions, circlebackTriggerOptions } from './utils'
import { buildMeetingOutputs, circlebackSetupInstructions, circlebackTriggerOptions } from './utils'
export const circlebackWebhookTrigger: TriggerConfig = {
id: 'circleback_webhook',
@@ -74,7 +74,7 @@ export const circlebackWebhookTrigger: TriggerConfig = {
},
],
outputs: buildGenericOutputs(),
outputs: buildMeetingOutputs(),
webhook: {
method: 'POST',

View File

@@ -31,8 +31,14 @@ export const TRIGGER_PERSISTED_SUBBLOCK_IDS: string[] = [
/**
* Trigger-related subblock IDs that represent runtime metadata. They should remain
* in the workflow state but must not be modified or cleared by diff operations.
*
* Note: 'triggerConfig' is included because it's an aggregate of individual trigger
* field subblocks. Those individual fields are compared separately, so comparing
* triggerConfig would be redundant. Additionally, the client populates triggerConfig
* with default values from the trigger definition on load, which aren't present in
* the deployed state, causing false positive change detection.
*/
export const TRIGGER_RUNTIME_SUBBLOCK_IDS: string[] = ['webhookId', 'triggerPath']
export const TRIGGER_RUNTIME_SUBBLOCK_IDS: string[] = ['webhookId', 'triggerPath', 'triggerConfig']
/**
* Maximum number of consecutive failures before a trigger (schedule/webhook) is auto-disabled.

View File

@@ -116,6 +116,11 @@ export const githubIssueClosedTrigger: TriggerConfig = {
],
outputs: {
event_type: {
type: 'string',
description:
'GitHub event type from X-GitHub-Event header (e.g., issues, pull_request, push)',
},
action: {
type: 'string',
description: 'Action performed (opened, closed, reopened, edited, etc.)',

View File

@@ -117,6 +117,10 @@ export const githubIssueCommentTrigger: TriggerConfig = {
],
outputs: {
event_type: {
type: 'string',
description: 'GitHub event type from X-GitHub-Event header (e.g., issue_comment)',
},
action: {
type: 'string',
description: 'Action performed (created, edited, deleted)',

View File

@@ -137,6 +137,11 @@ export const githubIssueOpenedTrigger: TriggerConfig = {
],
outputs: {
event_type: {
type: 'string',
description:
'GitHub event type from X-GitHub-Event header (e.g., issues, pull_request, push)',
},
action: {
type: 'string',
description: 'Action performed (opened, closed, reopened, edited, etc.)',

View File

@@ -117,6 +117,10 @@ export const githubPRClosedTrigger: TriggerConfig = {
],
outputs: {
event_type: {
type: 'string',
description: 'GitHub event type from X-GitHub-Event header (e.g., pull_request)',
},
action: {
type: 'string',
description: 'Action performed (opened, closed, synchronize, reopened, edited, etc.)',

View File

@@ -117,6 +117,10 @@ export const githubPRCommentTrigger: TriggerConfig = {
],
outputs: {
event_type: {
type: 'string',
description: 'GitHub event type from X-GitHub-Event header (e.g., issue_comment)',
},
action: {
type: 'string',
description: 'Action performed (created, edited, deleted)',

View File

@@ -116,6 +116,10 @@ export const githubPRMergedTrigger: TriggerConfig = {
],
outputs: {
event_type: {
type: 'string',
description: 'GitHub event type from X-GitHub-Event header (e.g., pull_request)',
},
action: {
type: 'string',
description: 'Action performed (opened, closed, synchronize, reopened, edited, etc.)',

View File

@@ -116,6 +116,10 @@ export const githubPROpenedTrigger: TriggerConfig = {
],
outputs: {
event_type: {
type: 'string',
description: 'GitHub event type from X-GitHub-Event header (e.g., pull_request)',
},
action: {
type: 'string',
description: 'Action performed (opened, closed, synchronize, reopened, edited, etc.)',

View File

@@ -117,6 +117,10 @@ export const githubPRReviewedTrigger: TriggerConfig = {
],
outputs: {
event_type: {
type: 'string',
description: 'GitHub event type from X-GitHub-Event header (e.g., pull_request_review)',
},
action: {
type: 'string',
description: 'Action performed (submitted, edited, dismissed)',

View File

@@ -116,6 +116,14 @@ export const githubPushTrigger: TriggerConfig = {
],
outputs: {
event_type: {
type: 'string',
description: 'GitHub event type from X-GitHub-Event header (e.g., push)',
},
branch: {
type: 'string',
description: 'Branch name derived from ref (e.g., main from refs/heads/main)',
},
ref: {
type: 'string',
description: 'Git reference that was pushed (e.g., refs/heads/main)',

View File

@@ -116,6 +116,10 @@ export const githubReleasePublishedTrigger: TriggerConfig = {
],
outputs: {
event_type: {
type: 'string',
description: 'GitHub event type from X-GitHub-Event header (e.g., release)',
},
action: {
type: 'string',
description:

View File

@@ -117,6 +117,10 @@ export const githubWorkflowRunTrigger: TriggerConfig = {
],
outputs: {
event_type: {
type: 'string',
description: 'GitHub event type from X-GitHub-Event header (e.g., workflow_run)',
},
action: {
type: 'string',
description: 'Action performed (requested, in_progress, completed)',

View File

@@ -265,11 +265,6 @@ function buildBaseWebhookOutputs(): Record<string, TriggerOutput> {
},
},
},
webhook: {
type: 'json',
description: 'Webhook metadata including provider, path, and raw payload',
},
}
}

View File

@@ -1,7 +1,7 @@
import { LemlistIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
buildActivityOutputs,
buildEmailBouncedOutputs,
buildLemlistExtraFields,
lemlistSetupInstructions,
lemlistTriggerOptions,
@@ -27,7 +27,7 @@ export const lemlistEmailBouncedTrigger: TriggerConfig = {
extraFields: buildLemlistExtraFields('lemlist_email_bounced'),
}),
outputs: buildActivityOutputs(),
outputs: buildEmailBouncedOutputs(),
webhook: {
method: 'POST',

View File

@@ -1,7 +1,7 @@
import { LemlistIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
buildActivityOutputs,
buildEmailClickedOutputs,
buildLemlistExtraFields,
lemlistSetupInstructions,
lemlistTriggerOptions,
@@ -27,7 +27,7 @@ export const lemlistEmailClickedTrigger: TriggerConfig = {
extraFields: buildLemlistExtraFields('lemlist_email_clicked'),
}),
outputs: buildActivityOutputs(),
outputs: buildEmailClickedOutputs(),
webhook: {
method: 'POST',

View File

@@ -1,7 +1,7 @@
import { LemlistIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
buildActivityOutputs,
buildEmailOpenedOutputs,
buildLemlistExtraFields,
lemlistSetupInstructions,
lemlistTriggerOptions,
@@ -27,7 +27,7 @@ export const lemlistEmailOpenedTrigger: TriggerConfig = {
extraFields: buildLemlistExtraFields('lemlist_email_opened'),
}),
outputs: buildActivityOutputs(),
outputs: buildEmailOpenedOutputs(),
webhook: {
method: 'POST',

View File

@@ -1,7 +1,7 @@
import { LemlistIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
buildEmailReplyOutputs,
buildEmailRepliedOutputs,
buildLemlistExtraFields,
lemlistSetupInstructions,
lemlistTriggerOptions,
@@ -30,7 +30,7 @@ export const lemlistEmailRepliedTrigger: TriggerConfig = {
extraFields: buildLemlistExtraFields('lemlist_email_replied'),
}),
outputs: buildEmailReplyOutputs(),
outputs: buildEmailRepliedOutputs(),
webhook: {
method: 'POST',

View File

@@ -1,7 +1,7 @@
import { LemlistIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
buildActivityOutputs,
buildEmailSentOutputs,
buildLemlistExtraFields,
lemlistSetupInstructions,
lemlistTriggerOptions,
@@ -27,7 +27,7 @@ export const lemlistEmailSentTrigger: TriggerConfig = {
extraFields: buildLemlistExtraFields('lemlist_email_sent'),
}),
outputs: buildActivityOutputs(),
outputs: buildEmailSentOutputs(),
webhook: {
method: 'POST',

View File

@@ -1,7 +1,7 @@
import { LemlistIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
buildActivityOutputs,
buildInterestOutputs,
buildLemlistExtraFields,
lemlistSetupInstructions,
lemlistTriggerOptions,
@@ -27,7 +27,7 @@ export const lemlistInterestedTrigger: TriggerConfig = {
extraFields: buildLemlistExtraFields('lemlist_interested'),
}),
outputs: buildActivityOutputs(),
outputs: buildInterestOutputs(),
webhook: {
method: 'POST',

View File

@@ -2,7 +2,7 @@ import { LemlistIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
buildLemlistExtraFields,
buildLinkedInReplyOutputs,
buildLinkedInRepliedOutputs,
lemlistSetupInstructions,
lemlistTriggerOptions,
} from '@/triggers/lemlist/utils'
@@ -27,7 +27,7 @@ export const lemlistLinkedInRepliedTrigger: TriggerConfig = {
extraFields: buildLemlistExtraFields('lemlist_linkedin_replied'),
}),
outputs: buildLinkedInReplyOutputs(),
outputs: buildLinkedInRepliedOutputs(),
webhook: {
method: 'POST',

View File

@@ -1,7 +1,7 @@
import { LemlistIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
buildActivityOutputs,
buildInterestOutputs,
buildLemlistExtraFields,
lemlistSetupInstructions,
lemlistTriggerOptions,
@@ -27,7 +27,7 @@ export const lemlistNotInterestedTrigger: TriggerConfig = {
extraFields: buildLemlistExtraFields('lemlist_not_interested'),
}),
outputs: buildActivityOutputs(),
outputs: buildInterestOutputs(),
webhook: {
method: 'POST',

View File

@@ -66,203 +66,254 @@ export function buildLemlistExtraFields(triggerId: string) {
}
/**
* Base activity outputs shared across all Lemlist triggers
* Core fields present in ALL Lemlist webhook payloads
* See: https://help.lemlist.com/en/articles/9423940-use-the-api-to-list-activity-types
*/
function buildBaseActivityOutputs(): Record<string, TriggerOutput> {
const coreOutputs = {
_id: {
type: 'string',
description: 'Unique activity identifier',
},
type: {
type: 'string',
description: 'Activity type (e.g., emailsSent, emailsReplied)',
},
createdAt: {
type: 'string',
description: 'Activity creation timestamp (ISO 8601)',
},
teamId: {
type: 'string',
description: 'Lemlist team identifier',
},
leadId: {
type: 'string',
description: 'Lead identifier',
},
campaignId: {
type: 'string',
description: 'Campaign identifier',
},
campaignName: {
type: 'string',
description: 'Campaign name',
},
} as const
/**
* Lead fields present in webhook payloads
*/
const leadOutputs = {
email: {
type: 'string',
description: 'Lead email address',
},
firstName: {
type: 'string',
description: 'Lead first name',
},
lastName: {
type: 'string',
description: 'Lead last name',
},
companyName: {
type: 'string',
description: 'Lead company name',
},
linkedinUrl: {
type: 'string',
description: 'Lead LinkedIn profile URL',
},
} as const
/**
* Sequence/campaign tracking fields for email activities
*/
const sequenceOutputs = {
sequenceId: {
type: 'string',
description: 'Sequence identifier',
},
sequenceStep: {
type: 'number',
description: 'Current step in the sequence (0-indexed)',
},
totalSequenceStep: {
type: 'number',
description: 'Total number of steps in the sequence',
},
isFirst: {
type: 'boolean',
description: 'Whether this is the first activity of this type for this step',
},
} as const
/**
* Sender information fields
*/
const senderOutputs = {
sendUserId: {
type: 'string',
description: 'Sender user identifier',
},
sendUserEmail: {
type: 'string',
description: 'Sender email address',
},
sendUserName: {
type: 'string',
description: 'Sender display name',
},
} as const
/**
* Email content fields
*/
const emailContentOutputs = {
subject: {
type: 'string',
description: 'Email subject line',
},
text: {
type: 'string',
description: 'Email body content (HTML)',
},
messageId: {
type: 'string',
description: 'Email message ID (RFC 2822 format)',
},
emailId: {
type: 'string',
description: 'Lemlist email identifier',
},
} as const
/**
* Build outputs for email sent events
*/
export function buildEmailSentOutputs(): Record<string, TriggerOutput> {
return {
type: {
type: 'string',
description: 'Activity type (emailsReplied, linkedinReplied, interested, emailsOpened, etc.)',
},
_id: {
type: 'string',
description: 'Unique activity identifier',
},
leadId: {
type: 'string',
description: 'Associated lead ID',
},
campaignId: {
type: 'string',
description: 'Campaign ID',
},
campaignName: {
type: 'string',
description: 'Campaign name',
},
sequenceId: {
type: 'string',
description: 'Sequence ID within the campaign',
},
stepId: {
type: 'string',
description: 'Step ID that triggered this activity',
},
createdAt: {
type: 'string',
description: 'When the activity occurred (ISO 8601)',
},
}
...coreOutputs,
...leadOutputs,
...sequenceOutputs,
...senderOutputs,
...emailContentOutputs,
} as Record<string, TriggerOutput>
}
/**
* Lead outputs - information about the lead
* Build outputs for email replied events
*/
function buildLeadOutputs(): Record<string, TriggerOutput> {
export function buildEmailRepliedOutputs(): Record<string, TriggerOutput> {
return {
lead: {
_id: {
type: 'string',
description: 'Lead unique identifier',
},
email: {
type: 'string',
description: 'Lead email address',
},
firstName: {
type: 'string',
description: 'Lead first name',
},
lastName: {
type: 'string',
description: 'Lead last name',
},
companyName: {
type: 'string',
description: 'Lead company name',
},
phone: {
type: 'string',
description: 'Lead phone number',
},
linkedinUrl: {
type: 'string',
description: 'Lead LinkedIn profile URL',
},
picture: {
type: 'string',
description: 'Lead profile picture URL',
},
icebreaker: {
type: 'string',
description: 'Personalized icebreaker text',
},
timezone: {
type: 'string',
description: 'Lead timezone (e.g., America/New_York)',
},
isUnsubscribed: {
type: 'boolean',
description: 'Whether the lead is unsubscribed',
},
},
}
...coreOutputs,
...leadOutputs,
...sequenceOutputs,
...senderOutputs,
...emailContentOutputs,
} as Record<string, TriggerOutput>
}
/**
* Standard activity outputs (activity + lead data)
* Build outputs for email opened events
*/
export function buildActivityOutputs(): Record<string, TriggerOutput> {
export function buildEmailOpenedOutputs(): Record<string, TriggerOutput> {
return {
...buildBaseActivityOutputs(),
...buildLeadOutputs(),
webhook: {
type: 'json',
description: 'Full webhook payload with all activity-specific data',
},
}
}
/**
* Email-specific outputs (includes message content for replies)
*/
export function buildEmailReplyOutputs(): Record<string, TriggerOutput> {
return {
...buildBaseActivityOutputs(),
...buildLeadOutputs(),
...coreOutputs,
...leadOutputs,
...sequenceOutputs,
...senderOutputs,
messageId: {
type: 'string',
description: 'Email message ID',
description: 'Email message ID that was opened',
},
subject: {
type: 'string',
description: 'Email subject line',
},
text: {
type: 'string',
description: 'Email reply text content',
},
html: {
type: 'string',
description: 'Email reply HTML content',
},
sentAt: {
type: 'string',
description: 'When the reply was sent',
},
webhook: {
type: 'json',
description: 'Full webhook payload with all email data',
},
}
} as Record<string, TriggerOutput>
}
/**
* LinkedIn-specific outputs (includes message content)
* Build outputs for email clicked events
*/
export function buildLinkedInReplyOutputs(): Record<string, TriggerOutput> {
export function buildEmailClickedOutputs(): Record<string, TriggerOutput> {
return {
...buildBaseActivityOutputs(),
...buildLeadOutputs(),
...coreOutputs,
...leadOutputs,
...sequenceOutputs,
...senderOutputs,
messageId: {
type: 'string',
description: 'LinkedIn message ID',
description: 'Email message ID containing the clicked link',
},
text: {
clickedUrl: {
type: 'string',
description: 'LinkedIn message text content',
description: 'URL that was clicked',
},
sentAt: {
type: 'string',
description: 'When the message was sent',
},
webhook: {
type: 'json',
description: 'Full webhook payload with all LinkedIn data',
},
}
} as Record<string, TriggerOutput>
}
/**
* All outputs for generic webhook (activity + lead + all possible fields)
* Build outputs for email bounced events
*/
export function buildAllOutputs(): Record<string, TriggerOutput> {
export function buildEmailBouncedOutputs(): Record<string, TriggerOutput> {
return {
...buildBaseActivityOutputs(),
...buildLeadOutputs(),
...coreOutputs,
...leadOutputs,
...sequenceOutputs,
...senderOutputs,
messageId: {
type: 'string',
description: 'Message ID (for email/LinkedIn events)',
description: 'Email message ID that bounced',
},
subject: {
errorMessage: {
type: 'string',
description: 'Email subject (for email events)',
description: 'Bounce error message',
},
} as Record<string, TriggerOutput>
}
/**
* Build outputs for LinkedIn replied events
*/
export function buildLinkedInRepliedOutputs(): Record<string, TriggerOutput> {
return {
...coreOutputs,
...leadOutputs,
...sequenceOutputs,
text: {
type: 'string',
description: 'Message text content',
description: 'LinkedIn message content',
},
html: {
type: 'string',
description: 'Message HTML content (for email events)',
},
sentAt: {
type: 'string',
description: 'When the message was sent',
},
webhook: {
type: 'json',
description: 'Full webhook payload with all data',
},
}
} as Record<string, TriggerOutput>
}
/**
* Build outputs for interested/not interested events
*/
export function buildInterestOutputs(): Record<string, TriggerOutput> {
return {
...coreOutputs,
...leadOutputs,
...sequenceOutputs,
} as Record<string, TriggerOutput>
}
/**
* Build outputs for generic webhook (all events)
* Includes all possible fields across event types
*/
export function buildLemlistOutputs(): Record<string, TriggerOutput> {
return {
...coreOutputs,
...leadOutputs,
...sequenceOutputs,
...senderOutputs,
...emailContentOutputs,
clickedUrl: {
type: 'string',
description: 'URL that was clicked (for emailsClicked events)',
},
errorMessage: {
type: 'string',
description: 'Error message (for bounce/failed events)',
},
} as Record<string, TriggerOutput>
}

View File

@@ -1,8 +1,8 @@
import { LemlistIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
buildAllOutputs,
buildLemlistExtraFields,
buildLemlistOutputs,
lemlistSetupInstructions,
lemlistTriggerOptions,
} from '@/triggers/lemlist/utils'
@@ -27,7 +27,7 @@ export const lemlistWebhookTrigger: TriggerConfig = {
extraFields: buildLemlistExtraFields('lemlist_webhook'),
}),
outputs: buildAllOutputs(),
outputs: buildLemlistOutputs(),
webhook: {
method: 'POST',

View File

@@ -110,6 +110,7 @@ export const telegramWebhookTrigger: TriggerConfig = {
},
sender: {
id: { type: 'number', description: 'Sender user ID' },
username: { type: 'string', description: 'Sender username (if available)' },
firstName: { type: 'string', description: 'Sender first name' },
lastName: { type: 'string', description: 'Sender last name' },
languageCode: { type: 'string', description: 'Sender language code (if available)' },

View File

@@ -136,6 +136,8 @@ export const typeformWebhookTrigger: TriggerConfig = {
'Array of respondent answers (only includes answered questions). Each answer contains type, value, and field reference.',
},
definition: {
description:
'Form definition (only included when "Include Form Definition" is enabled in trigger settings)',
id: {
type: 'string',
description: 'Form ID',

View File

@@ -96,10 +96,6 @@ export const webflowCollectionItemChangedTrigger: TriggerConfig = {
type: 'string',
description: 'The site ID where the event occurred',
},
workspaceId: {
type: 'string',
description: 'The workspace ID where the event occurred',
},
collectionId: {
type: 'string',
description: 'The collection ID where the item was changed',

View File

@@ -109,10 +109,6 @@ export const webflowCollectionItemCreatedTrigger: TriggerConfig = {
type: 'string',
description: 'The site ID where the event occurred',
},
workspaceId: {
type: 'string',
description: 'The workspace ID where the event occurred',
},
collectionId: {
type: 'string',
description: 'The collection ID where the item was created',

View File

@@ -97,10 +97,6 @@ export const webflowCollectionItemDeletedTrigger: TriggerConfig = {
type: 'string',
description: 'The site ID where the event occurred',
},
workspaceId: {
type: 'string',
description: 'The workspace ID where the event occurred',
},
collectionId: {
type: 'string',
description: 'The collection ID where the item was deleted',

View File

@@ -76,9 +76,9 @@ export const webflowFormSubmissionTrigger: TriggerConfig = {
type: 'string',
description: 'The site ID where the form was submitted',
},
workspaceId: {
formId: {
type: 'string',
description: 'The workspace ID where the event occurred',
description: 'The form ID',
},
name: {
type: 'string',

View File

@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "simstudio",