Compare commits

..

26 Commits

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

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

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

* improvement(emails): links, accounts, preview

* refactor(emails): file structure and wrapper components

* added envvar for personal emails sent, added isHosted gate

* fixed failing tests, added env mock

* fix: removed comment

---------

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

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

* hitl gaps

* deal with trigger worker crashes

* cleanup import strcuture

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

* feat(tools): added support for imap trigger

* feat(imap): added parity, tested

* ack PR comments

* final cleanup

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

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

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

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

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

* feat(admin): routes to manage deployments

* fix naming fo deployed by

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

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

* removed unused params, cleaned up redundant utils

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

* improvement(invite): aligned with rest of app

* fix(invite): error handling

* fix: addressed comments

---------

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

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

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

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

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

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

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

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