Compare commits

..

15 Commits

Author SHA1 Message Date
Siddharth Ganesan
6d46b44e51 Stash 2025-11-10 19:46:27 -08:00
Siddharth Ganesan
ff99d75055 Run from block v1 2025-11-10 19:09:43 -08:00
Waleed
0ed0a26b3b feat(workflow-block): added redeploy action to workflow header for workflow block (#1875) 2025-11-10 15:59:33 -08:00
Adam Gough
0d4d953169 fixed payload error (#1873) 2025-11-10 12:19:45 -08:00
Waleed
81a12e721e improvement(performance): added revalidation caches on ollama and openrouter models (#1872)
* improvement(performance): added revalidation caches on ollama and openrouter models

* ack PR comments
2025-11-10 12:19:22 -08:00
Waleed
b03f9702d2 feat(permissions): extend hook to detect missing scopes to return those scopes for upgrade, update credential selector subblock (#1869) 2025-11-10 11:40:15 -08:00
Vikhyath Mondreti
997c4639ed fix build 2025-11-10 10:00:51 -08:00
Emir Karabeg
1e8b4769aa feat: toolbar, terminal, tool-input, emcn updates, chat, deploy (#1864)
* feat: toolbar resizing and searching; refactor: copilot folders

* feat(terminal): clear, timestamp, run ID, input, height

* feat: tool inpul, emcn search

* feat: sidebar context menu, delete workflow hook

* feat: chat; improvement: input and dropdown/combobox padding

* feat(panel): deploy logic

* improvement(chat): streaming output
2025-11-10 03:08:13 -08:00
Waleed
28b416078c improvement(subblocks): fixed trigger save, schedule save, time inp, text subblocks and schedule/workflow badges, can now deploy from the badge itself (#1868) 2025-11-10 01:31:37 -08:00
Waleed
d0720b85bc feat(i18n): update translations (#1865) 2025-11-08 20:55:59 -08:00
Vikhyath Mondreti
75ce8882c8 improvement(execution): trigger manual execution using mock payloads (#1863)
* fix(err-message): manual run message

* make external triggers start workflow manually too

* improvement(execution): trigger manual execution using mock payloads

* remove redundant code and update generate mock value func

* cleanup code, add to docs

* fix multi trigger injection

* address greptile comments
2025-11-08 20:34:53 -08:00
Siddharth Ganesan
142d3aadb8 feat(helm): add copilot (#1833)
* Add helm for copilot

* Remove otel and log level

* Change repo name

* improvement(helm): enhance copilot chart with HA support and validation

* refactor(helm): consolidate copilot secrets and fix postgres volume mount
2025-11-08 17:36:48 -08:00
Waleed
7c6e6d1603 improvement(code): add wand config and system prompt for python code generation, strip \n from stdout in JS/Python (#1862) 2025-11-08 16:44:16 -08:00
Waleed
e186ea630a improvement(ux): optimistic updates for envvars, custom tools, folder operations, workflow deletions. shared hook for connection tags & tag dropdown, fix for triggers not re-rendering on trigger selected (#1861) 2025-11-08 15:45:29 -08:00
Waleed
b3490e9127 fix(build): remove mdx from transpilation (#1860) 2025-11-08 14:33:26 -08:00
235 changed files with 24028 additions and 7618 deletions

View File

@@ -38,3 +38,15 @@ Verwende den Start-Block für alles, was vom Editor, von Deploy-to-API oder von
3. Verbinde den Block mit dem Rest des Workflows.
> Bereitstellungen unterstützen jeden Trigger. Aktualisiere den Workflow, stelle ihn erneut bereit, und alle Trigger-Einstiegspunkte übernehmen den neuen Snapshot. Erfahre mehr unter [Ausführung → Bereitstellungs-Snapshots](/execution).
## Manuelle Ausführungspriorität
Wenn Sie im Editor auf **Ausführen** klicken, wählt Sim automatisch aus, welcher Auslöser basierend auf der folgenden Prioritätsreihenfolge ausgeführt wird:
1. **Start-Block** (höchste Priorität)
2. **Zeitplan-Auslöser**
3. **Externe Auslöser** (Webhooks, Integrationen wie Slack, Gmail, Airtable usw.)
Wenn Ihr Workflow mehrere Auslöser hat, wird der Auslöser mit der höchsten Priorität ausgeführt. Wenn Sie beispielsweise sowohl einen Start-Block als auch einen Webhook-Auslöser haben, wird durch Klicken auf Ausführen der Start-Block ausgeführt.
**Externe Auslöser mit Mock-Payloads**: Wenn externe Auslöser (Webhooks und Integrationen) manuell ausgeführt werden, generiert Sim automatisch Mock-Payloads basierend auf der erwarteten Datenstruktur des Auslösers. Dies stellt sicher, dass nachgelagerte Blöcke während des Tests Variablen korrekt auflösen können.

View File

@@ -39,3 +39,15 @@ Use the Start block for everything originating from the editor, deploy-to-API, o
> Deployments power every trigger. Update the workflow, redeploy, and all trigger entry points pick up the new snapshot. Learn more in [Execution → Deployment Snapshots](/execution).
## Manual Execution Priority
When you click **Run** in the editor, Sim automatically selects which trigger to execute based on the following priority order:
1. **Start Block** (highest priority)
2. **Schedule Triggers**
3. **External Triggers** (webhooks, integrations like Slack, Gmail, Airtable, etc.)
If your workflow has multiple triggers, the highest priority trigger will be executed. For example, if you have both a Start block and a Webhook trigger, clicking Run will execute the Start block.
**External triggers with mock payloads**: When external triggers (webhooks and integrations) are executed manually, Sim automatically generates mock payloads based on the trigger's expected data structure. This ensures downstream blocks can resolve variables correctly during testing.

View File

@@ -38,3 +38,15 @@ Usa el bloque Start para todo lo que se origina desde el editor, deploy-to-API o
3. Conecta el bloque al resto del flujo de trabajo.
> Los despliegues alimentan cada disparador. Actualiza el flujo de trabajo, vuelve a desplegar, y todos los puntos de entrada de disparadores recogen la nueva instantánea. Aprende más en [Ejecución → Instantáneas de despliegue](/execution).
## Prioridad de ejecución manual
Cuando haces clic en **Ejecutar** en el editor, Sim selecciona automáticamente qué disparador ejecutar según el siguiente orden de prioridad:
1. **Bloque de inicio** (prioridad más alta)
2. **Disparadores programados**
3. **Disparadores externos** (webhooks, integraciones como Slack, Gmail, Airtable, etc.)
Si tu flujo de trabajo tiene múltiples disparadores, se ejecutará el disparador de mayor prioridad. Por ejemplo, si tienes tanto un bloque de inicio como un disparador de webhook, al hacer clic en Ejecutar se ejecutará el bloque de inicio.
**Disparadores externos con cargas útiles simuladas**: Cuando los disparadores externos (webhooks e integraciones) se ejecutan manualmente, Sim genera automáticamente cargas útiles simuladas basadas en la estructura de datos esperada del disparador. Esto asegura que los bloques posteriores puedan resolver las variables correctamente durante las pruebas.

View File

@@ -38,3 +38,15 @@ Utilisez le bloc Start pour tout ce qui provient de l'éditeur, du déploiement
3. Connectez le bloc au reste du flux de travail.
> Les déploiements alimentent chaque déclencheur. Mettez à jour le flux de travail, redéployez, et tous les points d'entrée des déclencheurs récupèrent le nouveau snapshot. En savoir plus dans [Exécution → Snapshots de déploiement](/execution).
## Priorité d'exécution manuelle
Lorsque vous cliquez sur **Exécuter** dans l'éditeur, Sim sélectionne automatiquement le déclencheur à exécuter selon l'ordre de priorité suivant :
1. **Bloc de démarrage** (priorité la plus élevée)
2. **Déclencheurs planifiés**
3. **Déclencheurs externes** (webhooks, intégrations comme Slack, Gmail, Airtable, etc.)
Si votre flux de travail comporte plusieurs déclencheurs, celui ayant la priorité la plus élevée sera exécuté. Par exemple, si vous avez à la fois un bloc de démarrage et un déclencheur Webhook, cliquer sur Exécuter lancera le bloc de démarrage.
**Déclencheurs externes avec charges utiles simulées** : lorsque des déclencheurs externes (webhooks et intégrations) sont exécutés manuellement, Sim génère automatiquement des charges utiles simulées basées sur la structure de données attendue par le déclencheur. Cela garantit que les blocs en aval peuvent résoudre correctement les variables pendant les tests.

View File

@@ -38,3 +38,15 @@ import { Card, Cards } from 'fumadocs-ui/components/card'
3. ブロックをワークフローの残りの部分に接続します。
> デプロイメントはすべてのトリガーを動作させます。ワークフローを更新し、再デプロイすると、すべてのトリガーエントリーポイントが新しいスナップショットを取得します。詳細は[実行 → デプロイメントスナップショット](/execution)をご覧ください。
## 手動実行の優先順位
エディターで**実行**をクリックすると、Simは以下の優先順位に基づいて自動的に実行するトリガーを選択します
1. **スタートブロック**(最高優先度)
2. **スケジュールトリガー**
3. **外部トリガー**ウェブフック、Slack、Gmail、Airtableなどの連携
ワークフローに複数のトリガーがある場合、最も優先度の高いトリガーが実行されます。例えば、スタートブロックとウェブフックトリガーの両方がある場合、実行をクリックするとスタートブロックが実行されます。
**モックペイロードを持つ外部トリガー**外部トリガーウェブフックと連携が手動で実行される場合、Simはトリガーの予想されるデータ構造に基づいて自動的にモックペイロードを生成します。これにより、テスト中に下流のブロックが変数を正しく解決できるようになります。

View File

@@ -38,3 +38,15 @@ import { Card, Cards } from 'fumadocs-ui/components/card'
3. 将该块连接到工作流的其余部分。
> 部署为每个触发器提供支持。更新工作流,重新部署,所有触发器入口点都会获取新的快照。在[执行 → 部署快照](/execution)中了解更多信息。
## 手动执行优先级
当您在编辑器中点击 **运行** 时Sim 会根据以下优先级顺序自动选择要执行的触发器:
1. **开始块**(最高优先级)
2. **计划触发器**
3. **外部触发器**webhooks、Slack、Gmail、Airtable 等集成)
如果您的工作流有多个触发器,将执行优先级最高的触发器。例如,如果您同时有一个开始块和一个 Webhook 触发器,点击运行将执行开始块。
**带有模拟负载的外部触发器**当外部触发器webhooks 和集成被手动执行时Sim 会根据触发器的预期数据结构自动生成模拟负载。这确保了在测试期间,下游块可以正确解析变量。

View File

@@ -5788,6 +5788,11 @@ checksums:
content/7: cffe5b901d78ebf2000d07dc7579533e
content/8: 73486253d24eeff7ac44dfd0c8868d87
content/9: 05aed1f03c5717f3bcb10de2935332e8
content/10: 434a19ecd8a391bea52f68222f42b97d
content/11: c5d0a4062ef7a0a8b0c2610533fae6a0
content/12: e5ca2445d3b69b062af5bf0a2988e760
content/13: 67e0b520d57e352689789eff5803ebbc
content/14: a1d7382600994068ca24dc03f46b7c73
0bf172ef4ee9a2c94a2967d7d320b81b:
meta/title: 330265974a03ee22a09f42fa4ece25f6
meta/description: e3d54cbedf551315cf9e8749228c2d1c

View File

@@ -48,8 +48,8 @@ export default async function Page({ params }: { params: Promise<{ slug: string
/>
<header className='mx-auto max-w-[1450px] px-6 pt-8 sm:px-8 sm:pt-12 md:px-12 md:pt-16'>
<div className='mb-6'>
<Link href='/studio' className='text-gray-600 text-sm hover:text-gray-900'>
Back to Sim Studio
<Link href='/blog' className='text-gray-600 text-sm hover:text-gray-900'>
Back to Blog
</Link>
</div>
<div className='flex flex-col gap-8 md:flex-row md:gap-12'>
@@ -133,7 +133,7 @@ export default async function Page({ params }: { params: Promise<{ slug: string
<h2 className='mb-4 font-medium text-[24px]'>Related posts</h2>
<div className='grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3'>
{related.map((p) => (
<Link key={p.slug} href={`/studio/${p.slug}`} className='group'>
<Link key={p.slug} href={`/blog/${p.slug}`} className='group'>
<div className='overflow-hidden rounded-lg border border-gray-200'>
<Image
src={p.ogImage}

View File

@@ -20,7 +20,7 @@ export default async function AuthorPage({ params }: { params: Promise<{ id: str
'@context': 'https://schema.org',
'@type': 'Person',
name: author.name,
url: `https://sim.ai/studio/authors/${author.id}`,
url: `https://sim.ai/blog/authors/${author.id}`,
sameAs: author.url ? [author.url] : [],
image: author.avatarUrl,
}
@@ -44,7 +44,7 @@ export default async function AuthorPage({ params }: { params: Promise<{ id: str
</div>
<div className='grid grid-cols-1 gap-8 sm:grid-cols-2'>
{posts.map((p) => (
<Link key={p.slug} href={`/studio/${p.slug}`} className='group'>
<Link key={p.slug} href={`/blog/${p.slug}`} className='group'>
<div className='overflow-hidden rounded-lg border border-gray-200'>
<Image
src={p.ogImage}

View File

@@ -1,12 +1,12 @@
export default function Head() {
return (
<>
<link rel='canonical' href='https://sim.ai/studio' />
<link rel='canonical' href='https://sim.ai/blog' />
<link
rel='alternate'
type='application/rss+xml'
title='Sim Studio'
href='https://sim.ai/studio/rss.xml'
title='Sim Blog'
href='https://sim.ai/blog/rss.xml'
/>
</>
)

View File

@@ -1,6 +1,6 @@
import { Footer, Nav } from '@/app/(landing)/components'
export default function StudioLayout({ children }: { children: React.ReactNode }) {
export default function BlogLayout({ children }: { children: React.ReactNode }) {
const orgJsonLd = {
'@context': 'https://schema.org',
'@type': 'Organization',

View File

@@ -6,7 +6,7 @@ import { soehne } from '@/app/fonts/soehne/soehne'
export const revalidate = 3600
export default async function StudioIndex({
export default async function BlogIndex({
searchParams,
}: {
searchParams: Promise<{ page?: string; tag?: string }>
@@ -17,46 +17,44 @@ export default async function StudioIndex({
const all = await getAllPostMeta()
const filtered = tag ? all.filter((p) => p.tags.includes(tag)) : all
const featured = pageNum === 1 ? filtered.find((p) => p.featured) || filtered[0] : null
const listBase = featured ? filtered.filter((p) => p.slug !== featured.slug) : filtered
const totalPages = Math.max(1, Math.ceil(listBase.length / perPage))
const totalPages = Math.max(1, Math.ceil(filtered.length / perPage))
const start = (pageNum - 1) * perPage
const posts = listBase.slice(start, start + perPage)
const posts = filtered.slice(start, start + perPage)
// Tag filter chips are intentionally disabled for now.
// const tags = await getAllTags()
const studioJsonLd = {
const blogJsonLd = {
'@context': 'https://schema.org',
'@type': 'Blog',
name: 'Sim Studio',
url: 'https://sim.ai/studio',
name: 'Sim Blog',
url: 'https://sim.ai/blog',
description: 'Announcements, insights, and guides for building AI agent workflows.',
}
const rest = posts
const [featured, ...rest] = posts
return (
<main className={`${soehne.className} mx-auto max-w-[1200px] px-6 py-12 sm:px-8 md:px-12`}>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(studioJsonLd) }}
dangerouslySetInnerHTML={{ __html: JSON.stringify(blogJsonLd) }}
/>
<h1 className='mb-3 font-medium text-[40px] leading-tight sm:text-[56px]'>Sim Studio</h1>
<h1 className='mb-3 font-medium text-[40px] leading-tight sm:text-[56px]'>The Sim Times</h1>
<p className='mb-10 text-[18px] text-gray-700'>
Announcements, insights, and guides for building AI agent workflows.
</p>
{/* Tag filter chips hidden until we have more posts */}
{/* <div className='mb-10 flex flex-wrap gap-3'>
<Link href='/studio' className={`rounded-full border px-3 py-1 text-sm ${!tag ? 'border-black bg-black text-white' : 'border-gray-300'}`}>All</Link>
<Link href='/blog' className={`rounded-full border px-3 py-1 text-sm ${!tag ? 'border-black bg-black text-white' : 'border-gray-300'}`}>All</Link>
{tags.map((t) => (
<Link key={t.tag} href={`/studio?tag=${encodeURIComponent(t.tag)}`} className={`rounded-full border px-3 py-1 text-sm ${tag === t.tag ? 'border-black bg-black text-white' : 'border-gray-300'}`}>
<Link key={t.tag} href={`/blog?tag=${encodeURIComponent(t.tag)}`} className={`rounded-full border px-3 py-1 text-sm ${tag === t.tag ? 'border-black bg-black text-white' : 'border-gray-300'}`}>
{t.tag} ({t.count})
</Link>
))}
</div> */}
{featured && (
<Link href={`/studio/${featured.slug}`} className='group mb-10 block'>
<Link href={`/blog/${featured.slug}`} className='group mb-10 block'>
<div className='overflow-hidden rounded-2xl border border-gray-200'>
<Image
src={featured.ogImage}
@@ -137,7 +135,7 @@ export default async function StudioIndex({
return (
<Link
key={p.slug}
href={`/studio/${p.slug}`}
href={`/blog/${p.slug}`}
className='group mb-6 inline-block w-full break-inside-avoid'
>
<div className='overflow-hidden rounded-xl border border-gray-200 transition-colors duration-300 hover:border-gray-300'>
@@ -201,7 +199,7 @@ export default async function StudioIndex({
<div className='mt-10 flex items-center justify-center gap-3'>
{pageNum > 1 && (
<Link
href={`/studio?page=${pageNum - 1}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`}
href={`/blog?page=${pageNum - 1}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`}
className='rounded border px-3 py-1 text-sm'
>
Previous
@@ -212,7 +210,7 @@ export default async function StudioIndex({
</span>
{pageNum < totalPages && (
<Link
href={`/studio?page=${pageNum + 1}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`}
href={`/blog?page=${pageNum + 1}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`}
className='rounded border px-3 py-1 text-sm'
>
Next

View File

@@ -11,7 +11,7 @@ export async function GET() {
const xml = `<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0">
<channel>
<title>Sim Studio</title>
<title>Sim Blog</title>
<link>${site}</link>
<description>Announcements, insights, and guides for AI agent workflows.</description>
${items

View File

@@ -7,13 +7,13 @@ export default async function TagsIndex() {
<main className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
<h1 className='mb-6 font-medium text-[32px] leading-tight'>Browse by tag</h1>
<div className='flex flex-wrap gap-3'>
<Link href='/studio' className='rounded-full border border-gray-300 px-3 py-1 text-sm'>
<Link href='/blog' className='rounded-full border border-gray-300 px-3 py-1 text-sm'>
All
</Link>
{tags.map((t) => (
<Link
key={t.tag}
href={`/studio?tag=${encodeURIComponent(t.tag)}`}
href={`/blog?tag=${encodeURIComponent(t.tag)}`}
className='rounded-full border border-gray-300 px-3 py-1 text-sm'
>
{t.tag} ({t.count})

View File

@@ -14,11 +14,9 @@ import {
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { quickValidateEmail } from '@/lib/email/validation'
import { isHosted } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import Footer from '@/app/(landing)/components/footer/footer'
import Nav from '@/app/(landing)/components/nav/nav'
import { LegalLayout } from '@/app/(landing)/components'
import { soehne } from '@/app/fonts/soehne/soehne'
const logger = createLogger('CareersPage')
@@ -201,340 +199,329 @@ export default function CareersPage() {
}
return (
<main className={`${soehne.className} min-h-screen bg-white text-gray-900`}>
<Nav variant='landing' />
<LegalLayout title='Join Our Team'>
<div className={`${soehne.className} mx-auto max-w-2xl`}>
{/* Form Section */}
<section className='rounded-2xl border border-gray-200 bg-white p-6 shadow-sm sm:p-10'>
<h2 className='mb-2 font-medium text-2xl sm:text-3xl'>Apply Now</h2>
<p className='mb-8 text-gray-600 text-sm sm:text-base'>
Help us build the future of AI workflows
</p>
{/* Content */}
<div className='px-4 pt-[60px] pb-[80px] sm:px-8 md:px-[44px]'>
<h1 className='mb-10 text-center font-bold text-4xl text-gray-900 md:text-5xl'>
Join Our Team
</h1>
<div className='mx-auto max-w-4xl'>
{/* Form Section */}
<section className='rounded-2xl border border-gray-200 bg-white p-6 shadow-sm sm:p-10'>
<form onSubmit={onSubmit} className='space-y-5'>
{/* Name and Email */}
<div className='grid grid-cols-1 gap-4 sm:gap-6 md:grid-cols-2'>
<div className='space-y-2'>
<Label htmlFor='name' className='font-medium text-sm'>
Full Name *
</Label>
<Input
id='name'
placeholder='John Doe'
value={name}
onChange={(e) => setName(e.target.value)}
className={cn(
showErrors &&
nameErrors.length > 0 &&
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
)}
/>
{showErrors && nameErrors.length > 0 && (
<div className='mt-1 space-y-1 text-red-400 text-xs'>
{nameErrors.map((error, index) => (
<p key={index}>{error}</p>
))}
</div>
)}
</div>
<div className='space-y-2'>
<Label htmlFor='email' className='font-medium text-sm'>
Email *
</Label>
<Input
id='email'
type='email'
placeholder='john@example.com'
value={email}
onChange={(e) => setEmail(e.target.value)}
className={cn(
showErrors &&
emailErrors.length > 0 &&
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
)}
/>
{showErrors && emailErrors.length > 0 && (
<div className='mt-1 space-y-1 text-red-400 text-xs'>
{emailErrors.map((error, index) => (
<p key={index}>{error}</p>
))}
</div>
)}
</div>
</div>
{/* Phone and Position */}
<div className='grid grid-cols-1 gap-4 sm:gap-6 md:grid-cols-2'>
<div className='space-y-2'>
<Label htmlFor='phone' className='font-medium text-sm'>
Phone Number
</Label>
<Input
id='phone'
type='tel'
placeholder='+1 (555) 123-4567'
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='position' className='font-medium text-sm'>
Position of Interest *
</Label>
<Input
id='position'
placeholder='e.g. Full Stack Engineer, Product Designer'
value={position}
onChange={(e) => setPosition(e.target.value)}
className={cn(
showErrors &&
positionErrors.length > 0 &&
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
)}
/>
{showErrors && positionErrors.length > 0 && (
<div className='mt-1 space-y-1 text-red-400 text-xs'>
{positionErrors.map((error, index) => (
<p key={index}>{error}</p>
))}
</div>
)}
</div>
</div>
{/* LinkedIn and Portfolio */}
<div className='grid grid-cols-1 gap-4 sm:gap-6 md:grid-cols-2'>
<div className='space-y-2'>
<Label htmlFor='linkedin' className='font-medium text-sm'>
LinkedIn Profile
</Label>
<Input
id='linkedin'
placeholder='https://linkedin.com/in/yourprofile'
value={linkedin}
onChange={(e) => setLinkedin(e.target.value)}
className={cn(
showErrors &&
linkedinErrors.length > 0 &&
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
)}
/>
{showErrors && linkedinErrors.length > 0 && (
<div className='mt-1 space-y-1 text-red-400 text-xs'>
{linkedinErrors.map((error, index) => (
<p key={index}>{error}</p>
))}
</div>
)}
</div>
<div className='space-y-2'>
<Label htmlFor='portfolio' className='font-medium text-sm'>
Portfolio / Website
</Label>
<Input
id='portfolio'
placeholder='https://yourportfolio.com'
value={portfolio}
onChange={(e) => setPortfolio(e.target.value)}
className={cn(
showErrors &&
portfolioErrors.length > 0 &&
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
)}
/>
{showErrors && portfolioErrors.length > 0 && (
<div className='mt-1 space-y-1 text-red-400 text-xs'>
{portfolioErrors.map((error, index) => (
<p key={index}>{error}</p>
))}
</div>
)}
</div>
</div>
{/* Experience and Location */}
<div className='grid grid-cols-1 gap-4 sm:gap-6 md:grid-cols-2'>
<div className='space-y-2'>
<Label htmlFor='experience' className='font-medium text-sm'>
Years of Experience *
</Label>
<Select value={experience} onValueChange={setExperience}>
<SelectTrigger
className={cn(
showErrors &&
experienceErrors.length > 0 &&
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
)}
>
<SelectValue placeholder='Select experience level' />
</SelectTrigger>
<SelectContent>
<SelectItem value='0-1'>0-1 years</SelectItem>
<SelectItem value='1-3'>1-3 years</SelectItem>
<SelectItem value='3-5'>3-5 years</SelectItem>
<SelectItem value='5-10'>5-10 years</SelectItem>
<SelectItem value='10+'>10+ years</SelectItem>
</SelectContent>
</Select>
{showErrors && experienceErrors.length > 0 && (
<div className='mt-1 space-y-1 text-red-400 text-xs'>
{experienceErrors.map((error, index) => (
<p key={index}>{error}</p>
))}
</div>
)}
</div>
<div className='space-y-2'>
<Label htmlFor='location' className='font-medium text-sm'>
Location *
</Label>
<Input
id='location'
placeholder='e.g. San Francisco, CA'
value={location}
onChange={(e) => setLocation(e.target.value)}
className={cn(
showErrors &&
locationErrors.length > 0 &&
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
)}
/>
{showErrors && locationErrors.length > 0 && (
<div className='mt-1 space-y-1 text-red-400 text-xs'>
{locationErrors.map((error, index) => (
<p key={index}>{error}</p>
))}
</div>
)}
</div>
</div>
{/* Message */}
<form onSubmit={onSubmit} className='space-y-5'>
{/* Name and Email */}
<div className='grid grid-cols-1 gap-4 sm:gap-6 md:grid-cols-2'>
<div className='space-y-2'>
<Label htmlFor='message' className='font-medium text-sm'>
Tell us about yourself *
<Label htmlFor='name' className='font-medium text-sm'>
Full Name *
</Label>
<Textarea
id='message'
placeholder='Tell us about your experience, what excites you about Sim, and why you would be a great fit for this role...'
<Input
id='name'
placeholder='John Doe'
value={name}
onChange={(e) => setName(e.target.value)}
className={cn(
'min-h-[140px]',
showErrors &&
messageErrors.length > 0 &&
nameErrors.length > 0 &&
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
)}
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
<p className='mt-1.5 text-gray-500 text-xs'>Minimum 50 characters</p>
{showErrors && messageErrors.length > 0 && (
{showErrors && nameErrors.length > 0 && (
<div className='mt-1 space-y-1 text-red-400 text-xs'>
{messageErrors.map((error, index) => (
{nameErrors.map((error, index) => (
<p key={index}>{error}</p>
))}
</div>
)}
</div>
{/* Resume Upload */}
<div className='space-y-2'>
<Label htmlFor='resume' className='font-medium text-sm'>
Resume *
<Label htmlFor='email' className='font-medium text-sm'>
Email *
</Label>
<div className='relative'>
{resume ? (
<div className='flex items-center gap-2 rounded-md border border-input bg-background px-3 py-2'>
<span className='flex-1 truncate text-sm'>{resume.name}</span>
<button
type='button'
onClick={(e) => {
e.preventDefault()
setResume(null)
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}}
className='flex-shrink-0 text-muted-foreground transition-colors hover:text-foreground'
aria-label='Remove file'
>
<X className='h-4 w-4' />
</button>
</div>
) : (
<Input
id='resume'
type='file'
accept='.pdf,.doc,.docx'
onChange={handleFileChange}
ref={fileInputRef}
className={cn(
showErrors &&
resumeErrors.length > 0 &&
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
)}
/>
<Input
id='email'
type='email'
placeholder='john@example.com'
value={email}
onChange={(e) => setEmail(e.target.value)}
className={cn(
showErrors &&
emailErrors.length > 0 &&
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
)}
</div>
<p className='mt-1.5 text-gray-500 text-xs'>PDF or Word document, max 10MB</p>
{showErrors && resumeErrors.length > 0 && (
/>
{showErrors && emailErrors.length > 0 && (
<div className='mt-1 space-y-1 text-red-400 text-xs'>
{resumeErrors.map((error, index) => (
{emailErrors.map((error, index) => (
<p key={index}>{error}</p>
))}
</div>
)}
</div>
</div>
{/* Phone and Position */}
<div className='grid grid-cols-1 gap-4 sm:gap-6 md:grid-cols-2'>
<div className='space-y-2'>
<Label htmlFor='phone' className='font-medium text-sm'>
Phone Number
</Label>
<Input
id='phone'
type='tel'
placeholder='+1 (555) 123-4567'
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='position' className='font-medium text-sm'>
Position of Interest *
</Label>
<Input
id='position'
placeholder='e.g. Full Stack Engineer, Product Designer'
value={position}
onChange={(e) => setPosition(e.target.value)}
className={cn(
showErrors &&
positionErrors.length > 0 &&
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
)}
/>
{showErrors && positionErrors.length > 0 && (
<div className='mt-1 space-y-1 text-red-400 text-xs'>
{positionErrors.map((error, index) => (
<p key={index}>{error}</p>
))}
</div>
)}
</div>
</div>
{/* LinkedIn and Portfolio */}
<div className='grid grid-cols-1 gap-4 sm:gap-6 md:grid-cols-2'>
<div className='space-y-2'>
<Label htmlFor='linkedin' className='font-medium text-sm'>
LinkedIn Profile
</Label>
<Input
id='linkedin'
placeholder='https://linkedin.com/in/yourprofile'
value={linkedin}
onChange={(e) => setLinkedin(e.target.value)}
className={cn(
showErrors &&
linkedinErrors.length > 0 &&
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
)}
/>
{showErrors && linkedinErrors.length > 0 && (
<div className='mt-1 space-y-1 text-red-400 text-xs'>
{linkedinErrors.map((error, index) => (
<p key={index}>{error}</p>
))}
</div>
)}
</div>
{/* Submit Button */}
<div className='flex justify-end pt-2'>
<Button
type='submit'
disabled={isSubmitting || submitStatus === 'success'}
className='min-w-[200px] rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all duration-300 hover:opacity-90 disabled:opacity-50'
size='lg'
>
{isSubmitting ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Submitting...
</>
) : submitStatus === 'success' ? (
'Submitted'
) : (
'Submit Application'
<div className='space-y-2'>
<Label htmlFor='portfolio' className='font-medium text-sm'>
Portfolio / Website
</Label>
<Input
id='portfolio'
placeholder='https://yourportfolio.com'
value={portfolio}
onChange={(e) => setPortfolio(e.target.value)}
className={cn(
showErrors &&
portfolioErrors.length > 0 &&
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
)}
</Button>
/>
{showErrors && portfolioErrors.length > 0 && (
<div className='mt-1 space-y-1 text-red-400 text-xs'>
{portfolioErrors.map((error, index) => (
<p key={index}>{error}</p>
))}
</div>
)}
</div>
</form>
</section>
</div>
{/* Additional Info */}
<section className='mt-6 text-center text-gray-600 text-sm'>
<p>
Questions? Email us at{' '}
<a
href='mailto:careers@sim.ai'
className='font-medium text-gray-900 underline transition-colors hover:text-gray-700'
{/* Experience and Location */}
<div className='grid grid-cols-1 gap-4 sm:gap-6 md:grid-cols-2'>
<div className='space-y-2'>
<Label htmlFor='experience' className='font-medium text-sm'>
Years of Experience *
</Label>
<Select value={experience} onValueChange={setExperience}>
<SelectTrigger
className={cn(
showErrors &&
experienceErrors.length > 0 &&
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
)}
>
<SelectValue placeholder='Select experience level' />
</SelectTrigger>
<SelectContent>
<SelectItem value='0-1'>0-1 years</SelectItem>
<SelectItem value='1-3'>1-3 years</SelectItem>
<SelectItem value='3-5'>3-5 years</SelectItem>
<SelectItem value='5-10'>5-10 years</SelectItem>
<SelectItem value='10+'>10+ years</SelectItem>
</SelectContent>
</Select>
{showErrors && experienceErrors.length > 0 && (
<div className='mt-1 space-y-1 text-red-400 text-xs'>
{experienceErrors.map((error, index) => (
<p key={index}>{error}</p>
))}
</div>
)}
</div>
<div className='space-y-2'>
<Label htmlFor='location' className='font-medium text-sm'>
Location *
</Label>
<Input
id='location'
placeholder='e.g. San Francisco, CA'
value={location}
onChange={(e) => setLocation(e.target.value)}
className={cn(
showErrors &&
locationErrors.length > 0 &&
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
)}
/>
{showErrors && locationErrors.length > 0 && (
<div className='mt-1 space-y-1 text-red-400 text-xs'>
{locationErrors.map((error, index) => (
<p key={index}>{error}</p>
))}
</div>
)}
</div>
</div>
{/* Message */}
<div className='space-y-2'>
<Label htmlFor='message' className='font-medium text-sm'>
Tell us about yourself *
</Label>
<Textarea
id='message'
placeholder='Tell us about your experience, what excites you about Sim, and why you would be a great fit for this role...'
className={cn(
'min-h-[140px]',
showErrors &&
messageErrors.length > 0 &&
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
)}
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
<p className='mt-1.5 text-gray-500 text-xs'>Minimum 50 characters</p>
{showErrors && messageErrors.length > 0 && (
<div className='mt-1 space-y-1 text-red-400 text-xs'>
{messageErrors.map((error, index) => (
<p key={index}>{error}</p>
))}
</div>
)}
</div>
{/* Resume Upload */}
<div className='space-y-2'>
<Label htmlFor='resume' className='font-medium text-sm'>
Resume *
</Label>
<div className='relative'>
{resume ? (
<div className='flex items-center gap-2 rounded-md border border-input bg-background px-3 py-2'>
<span className='flex-1 truncate text-sm'>{resume.name}</span>
<button
type='button'
onClick={(e) => {
e.preventDefault()
setResume(null)
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}}
className='flex-shrink-0 text-muted-foreground transition-colors hover:text-foreground'
aria-label='Remove file'
>
<X className='h-4 w-4' />
</button>
</div>
) : (
<Input
id='resume'
type='file'
accept='.pdf,.doc,.docx'
onChange={handleFileChange}
ref={fileInputRef}
className={cn(
showErrors &&
resumeErrors.length > 0 &&
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
)}
/>
)}
</div>
<p className='mt-1.5 text-gray-500 text-xs'>PDF or Word document, max 10MB</p>
{showErrors && resumeErrors.length > 0 && (
<div className='mt-1 space-y-1 text-red-400 text-xs'>
{resumeErrors.map((error, index) => (
<p key={index}>{error}</p>
))}
</div>
)}
</div>
{/* Submit Button */}
<div className='flex justify-end pt-2'>
<Button
type='submit'
disabled={isSubmitting || submitStatus === 'success'}
className='min-w-[200px] rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all duration-300 hover:opacity-90 disabled:opacity-50'
size='lg'
>
careers@sim.ai
</a>
</p>
</section>
</div>
</div>
{isSubmitting ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Submitting...
</>
) : submitStatus === 'success' ? (
'Submitted'
) : (
'Submit Application'
)}
</Button>
</div>
</form>
</section>
{/* Footer - Only for hosted instances */}
{isHosted && (
<div className='relative z-20'>
<Footer fullWidth={true} />
</div>
)}
</main>
{/* Additional Info */}
<section className='mt-6 text-center text-gray-600 text-sm'>
<p>
Questions? Email us at{' '}
<a
href='mailto:careers@sim.ai'
className='font-medium text-gray-900 underline transition-colors hover:text-gray-700'
>
careers@sim.ai
</a>
</p>
</section>
</div>
</LegalLayout>
)
}

View File

@@ -217,10 +217,10 @@ export default function Footer({ fullWidth = false }: FooterProps) {
Enterprise
</Link>
<Link
href='/studio'
href='/blog'
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'
>
Sim Studio
Blog
</Link>
<Link
href='/changelog'

View File

@@ -8,14 +8,13 @@ import { soehne } from '@/app/fonts/soehne/soehne'
interface LegalLayoutProps {
title: string
children: React.ReactNode
navVariant?: 'landing' | 'auth' | 'legal'
}
export default function LegalLayout({ title, children, navVariant = 'legal' }: LegalLayoutProps) {
export default function LegalLayout({ title, children }: LegalLayoutProps) {
return (
<main className={`${soehne.className} min-h-screen bg-white text-gray-900`}>
{/* Header - Nav handles all conditional logic */}
<Nav variant={navVariant} />
<Nav variant='legal' />
{/* Content */}
<div className='px-12 pt-[40px] pb-[40px]'>

View File

@@ -71,7 +71,7 @@ export default function Nav({ hideAuthButtons = false, variant = 'landing' }: Na
</li>
<li>
<Link
href='/?from=nav#pricing'
href='#pricing'
className='text-[16px] text-muted-foreground transition-colors hover:text-foreground'
scroll={true}
>
@@ -88,14 +88,6 @@ export default function Nav({ hideAuthButtons = false, variant = 'landing' }: Na
Enterprise
</button>
</li>
<li>
<Link
href='/careers'
className='text-[16px] text-muted-foreground transition-colors hover:text-foreground'
>
Careers
</Link>
</li>
<li>
<a
href='https://github.com/simstudioai/sim'

View File

@@ -18,7 +18,11 @@ const credentialsQuerySchema = z
.object({
provider: z.string().nullish(),
workflowId: z.string().uuid('Workflow ID must be a valid UUID').nullish(),
credentialId: z.string().uuid('Credential ID must be a valid UUID').nullish(),
credentialId: z
.string()
.min(1, 'Credential ID must not be empty')
.max(255, 'Credential ID is too long')
.nullish(),
})
.refine((data) => data.provider || data.credentialId, {
message: 'Provider or credentialId is required',
@@ -206,7 +210,7 @@ export async function GET(request: NextRequest) {
displayName = `${acc.accountId} (${baseProvider})`
}
const grantedScopes = acc.scope ? acc.scope.split(/\s+/).filter(Boolean) : []
const grantedScopes = acc.scope ? acc.scope.split(/[\s,]+/).filter(Boolean) : []
const scopeEvaluation = evaluateScopeCoverage(acc.providerId, grantedScopes)
return {

View File

@@ -8,10 +8,9 @@ const logger = createLogger('CopilotTrainingAPI')
const WorkflowStateSchema = z.record(z.unknown())
const OperationSchema = z.object({
type: z.string(),
data: z.record(z.unknown()).optional(),
timestamp: z.number().optional(),
metadata: z.record(z.unknown()).optional(),
operation_type: z.string(),
block_id: z.string(),
params: z.record(z.unknown()).optional(),
})
const TrainingDataSchema = z.object({

View File

@@ -6,8 +6,6 @@ import type { ModelsObject } from '@/providers/ollama/types'
const logger = createLogger('OllamaModelsAPI')
const OLLAMA_HOST = env.OLLAMA_URL || 'http://localhost:11434'
export const dynamic = 'force-dynamic'
/**
* Get available Ollama models
*/
@@ -21,6 +19,7 @@ export async function GET(request: NextRequest) {
headers: {
'Content-Type': 'application/json',
},
next: { revalidate: 60 },
})
if (!response.ok) {

View File

@@ -4,8 +4,6 @@ import { filterBlacklistedModels } from '@/providers/utils'
const logger = createLogger('OpenRouterModelsAPI')
export const dynamic = 'force-dynamic'
interface OpenRouterModel {
id: string
}
@@ -18,7 +16,7 @@ export async function GET(_request: NextRequest) {
try {
const response = await fetch('https://openrouter.ai/api/v1/models', {
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
next: { revalidate: 300 },
})
if (!response.ok) {

View File

@@ -11,15 +11,23 @@ import {
loadDeployedWorkflowState,
loadWorkflowFromNormalizedTables,
} from '@/lib/workflows/db-helpers'
import type { NormalizedWorkflowData } from '@/lib/workflows/db-helpers'
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
import { type ExecutionEvent, encodeSSEEvent } from '@/lib/workflows/executor/execution-events'
import { PauseResumeManager } from '@/lib/workflows/executor/pause-resume-manager'
import { createStreamingResponse } from '@/lib/workflows/streaming'
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
import { type ExecutionMetadata, ExecutionSnapshot } from '@/executor/execution/snapshot'
import {
type ExecutionMetadata,
type SerializableExecutionState,
ExecutionSnapshot,
} from '@/executor/execution/snapshot'
import type { StreamingExecution } from '@/executor/types'
import { Serializer } from '@/serializer'
import type { SubflowType } from '@/stores/workflows/workflow/types'
import { getWorkflowExecutionState } from '@/lib/workflows/execution-state/service'
import { buildRunFromBlockPlan } from '@/lib/workflows/run-from-block/planner'
import { TriggerUtils } from '@/lib/workflows/triggers'
const logger = createLogger('WorkflowExecuteAPI')
@@ -29,6 +37,8 @@ const ExecuteWorkflowSchema = z.object({
stream: z.boolean().optional(),
useDraftState: z.boolean().optional(),
input: z.any().optional(),
startBlockId: z.string().optional(),
executionMode: z.enum(['run_from_block']).optional(),
})
export const runtime = 'nodejs'
@@ -122,6 +132,7 @@ export async function executeWorkflow(
triggerType,
useDraftState: false,
startTime: new Date().toISOString(),
executionMode: 'full',
}
const snapshot = new ExecutionSnapshot(
@@ -309,6 +320,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
stream: streamParam,
useDraftState,
input: validatedInput,
startBlockId,
executionMode,
} = validation.data
// For API key auth, the entire body is the input (except for our control fields)
@@ -321,16 +334,37 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
})()
: validatedInput
const shouldUseDraftState = useDraftState ?? auth.authType === 'session'
let shouldUseDraftState = useDraftState ?? auth.authType === 'session'
const isRunFromBlock = executionMode === 'run_from_block'
if (isRunFromBlock && auth.authType !== 'session') {
return NextResponse.json(
{ error: 'Run from block is only available within the client editor' },
{ status: 403 }
)
}
if (isRunFromBlock) {
if (!startBlockId) {
return NextResponse.json(
{ error: 'Run from block requires a block identifier.' },
{ status: 400 }
)
}
shouldUseDraftState = true
}
const streamHeader = req.headers.get('X-Stream-Response') === 'true'
const enableSSE = streamHeader || streamParam === true
const effectiveTriggerType = isRunFromBlock ? 'manual' : triggerType
logger.info(`[${requestId}] Starting server-side execution`, {
workflowId,
userId,
hasInput: !!input,
triggerType,
triggerType: effectiveTriggerType,
authType: auth.authType,
streamParam,
streamHeader,
@@ -341,13 +375,13 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
type LoggingTriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat'
let loggingTriggerType: LoggingTriggerType = 'manual'
if (
triggerType === 'api' ||
triggerType === 'chat' ||
triggerType === 'webhook' ||
triggerType === 'schedule' ||
triggerType === 'manual'
effectiveTriggerType === 'api' ||
effectiveTriggerType === 'chat' ||
effectiveTriggerType === 'webhook' ||
effectiveTriggerType === 'schedule' ||
effectiveTriggerType === 'manual'
) {
loggingTriggerType = triggerType as LoggingTriggerType
loggingTriggerType = effectiveTriggerType as LoggingTriggerType
}
const loggingSession = new LoggingSession(
workflowId,
@@ -365,7 +399,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
currentUsage: usageCheck.currentUsage,
limit: usageCheck.limit,
workflowId,
triggerType,
triggerType: effectiveTriggerType,
})
await loggingSession.safeStart({
@@ -394,8 +428,9 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
// Process file fields in workflow input (base64/URL to UserFile conversion)
let processedInput = input
let workflowData: NormalizedWorkflowData | null = null
try {
const workflowData = shouldUseDraftState
workflowData = shouldUseDraftState
? await loadWorkflowFromNormalizedTables(workflowId)
: await loadDeployedWorkflowState(workflowId)
@@ -447,6 +482,83 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
)
}
let runFromBlockPlan:
| {
snapshotState: SerializableExecutionState
resumePendingQueue: string[]
triggerBlockId: string
}
| null = null
if (isRunFromBlock) {
if (!workflowData) {
return NextResponse.json(
{ error: 'Unable to load workflow state for run from block execution' },
{ status: 400 }
)
}
const startCandidate = TriggerUtils.findStartBlock(workflowData.blocks, 'manual', false)
if (!startCandidate) {
return NextResponse.json(
{ error: 'No manual trigger block found for this workflow' },
{ status: 400 }
)
}
const latestState = await getWorkflowExecutionState(workflowId, startCandidate.blockId)
if (!latestState) {
return NextResponse.json(
{
error:
'No prior execution snapshot found. Run the workflow once before using run from block.',
},
{ status: 400 }
)
}
const serializedWorkflow = new Serializer().serializeWorkflow(
workflowData.blocks,
workflowData.edges,
workflowData.loops,
workflowData.parallels,
true
)
try {
const trimmedStartBlockId = startBlockId!.trim()
const triggerBlockIdForPlan = latestState.triggerBlockId || startCandidate.blockId
const plan = buildRunFromBlockPlan({
serializedWorkflow,
previousState: latestState.serializedState,
previousResolvedInputs: latestState.resolvedInputs,
previousResolvedOutputs: latestState.resolvedOutputs,
previousWorkflow: latestState.serializedWorkflow,
startBlockId: trimmedStartBlockId,
triggerBlockId: triggerBlockIdForPlan,
})
runFromBlockPlan = {
snapshotState: plan.snapshotState,
resumePendingQueue: plan.resumePendingQueue,
triggerBlockId: triggerBlockIdForPlan,
}
} catch (planError) {
logger.error(`[${requestId}] Failed to build run-from-block plan`, {
error: planError,
})
return NextResponse.json(
{
error:
planError instanceof Error ? planError.message : 'Unable to build run from block plan',
},
{ status: 400 }
)
}
}
if (!enableSSE) {
logger.info(`[${requestId}] Using non-SSE execution (direct JSON response)`)
try {
@@ -456,9 +568,13 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
workflowId,
workspaceId: workflow.workspaceId,
userId,
triggerType,
triggerType: effectiveTriggerType,
triggerBlockId: runFromBlockPlan?.triggerBlockId,
useDraftState: shouldUseDraftState,
startTime: new Date().toISOString(),
resumeFromSnapshot: isRunFromBlock,
executionMode: isRunFromBlock ? 'run_from_block' : 'full',
pendingBlocks: runFromBlockPlan?.resumePendingQueue,
}
const snapshot = new ExecutionSnapshot(
@@ -467,7 +583,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
processedInput,
{},
workflow.variables || {},
selectedOutputs
selectedOutputs,
runFromBlockPlan?.snapshotState
)
const result = await executeWorkflowCore({
@@ -527,7 +644,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
streamConfig: {
selectedOutputs: resolvedSelectedOutputs,
isSecureMode: false,
workflowTriggerType: triggerType === 'chat' ? 'chat' : 'api',
workflowTriggerType: effectiveTriggerType === 'chat' ? 'chat' : 'api',
},
createFilteredResult,
executionId,
@@ -710,9 +827,13 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
workflowId,
workspaceId: workflow.workspaceId,
userId,
triggerType,
triggerType: effectiveTriggerType,
triggerBlockId: runFromBlockPlan?.triggerBlockId,
useDraftState: shouldUseDraftState,
startTime: new Date().toISOString(),
resumeFromSnapshot: isRunFromBlock,
executionMode: isRunFromBlock ? 'run_from_block' : 'full',
pendingBlocks: runFromBlockPlan?.resumePendingQueue,
}
const snapshot = new ExecutionSnapshot(
@@ -721,7 +842,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
processedInput,
{},
workflow.variables || {},
selectedOutputs
selectedOutputs,
runFromBlockPlan?.snapshotState
)
const result = await executeWorkflowCore({

View File

@@ -20,7 +20,7 @@
--panel-width: 244px;
--toolbar-triggers-height: 300px;
--editor-connections-height: 200px;
--terminal-height: 30px;
--terminal-height: 100px;
}
.sidebar-container {

View File

@@ -114,7 +114,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
var toolbarParsed = JSON.parse(toolbarStored);
var toolbarState = toolbarParsed?.state;
var toolbarTriggersHeight = toolbarState?.toolbarTriggersHeight;
if (toolbarTriggersHeight !== undefined && toolbarTriggersHeight >= 100 && toolbarTriggersHeight <= 800) {
if (toolbarTriggersHeight !== undefined && toolbarTriggersHeight >= 30 && toolbarTriggersHeight <= 800) {
document.documentElement.style.setProperty('--toolbar-triggers-height', toolbarTriggersHeight + 'px');
}
}
@@ -144,13 +144,13 @@ export default function RootLayout({ children }: { children: React.ReactNode })
var terminalParsed = JSON.parse(terminalStored);
var terminalState = terminalParsed?.state;
var terminalHeight = terminalState?.terminalHeight;
var maxTerminalHeight = window.innerHeight * 0.5;
var maxTerminalHeight = window.innerHeight * 0.7;
// Cap stored height at 50% of viewport
// Cap stored height at 70% of viewport
if (terminalHeight >= 30 && terminalHeight <= maxTerminalHeight) {
document.documentElement.style.setProperty('--terminal-height', terminalHeight + 'px');
} else if (terminalHeight > maxTerminalHeight) {
// If stored height exceeds 50%, cap it
// If stored height exceeds 70%, cap it
document.documentElement.style.setProperty('--terminal-height', maxTerminalHeight + 'px');
}
}

View File

@@ -20,7 +20,7 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
pathname.startsWith('/careers') ||
pathname.startsWith('/changelog') ||
pathname.startsWith('/chat') ||
pathname.startsWith('/studio')
pathname.startsWith('/blog')
? 'light'
: undefined

View File

@@ -0,0 +1,826 @@
'use client'
import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { AlertCircle, ArrowDownToLine, ArrowUp, MoreVertical, Paperclip, X } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Badge,
Button,
Input,
Popover,
PopoverContent,
PopoverItem,
PopoverScrollArea,
PopoverTrigger,
Trash,
} from '@/components/emcn'
import { createLogger } from '@/lib/logs/console/logger'
import {
extractBlockIdFromOutputId,
extractPathFromOutputId,
parseOutputContentSafely,
} from '@/lib/response-format'
import { cn } from '@/lib/utils'
import { useScrollManagement } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
import type { BlockLog, ExecutionResult } from '@/executor/types'
import { getChatPosition, useChatStore } from '@/stores/chat/store'
import { useExecutionStore } from '@/stores/execution/store'
import { useTerminalConsoleStore } from '@/stores/terminal'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { ChatMessage, OutputSelect } from './components'
import { useChatBoundarySync, useChatDrag, useChatFileUpload, useChatResize } from './hooks'
const logger = createLogger('FloatingChat')
/**
* Formats file size in human-readable format
* @param bytes - Size in bytes
* @returns Formatted string with appropriate unit (B, KB, MB, GB)
*/
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${Math.round((bytes / 1024 ** i) * 10) / 10} ${units[i]}`
}
/**
* Reads files and converts them to data URLs for image display
* @param chatFiles - Array of chat files to process
* @returns Promise resolving to array of files with data URLs for images
*/
const processFileAttachments = async (chatFiles: any[]) => {
return Promise.all(
chatFiles.map(async (file) => {
let dataUrl = ''
if (file.type.startsWith('image/')) {
try {
dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(file.file)
})
} catch (error) {
logger.error('Error reading file as data URL:', error)
}
}
return {
id: file.id,
name: file.name,
type: file.type,
size: file.size,
dataUrl,
}
})
)
}
/**
* Extracts output value from logs based on output ID
* @param logs - Array of block logs from workflow execution
* @param outputId - Output identifier in format blockId or blockId.path
* @returns Extracted output value or undefined if not found
*/
const extractOutputFromLogs = (logs: BlockLog[] | undefined, outputId: string): any | undefined => {
const blockId = extractBlockIdFromOutputId(outputId)
const path = extractPathFromOutputId(outputId, blockId)
const log = logs?.find((l) => l.blockId === blockId)
if (!log) return undefined
let output = log.output
if (path) {
output = parseOutputContentSafely(output)
const pathParts = path.split('.')
let current = output
for (const part of pathParts) {
if (current && typeof current === 'object' && part in current) {
current = current[part]
} else {
return undefined
}
}
return current
}
return output
}
/**
* Formats output content for display in chat
* @param output - Output value to format (string, object, or other)
* @returns Formatted string, markdown code block for objects, or empty string
*/
const formatOutputContent = (output: any): string => {
if (typeof output === 'string') {
return output
}
if (output && typeof output === 'object') {
return `\`\`\`json\n${JSON.stringify(output, null, 2)}\n\`\`\``
}
return ''
}
/**
* Floating chat modal component
*
* A draggable chat interface positioned over the workflow canvas that allows users to:
* - Send messages and execute workflows
* - Upload and attach files
* - View streaming responses
* - Select workflow outputs as context
*
* The modal is constrained by sidebar, panel, and terminal dimensions and persists
* position across sessions using the floating chat store.
*/
export function Chat() {
const params = useParams()
const workspaceId = params.workspaceId as string
const { activeWorkflowId } = useWorkflowRegistry()
// Chat state (UI and messages from unified store)
const {
isChatOpen,
chatPosition,
chatWidth,
chatHeight,
setIsChatOpen,
setChatPosition,
setChatDimensions,
messages,
addMessage,
selectedWorkflowOutputs,
setSelectedWorkflowOutput,
appendMessageContent,
finalizeMessageStream,
getConversationId,
clearChat,
exportChatCSV,
} = useChatStore()
const { entries } = useTerminalConsoleStore()
const { isExecuting } = useExecutionStore()
const { handleRunWorkflow } = useWorkflowExecution()
// Local state
const [chatMessage, setChatMessage] = useState('')
const [promptHistory, setPromptHistory] = useState<string[]>([])
const [historyIndex, setHistoryIndex] = useState(-1)
// Refs
const inputRef = useRef<HTMLInputElement>(null)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const abortControllerRef = useRef<AbortController | null>(null)
// File upload hook
const {
chatFiles,
uploadErrors,
isDragOver,
removeFile,
clearFiles,
clearErrors,
handleFileInputChange,
handleDragEnter,
handleDragOver,
handleDragLeave,
handleDrop,
} = useChatFileUpload()
// Get actual position (default if not set)
const actualPosition = useMemo(
() => getChatPosition(chatPosition, chatWidth, chatHeight),
[chatPosition, chatWidth, chatHeight]
)
// Drag hook
const { handleMouseDown } = useChatDrag({
position: actualPosition,
width: chatWidth,
height: chatHeight,
onPositionChange: setChatPosition,
})
// Boundary sync hook - keeps chat within bounds when layout changes
useChatBoundarySync({
isOpen: isChatOpen,
position: actualPosition,
width: chatWidth,
height: chatHeight,
onPositionChange: setChatPosition,
})
// Resize hook - enables resizing from all edges and corners
const {
cursor: resizeCursor,
handleMouseMove: handleResizeMouseMove,
handleMouseLeave: handleResizeMouseLeave,
handleMouseDown: handleResizeMouseDown,
} = useChatResize({
position: actualPosition,
width: chatWidth,
height: chatHeight,
onPositionChange: setChatPosition,
onDimensionsChange: setChatDimensions,
})
// Get output entries from console
const outputEntries = useMemo(() => {
if (!activeWorkflowId) return []
return entries.filter((entry) => entry.workflowId === activeWorkflowId && entry.output)
}, [entries, activeWorkflowId])
// Get filtered messages for current workflow
const workflowMessages = useMemo(() => {
if (!activeWorkflowId) return []
return messages
.filter((msg) => msg.workflowId === activeWorkflowId)
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())
}, [messages, activeWorkflowId])
// Check if any message is currently streaming
const isStreaming = useMemo(() => {
// Match copilot semantics: only treat as streaming if the LAST message is streaming
const lastMessage = workflowMessages[workflowMessages.length - 1]
return Boolean(lastMessage?.isStreaming)
}, [workflowMessages])
// Map chat messages to copilot message format (type -> role) for scroll hook
const messagesForScrollHook = useMemo(() => {
return workflowMessages.map((msg) => ({
...msg,
role: msg.type,
}))
}, [workflowMessages])
// Scroll management hook - reuse copilot's implementation
const { scrollAreaRef, scrollToBottom } = useScrollManagement(messagesForScrollHook, isStreaming)
// Memoize user messages for performance
const userMessages = useMemo(() => {
return workflowMessages
.filter((msg) => msg.type === 'user')
.map((msg) => msg.content)
.filter((content): content is string => typeof content === 'string')
}, [workflowMessages])
// Update prompt history when workflow changes
useEffect(() => {
if (!activeWorkflowId) {
setPromptHistory([])
setHistoryIndex(-1)
return
}
setPromptHistory(userMessages)
setHistoryIndex(-1)
}, [activeWorkflowId, userMessages])
/**
* Auto-scroll to bottom when messages load
*/
useEffect(() => {
if (workflowMessages.length > 0 && isChatOpen) {
scrollToBottom()
}
}, [workflowMessages.length, scrollToBottom, isChatOpen])
// Get selected workflow outputs (deduplicated)
const selectedOutputs = useMemo(() => {
if (!activeWorkflowId) return []
const selected = selectedWorkflowOutputs[activeWorkflowId]
return selected && selected.length > 0 ? [...new Set(selected)] : []
}, [selectedWorkflowOutputs, activeWorkflowId])
/**
* Focuses the input field with optional delay
*/
const focusInput = useCallback((delay = 0) => {
timeoutRef.current && clearTimeout(timeoutRef.current)
timeoutRef.current = setTimeout(() => {
if (inputRef.current && document.contains(inputRef.current)) {
inputRef.current.focus({ preventScroll: true })
}
}, delay)
}, [])
// Cleanup on unmount
useEffect(() => {
return () => {
timeoutRef.current && clearTimeout(timeoutRef.current)
abortControllerRef.current?.abort()
}
}, [])
/**
* Processes streaming response from workflow execution
*/
const processStreamingResponse = useCallback(
async (stream: ReadableStream, responseMessageId: string) => {
const reader = stream.getReader()
const decoder = new TextDecoder()
let accumulatedContent = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) {
finalizeMessageStream(responseMessageId)
break
}
const chunk = decoder.decode(value)
const lines = chunk.split('\n\n')
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const data = line.substring(6)
if (data === '[DONE]') continue
try {
const json = JSON.parse(data)
const { event, data: eventData, chunk: contentChunk } = json
if (event === 'final' && eventData) {
const result = eventData as ExecutionResult
if ('success' in result && !result.success) {
const errorMessage = result.error || 'Workflow execution failed'
appendMessageContent(
responseMessageId,
`${accumulatedContent ? '\n\n' : ''}Error: ${errorMessage}`
)
finalizeMessageStream(responseMessageId)
return
}
finalizeMessageStream(responseMessageId)
} else if (contentChunk) {
accumulatedContent += contentChunk
appendMessageContent(responseMessageId, contentChunk)
}
} catch (e) {
logger.error('Error parsing stream data:', e)
}
}
}
} catch (error) {
logger.error('Error processing stream:', error)
} finally {
focusInput(100)
}
},
[appendMessageContent, finalizeMessageStream, focusInput]
)
/**
* Handles workflow execution response
*/
const handleWorkflowResponse = useCallback(
(result: any) => {
if (!result || !activeWorkflowId) return
// Handle streaming response
if ('stream' in result && result.stream instanceof ReadableStream) {
const responseMessageId = crypto.randomUUID()
addMessage({
id: responseMessageId,
content: '',
workflowId: activeWorkflowId,
type: 'workflow',
isStreaming: true,
})
processStreamingResponse(result.stream, responseMessageId)
return
}
// Handle success with logs
if ('success' in result && result.success && 'logs' in result) {
selectedOutputs
.map((outputId) => extractOutputFromLogs(result.logs, outputId))
.filter((output) => output !== undefined)
.forEach((output) => {
const content = formatOutputContent(output)
if (content) {
addMessage({
content,
workflowId: activeWorkflowId,
type: 'workflow',
})
}
})
return
}
// Handle error response
if ('success' in result && !result.success) {
const errorMessage = 'error' in result ? result.error : 'Workflow execution failed.'
addMessage({
content: `Error: ${errorMessage}`,
workflowId: activeWorkflowId,
type: 'workflow',
})
}
},
[activeWorkflowId, selectedOutputs, addMessage, processStreamingResponse]
)
/**
* Sends a chat message and executes the workflow
*/
const handleSendMessage = useCallback(async () => {
if ((!chatMessage.trim() && chatFiles.length === 0) || !activeWorkflowId || isExecuting) return
const sentMessage = chatMessage.trim()
// Update prompt history (only if new unique message)
if (sentMessage && promptHistory[promptHistory.length - 1] !== sentMessage) {
setPromptHistory((prev) => [...prev, sentMessage])
}
setHistoryIndex(-1)
// Reset abort controller
abortControllerRef.current?.abort()
abortControllerRef.current = new AbortController()
const conversationId = getConversationId(activeWorkflowId)
try {
// Process file attachments
const attachmentsWithData = await processFileAttachments(chatFiles)
// Add user message
const messageContent =
sentMessage || (chatFiles.length > 0 ? `Uploaded ${chatFiles.length} file(s)` : '')
addMessage({
content: messageContent,
workflowId: activeWorkflowId,
type: 'user',
attachments: attachmentsWithData,
})
// Prepare workflow input
const workflowInput: any = {
input: sentMessage,
conversationId,
}
if (chatFiles.length > 0) {
workflowInput.files = chatFiles.map((chatFile) => ({
name: chatFile.name,
size: chatFile.size,
type: chatFile.type,
file: chatFile.file,
}))
workflowInput.onUploadError = (message: string) => {
logger.error('File upload error:', message)
}
}
// Clear input and files
setChatMessage('')
clearFiles()
clearErrors()
focusInput(10)
// Execute workflow
const result = await handleRunWorkflow(workflowInput)
handleWorkflowResponse(result)
} catch (error) {
logger.error('Error in handleSendMessage:', error)
}
focusInput(100)
}, [
chatMessage,
chatFiles,
activeWorkflowId,
isExecuting,
promptHistory,
getConversationId,
addMessage,
handleRunWorkflow,
handleWorkflowResponse,
focusInput,
clearFiles,
clearErrors,
])
/**
* Handles keyboard input for chat
*/
const handleKeyPress = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSendMessage()
} else if (e.key === 'ArrowUp') {
e.preventDefault()
if (promptHistory.length > 0) {
const newIndex =
historyIndex === -1 ? promptHistory.length - 1 : Math.max(0, historyIndex - 1)
setHistoryIndex(newIndex)
setChatMessage(promptHistory[newIndex])
}
} else if (e.key === 'ArrowDown') {
e.preventDefault()
if (historyIndex >= 0) {
const newIndex = historyIndex + 1
if (newIndex >= promptHistory.length) {
setHistoryIndex(-1)
setChatMessage('')
} else {
setHistoryIndex(newIndex)
setChatMessage(promptHistory[newIndex])
}
}
}
},
[handleSendMessage, promptHistory, historyIndex]
)
/**
* Handles output selection changes
*/
const handleOutputSelection = useCallback(
(values: string[]) => {
if (!activeWorkflowId) return
const dedupedValues = [...new Set(values)]
setSelectedWorkflowOutput(activeWorkflowId, dedupedValues)
},
[activeWorkflowId, setSelectedWorkflowOutput]
)
/**
* Closes the chat modal
*/
const handleClose = useCallback(() => {
setIsChatOpen(false)
}, [setIsChatOpen])
// Don't render if not open
if (!isChatOpen) return null
return (
<div
className='fixed z-30 flex flex-col overflow-hidden rounded-[6px] bg-[#1E1E1E] px-[10px] pt-[2px] pb-[8px]'
style={{
left: `${actualPosition.x}px`,
top: `${actualPosition.y}px`,
width: `${chatWidth}px`,
height: `${chatHeight}px`,
cursor: resizeCursor || undefined,
}}
onMouseMove={handleResizeMouseMove}
onMouseLeave={handleResizeMouseLeave}
onMouseDown={handleResizeMouseDown}
>
{/* Header with drag handle */}
<div
className='flex h-[32px] flex-shrink-0 cursor-grab items-center justify-between bg-[#1E1E1E] p-0 active:cursor-grabbing'
onMouseDown={handleMouseDown}
>
<div className='flex items-center'>
<span className='flex-shrink-0 font-medium text-[#E6E6E6] text-[14px]'>Chat</span>
</div>
{/* Output selector - centered with mx-auto */}
<div className='mr-[6px] ml-auto' onMouseDown={(e) => e.stopPropagation()}>
<OutputSelect
workflowId={activeWorkflowId}
selectedOutputs={selectedOutputs}
onOutputSelect={handleOutputSelection}
disabled={!activeWorkflowId}
placeholder='Select outputs'
/>
</div>
<div className='flex items-center gap-[8px]'>
{/* More menu with actions */}
<Popover variant='default'>
<PopoverTrigger asChild>
<Button
variant='ghost'
className='!p-1.5 -m-1.5'
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className='h-[14px] w-[14px]' strokeWidth={2} />
</Button>
</PopoverTrigger>
<PopoverContent
side='bottom'
align='end'
sideOffset={8}
style={{ width: '110px', minWidth: '110px' }}
>
<PopoverScrollArea>
<PopoverItem
onClick={(e) => {
e.stopPropagation()
if (activeWorkflowId) exportChatCSV(activeWorkflowId)
}}
disabled={messages.length === 0}
>
<ArrowDownToLine className='h-[14px] w-[14px]' />
<span>Download</span>
</PopoverItem>
<PopoverItem
onClick={(e) => {
e.stopPropagation()
if (activeWorkflowId) clearChat(activeWorkflowId)
}}
disabled={messages.length === 0}
>
<Trash className='h-[14px] w-[14px]' />
<span>Clear</span>
</PopoverItem>
</PopoverScrollArea>
</PopoverContent>
</Popover>
{/* Close button */}
<Button variant='ghost' className='!p-1.5 -m-1.5' onClick={handleClose}>
<X className='h-[16px] w-[16px]' />
</Button>
</div>
</div>
{/* Chat content */}
<div className='flex flex-1 flex-col overflow-hidden'>
{/* Messages */}
<div className='flex-1 overflow-hidden'>
{workflowMessages.length === 0 ? (
<div className='flex h-full items-center justify-center text-[#8D8D8D] text-[13px]'>
No messages yet
</div>
) : (
<div ref={scrollAreaRef} className='h-full overflow-y-auto overflow-x-hidden'>
<div className='w-full max-w-full space-y-[8px] overflow-hidden py-[8px]'>
{workflowMessages.map((message) => (
<ChatMessage key={message.id} message={message} />
))}
</div>
</div>
)}
</div>
{/* Input section */}
<div
className='flex-none'
onDragEnter={!activeWorkflowId || isExecuting ? undefined : handleDragEnter}
onDragOver={!activeWorkflowId || isExecuting ? undefined : handleDragOver}
onDragLeave={!activeWorkflowId || isExecuting ? undefined : handleDragLeave}
onDrop={!activeWorkflowId || isExecuting ? undefined : handleDrop}
>
{/* Error messages */}
{uploadErrors.length > 0 && (
<div>
<div className='rounded-lg border border-[#883827] bg-[#491515]'>
<div className='flex items-start gap-2'>
<AlertCircle className='mt-0.5 h-3 w-3 shrink-0 text-[#EF4444]' />
<div className='flex-1'>
<div className='mb-1 font-medium text-[#EF4444] text-[11px]'>
File upload error
</div>
<div className='space-y-1'>
{uploadErrors.map((err, idx) => (
<div key={idx} className='text-[#EF4444] text-[10px]'>
{err}
</div>
))}
</div>
</div>
</div>
</div>
</div>
)}
{/* Combined input container */}
<div
className={`rounded-[4px] border bg-[#282828] py-0 pr-[6px] pl-[4px] transition-colors dark:bg-[#363636] ${
isDragOver
? 'border-[var(--brand-primary-hover-hex)] bg-purple-50/50 dark:border-[var(--brand-primary-hover-hex)] dark:bg-purple-950/20'
: 'border-[#3D3D3D]'
}`}
>
{/* File thumbnails */}
{chatFiles.length > 0 && (
<div className='mt-[4px] flex gap-[6px] overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
{chatFiles.map((file) => {
const isImage = file.type.startsWith('image/')
const previewUrl = isImage ? URL.createObjectURL(file.file) : null
return (
<div
key={file.id}
className={cn(
'group relative flex-shrink-0 overflow-hidden rounded-[6px] bg-[#232323]',
previewUrl
? 'h-[40px] w-[40px]'
: 'flex min-w-[80px] max-w-[120px] items-center justify-center px-[8px] py-[2px]'
)}
>
{previewUrl ? (
<img
src={previewUrl}
alt={file.name}
className='h-full w-full object-cover'
onLoad={() => URL.revokeObjectURL(previewUrl)}
/>
) : (
<div className='min-w-0 flex-1'>
<div className='truncate font-medium text-[#FFFFFF] text-[10px]'>
{file.name}
</div>
<div className='text-[#AEAEAE] text-[9px]'>
{formatFileSize(file.size)}
</div>
</div>
)}
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
removeFile(file.id)
}}
className='absolute top-0.5 right-0.5 h-4 w-4 p-0 opacity-0 transition-opacity group-hover:opacity-100'
>
<X className='h-2.5 w-2.5' />
</Button>
</div>
)
})}
</div>
)}
{/* Input field with inline buttons */}
<div className='relative'>
<Input
ref={inputRef}
value={chatMessage}
onChange={(e) => {
setChatMessage(e.target.value)
setHistoryIndex(-1)
}}
onKeyDown={handleKeyPress}
placeholder={isDragOver ? 'Drop files here...' : 'Type a message...'}
className='w-full border-0 bg-transparent pr-[56px] pl-[4px] shadow-none focus-visible:ring-0 focus-visible:ring-offset-0'
disabled={!activeWorkflowId || isExecuting}
/>
{/* Buttons positioned absolutely on the right */}
<div className='-translate-y-1/2 absolute top-1/2 right-[2px] flex items-center gap-[10px]'>
<Badge
onClick={() => document.getElementById('floating-chat-file-input')?.click()}
title='Attach file'
className={cn(
'cursor-pointer rounded-[6px] bg-transparent p-[0px] dark:bg-transparent',
(!activeWorkflowId || isExecuting || chatFiles.length >= 15) &&
'cursor-not-allowed opacity-50'
)}
>
<Paperclip className='!h-3.5 !w-3.5' />
</Badge>
<Button
onClick={handleSendMessage}
disabled={
(!chatMessage.trim() && chatFiles.length === 0) ||
!activeWorkflowId ||
isExecuting
}
className={cn(
'h-[22px] w-[22px] rounded-full p-0 transition-colors',
chatMessage.trim() || chatFiles.length > 0
? 'bg-[#C0C0C0] hover:bg-[#D0D0D0] dark:bg-[#C0C0C0] dark:hover:bg-[#D0D0D0]'
: 'bg-[#C0C0C0] dark:bg-[#C0C0C0]'
)}
>
<ArrowUp className='h-3.5 w-3.5 text-black' strokeWidth={2.25} />
</Button>
</div>
</div>
{/* Hidden file input */}
<input
id='floating-chat-file-input'
type='file'
multiple
accept='.pdf,.csv,.doc,.docx,.txt,.md,.xlsx,.xls,.html,.htm,.pptx,.ppt,.json,.xml,.rtf,image/*'
onChange={handleFileInputChange}
className='hidden'
disabled={!activeWorkflowId || isExecuting}
/>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,194 @@
import { useMemo } from 'react'
import { File, FileText, Image as ImageIcon } from 'lucide-react'
import { StreamingIndicator } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/copilot-message/components/smooth-streaming'
interface ChatAttachment {
id: string
name: string
type: string
dataUrl: string
size?: number
}
interface ChatMessageProps {
message: {
id: string
content: any
timestamp: string | Date
type: 'user' | 'workflow'
isStreaming?: boolean
attachments?: ChatAttachment[]
}
}
const MAX_WORD_LENGTH = 25
/**
* Formats file size in human-readable format
*/
const formatFileSize = (bytes?: number): string => {
if (!bytes || bytes === 0) return ''
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${Math.round((bytes / 1024 ** i) * 10) / 10} ${sizes[i]}`
}
/**
* Returns appropriate icon for file type
*/
const getFileIcon = (type: string) => {
if (type.includes('pdf')) return <FileText className='h-5 w-5 text-muted-foreground' />
if (type.startsWith('image/')) return <ImageIcon className='h-5 w-5 text-muted-foreground' />
if (type.includes('text') || type.includes('json'))
return <FileText className='h-5 w-5 text-muted-foreground' />
return <File className='h-5 w-5 text-muted-foreground' />
}
/**
* Opens image attachment in new window
*/
const openImageInNewWindow = (dataUrl: string, fileName: string) => {
const newWindow = window.open('', '_blank')
if (!newWindow) return
newWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<title>${fileName}</title>
<style>
body { margin: 0; display: flex; justify-content: center; align-items: center; min-height: 100vh; background: #000; }
img { max-width: 100%; max-height: 100vh; object-fit: contain; }
</style>
</head>
<body>
<img src="${dataUrl}" alt="${fileName}" />
</body>
</html>
`)
newWindow.document.close()
}
/**
* Component for wrapping long words to prevent overflow
*/
const WordWrap = ({ text }: { text: string }) => {
if (!text) return null
const parts = text.split(/(\s+)/g)
return (
<>
{parts.map((part, index) => {
if (part.match(/\s+/) || part.length <= MAX_WORD_LENGTH) {
return <span key={index}>{part}</span>
}
const chunks = []
for (let i = 0; i < part.length; i += MAX_WORD_LENGTH) {
chunks.push(part.substring(i, i + MAX_WORD_LENGTH))
}
return (
<span key={index} className='break-all'>
{chunks.map((chunk, chunkIndex) => (
<span key={chunkIndex}>{chunk}</span>
))}
</span>
)
})}
</>
)
}
/**
* Renders a chat message with optional file attachments
*/
export function ChatMessage({ message }: ChatMessageProps) {
const formattedContent = useMemo(() => {
if (typeof message.content === 'object' && message.content !== null) {
return JSON.stringify(message.content, null, 2)
}
return String(message.content || '')
}, [message.content])
const handleAttachmentClick = (attachment: ChatAttachment) => {
const validDataUrl = attachment.dataUrl?.trim()
if (validDataUrl?.startsWith('data:')) {
openImageInNewWindow(validDataUrl, attachment.name)
}
}
if (message.type === 'user') {
return (
<div className='w-full max-w-full overflow-hidden opacity-100 transition-opacity duration-200'>
{message.attachments && message.attachments.length > 0 && (
<div className='mb-2 flex flex-wrap gap-1.5'>
{message.attachments.map((attachment) => {
const isImage = attachment.type.startsWith('image/')
const hasValidDataUrl =
attachment.dataUrl?.trim() && attachment.dataUrl.startsWith('data:')
return (
<div
key={attachment.id}
className={`relative overflow-hidden rounded-md border border-border/50 bg-muted/20 ${
hasValidDataUrl ? 'cursor-pointer' : ''
} ${isImage ? 'h-16 w-16' : 'flex h-16 min-w-[120px] max-w-[200px] items-center gap-2 px-2'}`}
onClick={(e) => {
if (hasValidDataUrl) {
e.preventDefault()
e.stopPropagation()
handleAttachmentClick(attachment)
}
}}
>
{isImage && hasValidDataUrl ? (
<img
src={attachment.dataUrl}
alt={attachment.name}
className='h-full w-full object-cover'
/>
) : (
<>
<div className='flex h-8 w-8 flex-shrink-0 items-center justify-center rounded bg-background/50'>
{getFileIcon(attachment.type)}
</div>
<div className='min-w-0 flex-1'>
<div className='truncate font-medium text-foreground text-xs'>
{attachment.name}
</div>
{attachment.size && (
<div className='text-[10px] text-muted-foreground'>
{formatFileSize(attachment.size)}
</div>
)}
</div>
</>
)}
</div>
)
})}
</div>
)}
{formattedContent && !formattedContent.startsWith('Uploaded') && (
<div className='rounded-[4px] border border-[#3D3D3D] bg-[#282828] px-[8px] py-[6px] transition-all duration-200 dark:border-[#3D3D3D] dark:bg-[#363636]'>
<div className='whitespace-pre-wrap break-words font-medium font-sans text-[#0D0D0D] text-sm leading-[1.25rem] dark:text-gray-100'>
<WordWrap text={formattedContent} />
</div>
</div>
)}
</div>
)
}
return (
<div className='w-full max-w-full overflow-hidden pl-[2px] opacity-100 transition-opacity duration-200'>
<div className='whitespace-pre-wrap break-words font-[470] font-season text-[#707070] text-sm leading-[1.25rem] dark:text-[#E8E8E8]'>
<WordWrap text={formattedContent} />
{message.isStreaming && <StreamingIndicator />}
</div>
</div>
)
}

View File

@@ -1,3 +1,2 @@
export { ChatFileUpload } from './chat-file-upload/chat-file-upload'
export { ChatMessage } from './chat-message/chat-message'
export { OutputSelect } from './output-select/output-select'

View File

@@ -0,0 +1,327 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Check } from 'lucide-react'
import {
Badge,
Popover,
PopoverContent,
PopoverItem,
PopoverScrollArea,
PopoverSection,
PopoverTrigger,
} from '@/components/emcn'
import { extractFieldsFromSchema, parseResponseFormatSafely } from '@/lib/response-format'
import { getBlock } from '@/blocks'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
interface OutputSelectProps {
workflowId: string | null
selectedOutputs: string[]
onOutputSelect: (outputIds: string[]) => void
disabled?: boolean
placeholder?: string
valueMode?: 'id' | 'label'
}
export function OutputSelect({
workflowId,
selectedOutputs = [],
onOutputSelect,
disabled = false,
placeholder = 'Select outputs',
valueMode = 'id',
}: OutputSelectProps) {
const [open, setOpen] = useState(false)
const triggerRef = useRef<HTMLDivElement>(null)
const popoverRef = useRef<HTMLDivElement>(null)
const blocks = useWorkflowStore((state) => state.blocks)
const { isShowingDiff, isDiffReady, diffWorkflow } = useWorkflowDiffStore()
const subBlockValues = useSubBlockStore((state) =>
workflowId ? state.workflowValues[workflowId] : null
)
/**
* Uses diff blocks when in diff mode, otherwise main blocks
*/
const workflowBlocks = isShowingDiff && isDiffReady && diffWorkflow ? diffWorkflow.blocks : blocks
/**
* Extracts all available workflow outputs for the dropdown
*/
const workflowOutputs = useMemo(() => {
const outputs: Array<{
id: string
label: string
blockId: string
blockName: string
blockType: string
path: string
}> = []
if (!workflowId || !workflowBlocks || typeof workflowBlocks !== 'object') {
return outputs
}
const blockArray = Object.values(workflowBlocks)
if (blockArray.length === 0) return outputs
blockArray.forEach((block) => {
if (block.type === 'starter' || !block?.id || !block?.type) return
const blockName =
block.name && typeof block.name === 'string'
? block.name.replace(/\s+/g, '').toLowerCase()
: `block-${block.id}`
const blockConfig = getBlock(block.type)
const responseFormatValue =
isShowingDiff && isDiffReady && diffWorkflow
? diffWorkflow.blocks[block.id]?.subBlocks?.responseFormat?.value
: subBlockValues?.[block.id]?.responseFormat
const responseFormat = parseResponseFormatSafely(responseFormatValue, block.id)
let outputsToProcess: Record<string, any> = {}
if (responseFormat) {
const schemaFields = extractFieldsFromSchema(responseFormat)
if (schemaFields.length > 0) {
schemaFields.forEach((field) => {
outputsToProcess[field.name] = { type: field.type }
})
} else {
outputsToProcess = blockConfig?.outputs || {}
}
} else {
outputsToProcess = blockConfig?.outputs || {}
}
if (Object.keys(outputsToProcess).length === 0) return
const addOutput = (path: string, outputObj: any, prefix = '') => {
const fullPath = prefix ? `${prefix}.${path}` : path
const createOutput = () => ({
id: `${block.id}_${fullPath}`,
label: `${blockName}.${fullPath}`,
blockId: block.id,
blockName: block.name || `Block ${block.id}`,
blockType: block.type,
path: fullPath,
})
if (
typeof outputObj !== 'object' ||
outputObj === null ||
('type' in outputObj && typeof outputObj.type === 'string') ||
Array.isArray(outputObj)
) {
outputs.push(createOutput())
return
}
Object.entries(outputObj).forEach(([key, value]) => {
addOutput(key, value, fullPath)
})
}
Object.entries(outputsToProcess).forEach(([key, value]) => {
addOutput(key, value)
})
})
return outputs
}, [workflowBlocks, workflowId, isShowingDiff, isDiffReady, diffWorkflow, blocks, subBlockValues])
/**
* Checks if output is selected by id or label
*/
const isSelectedValue = (o: { id: string; label: string }) =>
selectedOutputs.includes(o.id) || selectedOutputs.includes(o.label)
/**
* Gets display text for selected outputs
*/
const selectedOutputsDisplayText = useMemo(() => {
if (!selectedOutputs || selectedOutputs.length === 0) {
return placeholder
}
const validOutputs = selectedOutputs.filter((val) =>
workflowOutputs.some((o) => o.id === val || o.label === val)
)
if (validOutputs.length === 0) {
return placeholder
}
if (validOutputs.length === 1) {
const output = workflowOutputs.find(
(o) => o.id === validOutputs[0] || o.label === validOutputs[0]
)
return output?.label || placeholder
}
return `${validOutputs.length} outputs`
}, [selectedOutputs, workflowOutputs, placeholder])
/**
* Groups outputs by block and sorts by distance from starter block
*/
const groupedOutputs = useMemo(() => {
const groups: Record<string, typeof workflowOutputs> = {}
const blockDistances: Record<string, number> = {}
const edges = useWorkflowStore.getState().edges
const starterBlock = Object.values(blocks).find((block) => block.type === 'starter')
const starterBlockId = starterBlock?.id
if (starterBlockId) {
const adjList: Record<string, string[]> = {}
edges.forEach((edge) => {
if (!adjList[edge.source]) adjList[edge.source] = []
adjList[edge.source].push(edge.target)
})
const visited = new Set<string>()
const queue: Array<[string, number]> = [[starterBlockId, 0]]
while (queue.length > 0) {
const [currentNodeId, distance] = queue.shift()!
if (visited.has(currentNodeId)) continue
visited.add(currentNodeId)
blockDistances[currentNodeId] = distance
const outgoingNodeIds = adjList[currentNodeId] || []
outgoingNodeIds.forEach((targetId) => {
queue.push([targetId, distance + 1])
})
}
}
workflowOutputs.forEach((output) => {
if (!groups[output.blockName]) groups[output.blockName] = []
groups[output.blockName].push(output)
})
return Object.entries(groups)
.map(([blockName, outputs]) => ({
blockName,
outputs,
distance: blockDistances[outputs[0]?.blockId] || 0,
}))
.sort((a, b) => b.distance - a.distance)
.reduce(
(acc, { blockName, outputs }) => {
acc[blockName] = outputs
return acc
},
{} as Record<string, typeof workflowOutputs>
)
}, [workflowOutputs, blocks])
/**
* Gets block color for an output
*/
const getOutputColor = (blockId: string, blockType: string) => {
const blockConfig = getBlock(blockType)
return blockConfig?.bgColor || '#2F55FF'
}
/**
* Handles output selection - toggle selection
*/
const handleOutputSelection = (value: string) => {
const emittedValue =
valueMode === 'label' ? value : workflowOutputs.find((o) => o.label === value)?.id || value
const index = selectedOutputs.indexOf(emittedValue)
const newSelectedOutputs =
index === -1
? [...new Set([...selectedOutputs, emittedValue])]
: selectedOutputs.filter((id) => id !== emittedValue)
onOutputSelect(newSelectedOutputs)
}
/**
* Closes popover when clicking outside
*/
useEffect(() => {
if (!open) return
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node
const insideTrigger = triggerRef.current?.contains(target)
const insidePopover = popoverRef.current?.contains(target)
if (!insideTrigger && !insidePopover) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [open])
return (
<Popover open={open} variant='default'>
<PopoverTrigger asChild>
<div ref={triggerRef} className='min-w-0 max-w-full'>
<Badge
variant='outline'
className='min-w-0 max-w-full cursor-pointer rounded-[6px]'
title='Select outputs'
aria-expanded={open}
onMouseDown={(e) => {
if (disabled || workflowOutputs.length === 0) return
e.stopPropagation()
setOpen((prev) => !prev)
}}
>
<span className='min-w-0 flex-1 truncate'>{selectedOutputsDisplayText}</span>
</Badge>
</div>
</PopoverTrigger>
<PopoverContent
ref={popoverRef}
side='bottom'
align='start'
sideOffset={4}
maxHeight={280}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<PopoverScrollArea className='space-y-[2px]'>
{Object.entries(groupedOutputs).map(([blockName, outputs]) => (
<div key={blockName}>
<PopoverSection>{blockName}</PopoverSection>
{outputs.map((output) => (
<PopoverItem
key={output.id}
active={isSelectedValue(output)}
onClick={() => handleOutputSelection(output.label)}
>
<div
className='flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center rounded'
style={{
backgroundColor: getOutputColor(output.blockId, output.blockType),
}}
>
<span className='font-bold text-[10px] text-white'>
{blockName.charAt(0).toUpperCase()}
</span>
</div>
<span className='min-w-0 flex-1 truncate'>{output.path}</span>
{isSelectedValue(output) && <Check className='h-3 w-3 flex-shrink-0' />}
</PopoverItem>
))}
</div>
))}
</PopoverScrollArea>
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,5 @@
export { useChatBoundarySync } from './use-chat-boundary-sync'
export { useChatDrag } from './use-chat-drag'
export type { ChatFile } from './use-chat-file-upload'
export { useChatFileUpload } from './use-chat-file-upload'
export { useChatResize } from './use-chat-resize'

View File

@@ -0,0 +1,112 @@
import { useCallback, useEffect, useRef } from 'react'
interface UseChatBoundarySyncProps {
isOpen: boolean
position: { x: number; y: number }
width: number
height: number
onPositionChange: (position: { x: number; y: number }) => void
}
/**
* Hook to synchronize chat position with layout boundary changes
* Keeps chat within bounds when sidebar, panel, or terminal resize
* Uses requestAnimationFrame for smooth real-time updates
*/
export function useChatBoundarySync({
isOpen,
position,
width,
height,
onPositionChange,
}: UseChatBoundarySyncProps) {
const rafIdRef = useRef<number | null>(null)
const positionRef = useRef(position)
const previousDimensionsRef = useRef({ sidebarWidth: 0, panelWidth: 0, terminalHeight: 0 })
// Keep position ref up to date
positionRef.current = position
const checkAndUpdatePosition = useCallback(() => {
// Get current layout dimensions
const sidebarWidth = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0'
)
const panelWidth = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--panel-width') || '0'
)
const terminalHeight = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--terminal-height') || '0'
)
// Check if dimensions actually changed
const prev = previousDimensionsRef.current
if (
prev.sidebarWidth === sidebarWidth &&
prev.panelWidth === panelWidth &&
prev.terminalHeight === terminalHeight
) {
return // No change, skip update
}
// Update previous dimensions
previousDimensionsRef.current = { sidebarWidth, panelWidth, terminalHeight }
// Calculate bounds
const minX = sidebarWidth
const maxX = window.innerWidth - panelWidth - width
const minY = 0
const maxY = window.innerHeight - terminalHeight - height
const currentPos = positionRef.current
// Check if current position is out of bounds
if (currentPos.x < minX || currentPos.x > maxX || currentPos.y < minY || currentPos.y > maxY) {
// Constrain to new bounds
const newPosition = {
x: Math.max(minX, Math.min(maxX, currentPos.x)),
y: Math.max(minY, Math.min(maxY, currentPos.y)),
}
onPositionChange(newPosition)
}
}, [width, height, onPositionChange])
useEffect(() => {
if (!isOpen) return
const handleResize = () => {
// Cancel any pending animation frame
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current)
}
// Schedule update on next animation frame for smooth 60fps updates
rafIdRef.current = requestAnimationFrame(() => {
checkAndUpdatePosition()
rafIdRef.current = null
})
}
// Listen for window resize
window.addEventListener('resize', handleResize)
// Create MutationObserver to watch for CSS variable changes
// This fires immediately when sidebar/panel/terminal resize
const observer = new MutationObserver(handleResize)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['style'],
})
// Initial check
checkAndUpdatePosition()
return () => {
window.removeEventListener('resize', handleResize)
observer.disconnect()
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current)
}
}
}, [isOpen, checkAndUpdatePosition])
}

View File

@@ -0,0 +1,93 @@
import { useCallback, useEffect, useRef } from 'react'
import { constrainChatPosition } from '@/stores/chat/store'
interface UseChatDragProps {
position: { x: number; y: number }
width: number
height: number
onPositionChange: (position: { x: number; y: number }) => void
}
/**
* Hook for handling drag functionality of floating chat modal
* Provides mouse event handlers and manages drag state
*/
export function useChatDrag({ position, width, height, onPositionChange }: UseChatDragProps) {
const isDraggingRef = useRef(false)
const dragStartRef = useRef({ x: 0, y: 0 })
const initialPositionRef = useRef({ x: 0, y: 0 })
/**
* Handle mouse down on drag handle - start dragging
*/
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
// Only left click
if (e.button !== 0) return
e.preventDefault()
e.stopPropagation()
isDraggingRef.current = true
dragStartRef.current = { x: e.clientX, y: e.clientY }
initialPositionRef.current = { ...position }
// Add dragging cursor to body
document.body.style.cursor = 'grabbing'
document.body.style.userSelect = 'none'
},
[position]
)
/**
* Handle mouse move - update position while dragging
*/
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!isDraggingRef.current) return
const deltaX = e.clientX - dragStartRef.current.x
const deltaY = e.clientY - dragStartRef.current.y
const newPosition = {
x: initialPositionRef.current.x + deltaX,
y: initialPositionRef.current.y + deltaY,
}
// Constrain to bounds
const constrainedPosition = constrainChatPosition(newPosition, width, height)
onPositionChange(constrainedPosition)
},
[onPositionChange, width, height]
)
/**
* Handle mouse up - stop dragging
*/
const handleMouseUp = useCallback(() => {
if (!isDraggingRef.current) return
isDraggingRef.current = false
// Remove dragging cursor
document.body.style.cursor = ''
document.body.style.userSelect = ''
}, [])
/**
* Set up global mouse event listeners
*/
useEffect(() => {
window.addEventListener('mousemove', handleMouseMove)
window.addEventListener('mouseup', handleMouseUp)
return () => {
window.removeEventListener('mousemove', handleMouseMove)
window.removeEventListener('mouseup', handleMouseUp)
}
}, [handleMouseMove, handleMouseUp])
return {
handleMouseDown,
}
}

View File

@@ -0,0 +1,172 @@
import { useCallback, useState } from 'react'
export interface ChatFile {
id: string
name: string
size: number
type: string
file: File
}
const MAX_FILES = 15
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
/**
* Hook for handling file uploads in the chat modal
* Manages file state, validation, and drag-drop functionality
*/
export function useChatFileUpload() {
const [chatFiles, setChatFiles] = useState<ChatFile[]>([])
const [uploadErrors, setUploadErrors] = useState<string[]>([])
const [dragCounter, setDragCounter] = useState(0)
const isDragOver = dragCounter > 0
/**
* Validate and add files
*/
const addFiles = useCallback(
(files: File[]) => {
const remainingSlots = Math.max(0, MAX_FILES - chatFiles.length)
const candidateFiles = files.slice(0, remainingSlots)
const errors: string[] = []
const validNewFiles: ChatFile[] = []
for (const file of candidateFiles) {
// Check file size
if (file.size > MAX_FILE_SIZE) {
errors.push(`${file.name} is too large (max 10MB)`)
continue
}
// Check for duplicates
const isDuplicate = chatFiles.some(
(existingFile) => existingFile.name === file.name && existingFile.size === file.size
)
if (isDuplicate) {
errors.push(`${file.name} already added`)
continue
}
validNewFiles.push({
id: crypto.randomUUID(),
name: file.name,
size: file.size,
type: file.type,
file,
})
}
if (errors.length > 0) {
setUploadErrors(errors)
}
if (validNewFiles.length > 0) {
setChatFiles([...chatFiles, ...validNewFiles])
// Clear errors when files are successfully added
if (errors.length === 0) {
setUploadErrors([])
}
}
},
[chatFiles]
)
/**
* Remove a file
*/
const removeFile = useCallback((fileId: string) => {
setChatFiles((prev) => prev.filter((f) => f.id !== fileId))
}, [])
/**
* Clear all files
*/
const clearFiles = useCallback(() => {
setChatFiles([])
setUploadErrors([])
}, [])
/**
* Clear errors
*/
const clearErrors = useCallback(() => {
setUploadErrors([])
}, [])
/**
* Handle file input change
*/
const handleFileInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files) return
const fileArray = Array.from(files)
addFiles(fileArray)
// Reset input value to allow selecting the same file again
e.target.value = ''
},
[addFiles]
)
/**
* Handle drag enter
*/
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setDragCounter((prev) => prev + 1)
}, [])
/**
* Handle drag over
*/
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'copy'
}, [])
/**
* Handle drag leave
*/
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setDragCounter((prev) => Math.max(0, prev - 1))
}, [])
/**
* Handle drop
*/
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setDragCounter(0)
const droppedFiles = Array.from(e.dataTransfer.files)
if (droppedFiles.length > 0) {
addFiles(droppedFiles)
}
},
[addFiles]
)
return {
chatFiles,
uploadErrors,
isDragOver,
addFiles,
removeFile,
clearFiles,
clearErrors,
handleFileInputChange,
handleDragEnter,
handleDragOver,
handleDragLeave,
handleDrop,
}
}

View File

@@ -0,0 +1,315 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import {
MAX_CHAT_HEIGHT,
MAX_CHAT_WIDTH,
MIN_CHAT_HEIGHT,
MIN_CHAT_WIDTH,
} from '@/stores/chat/store'
interface UseChatResizeProps {
position: { x: number; y: number }
width: number
height: number
onPositionChange: (position: { x: number; y: number }) => void
onDimensionsChange: (dimensions: { width: number; height: number }) => void
}
/**
* Resize direction types - supports all 8 directions (4 corners + 4 edges)
*/
type ResizeDirection =
| 'top-left'
| 'top'
| 'top-right'
| 'right'
| 'bottom-right'
| 'bottom'
| 'bottom-left'
| 'left'
| null
/**
* Edge detection threshold in pixels (matches sidebar/panel resize handle width)
*/
const EDGE_THRESHOLD = 8
/**
* Hook for handling multi-directional resize functionality of floating chat modal
* Supports resizing from all 8 directions: 4 corners and 4 edges
*/
export function useChatResize({
position,
width,
height,
onPositionChange,
onDimensionsChange,
}: UseChatResizeProps) {
const [cursor, setCursor] = useState<string>('')
const isResizingRef = useRef(false)
const activeDirectionRef = useRef<ResizeDirection>(null)
const resizeStartRef = useRef({ x: 0, y: 0 })
const initialStateRef = useRef({
x: 0,
y: 0,
width: 0,
height: 0,
})
/**
* Detect which edge or corner the mouse is near
* @param e - Mouse event
* @param chatElement - Chat container element
* @returns The direction the mouse is near, or null
*/
const detectResizeDirection = useCallback(
(e: React.MouseEvent, chatElement: HTMLElement): ResizeDirection => {
const rect = chatElement.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
const isNearTop = y <= EDGE_THRESHOLD
const isNearBottom = y >= rect.height - EDGE_THRESHOLD
const isNearLeft = x <= EDGE_THRESHOLD
const isNearRight = x >= rect.width - EDGE_THRESHOLD
// Check corners first (they take priority over edges)
if (isNearTop && isNearLeft) return 'top-left'
if (isNearTop && isNearRight) return 'top-right'
if (isNearBottom && isNearLeft) return 'bottom-left'
if (isNearBottom && isNearRight) return 'bottom-right'
// Check edges
if (isNearTop) return 'top'
if (isNearBottom) return 'bottom'
if (isNearLeft) return 'left'
if (isNearRight) return 'right'
return null
},
[]
)
/**
* Get cursor style for a given resize direction
*/
const getCursorForDirection = useCallback((direction: ResizeDirection): string => {
switch (direction) {
case 'top-left':
case 'bottom-right':
return 'nwse-resize'
case 'top-right':
case 'bottom-left':
return 'nesw-resize'
case 'top':
case 'bottom':
return 'ns-resize'
case 'left':
case 'right':
return 'ew-resize'
default:
return ''
}
}, [])
/**
* Handle mouse move over chat - update cursor based on proximity to edges/corners
*/
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
if (isResizingRef.current) return
const chatElement = e.currentTarget as HTMLElement
const direction = detectResizeDirection(e, chatElement)
const newCursor = getCursorForDirection(direction)
if (newCursor !== cursor) {
setCursor(newCursor)
}
},
[cursor, detectResizeDirection, getCursorForDirection]
)
/**
* Handle mouse leave - reset cursor
*/
const handleMouseLeave = useCallback(() => {
if (!isResizingRef.current) {
setCursor('')
}
}, [])
/**
* Handle mouse down on edge/corner - start resizing
*/
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
// Only left click
if (e.button !== 0) return
const chatElement = e.currentTarget as HTMLElement
const direction = detectResizeDirection(e, chatElement)
if (!direction) return
e.preventDefault()
e.stopPropagation()
isResizingRef.current = true
activeDirectionRef.current = direction
resizeStartRef.current = { x: e.clientX, y: e.clientY }
initialStateRef.current = {
x: position.x,
y: position.y,
width,
height,
}
// Set cursor on body
document.body.style.cursor = getCursorForDirection(direction)
document.body.style.userSelect = 'none'
},
[position, width, height, detectResizeDirection, getCursorForDirection]
)
/**
* Handle global mouse move - update dimensions while resizing
*/
const handleGlobalMouseMove = useCallback(
(e: MouseEvent) => {
if (!isResizingRef.current || !activeDirectionRef.current) return
const deltaX = e.clientX - resizeStartRef.current.x
const deltaY = e.clientY - resizeStartRef.current.y
const initial = initialStateRef.current
const direction = activeDirectionRef.current
let newX = initial.x
let newY = initial.y
let newWidth = initial.width
let newHeight = initial.height
// Get layout bounds
const sidebarWidth = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0'
)
const panelWidth = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--panel-width') || '0'
)
const terminalHeight = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--terminal-height') || '0'
)
// Calculate new dimensions based on resize direction
switch (direction) {
// Corners
case 'top-left':
newWidth = initial.width - deltaX
newHeight = initial.height - deltaY
newX = initial.x + deltaX
newY = initial.y + deltaY
break
case 'top-right':
newWidth = initial.width + deltaX
newHeight = initial.height - deltaY
newY = initial.y + deltaY
break
case 'bottom-left':
newWidth = initial.width - deltaX
newHeight = initial.height + deltaY
newX = initial.x + deltaX
break
case 'bottom-right':
newWidth = initial.width + deltaX
newHeight = initial.height + deltaY
break
// Edges
case 'top':
newHeight = initial.height - deltaY
newY = initial.y + deltaY
break
case 'bottom':
newHeight = initial.height + deltaY
break
case 'left':
newWidth = initial.width - deltaX
newX = initial.x + deltaX
break
case 'right':
newWidth = initial.width + deltaX
break
}
// Constrain dimensions to min/max
const constrainedWidth = Math.max(MIN_CHAT_WIDTH, Math.min(MAX_CHAT_WIDTH, newWidth))
const constrainedHeight = Math.max(MIN_CHAT_HEIGHT, Math.min(MAX_CHAT_HEIGHT, newHeight))
// Adjust position if dimensions were constrained on left/top edges
if (direction === 'top-left' || direction === 'bottom-left' || direction === 'left') {
if (constrainedWidth !== newWidth) {
newX = initial.x + initial.width - constrainedWidth
}
}
if (direction === 'top-left' || direction === 'top-right' || direction === 'top') {
if (constrainedHeight !== newHeight) {
newY = initial.y + initial.height - constrainedHeight
}
}
// Constrain position to bounds
const minX = sidebarWidth
const maxX = window.innerWidth - panelWidth - constrainedWidth
const minY = 0
const maxY = window.innerHeight - terminalHeight - constrainedHeight
const finalX = Math.max(minX, Math.min(maxX, newX))
const finalY = Math.max(minY, Math.min(maxY, newY))
// Update state
onDimensionsChange({
width: constrainedWidth,
height: constrainedHeight,
})
onPositionChange({
x: finalX,
y: finalY,
})
},
[onDimensionsChange, onPositionChange]
)
/**
* Handle global mouse up - stop resizing
*/
const handleGlobalMouseUp = useCallback(() => {
if (!isResizingRef.current) return
isResizingRef.current = false
activeDirectionRef.current = null
// Remove cursor from body
document.body.style.cursor = ''
document.body.style.userSelect = ''
setCursor('')
}, [])
/**
* Set up global mouse event listeners
*/
useEffect(() => {
window.addEventListener('mousemove', handleGlobalMouseMove)
window.addEventListener('mouseup', handleGlobalMouseUp)
return () => {
window.removeEventListener('mousemove', handleGlobalMouseMove)
window.removeEventListener('mouseup', handleGlobalMouseUp)
}
}, [handleGlobalMouseMove, handleGlobalMouseUp])
return {
cursor,
handleMouseMove,
handleMouseLeave,
handleMouseDown,
}
}

View File

@@ -23,12 +23,12 @@ import {
} from '@/components/ui'
import { createLogger } from '@/lib/logs/console/logger'
import { getEmailDomain } from '@/lib/urls/utils'
import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select'
import { AuthSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/auth-selector'
import { IdentifierInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/identifier-input'
import { SuccessView } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/success-view'
import { useChatDeployment } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-chat-deployment'
import { useChatForm } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-chat-form'
import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/output-select/output-select'
const logger = createLogger('ChatDeploy')

View File

@@ -2,7 +2,7 @@ import { useCallback, useState } from 'react'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger'
import type { ChatFormData } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-chat-form'
import type { OutputConfig } from '@/stores/panel/chat/types'
import type { OutputConfig } from '@/stores/chat/store'
const logger = createLogger('ChatDeployment')

View File

@@ -12,7 +12,7 @@ import {
} from '@/components/ui/dropdown-menu'
import { Label } from '@/components/ui/label'
import { getEnv, isTruthy } from '@/lib/env'
import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/output-select/output-select'
import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select'
interface ExampleCommandProps {
command: string

View File

@@ -1,4 +1,4 @@
export * from './file-display'
export * from './markdown-renderer'
export { default as CopilotMarkdownRenderer } from './markdown-renderer'
export * from './smooth-streaming'
export * from './thinking-block'

View File

@@ -6,6 +6,11 @@ import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Tooltip } from '@/components/emcn'
/**
* Recursively extracts text content from React elements
* @param element - React node to extract text from
* @returns Concatenated text content
*/
const getTextContent = (element: React.ReactNode): string => {
if (typeof element === 'string') {
return element
@@ -91,7 +96,12 @@ if (typeof document !== 'undefined') {
}
}
// Link component with preview
/**
* 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
*/
function LinkWithPreview({ href, children }: { href: string; children: React.ReactNode }) {
return (
<Tooltip.Root delayDuration={300}>
@@ -112,10 +122,22 @@ function LinkWithPreview({ href, children }: { href: string; children: React.Rea
)
}
/**
* Props for the CopilotMarkdownRenderer component
*/
interface CopilotMarkdownRendererProps {
/** Markdown content to render */
content: string
}
/**
* CopilotMarkdownRenderer renders markdown content with custom styling
* Supports GitHub-flavored markdown, code blocks with syntax highlighting,
* tables, links with preview, and more
*
* @param props - Component props
* @returns Rendered markdown content
*/
export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRendererProps) {
const [copiedCodeBlocks, setCopiedCodeBlocks] = useState<Record<string, boolean>>({})

View File

@@ -14,19 +14,26 @@ const TIMER_UPDATE_INTERVAL = 100
*/
const SECONDS_THRESHOLD = 1000
/**
* Props for the ShimmerOverlayText component
*/
interface ShimmerOverlayTextProps {
/** Label text to display */
label: string
/** Value text to display */
value: string
/** Whether the shimmer animation is active */
active?: boolean
}
/**
* ShimmerOverlayText component for thinking block
* Applies shimmer effect to the "Thought for X.Xs" text during streaming
*
* @param props - Component props
* @returns Text with optional shimmer overlay effect
*/
function ShimmerOverlayText({
label,
value,
active = false,
}: {
label: string
value: string
active?: boolean
}) {
function ShimmerOverlayText({ label, value, active = false }: ShimmerOverlayTextProps) {
return (
<span className='relative inline-block'>
<span style={{ color: '#B8B8B8' }}>{label}</span>

View File

@@ -1,7 +1,7 @@
'use client'
import { FileText, Image, Loader2, X } from 'lucide-react'
import { Button } from '@/components/ui'
import { Button } from '@/components/emcn'
interface AttachedFile {
id: string
@@ -67,11 +67,11 @@ export function AttachedFilesDisplay({
const isImageFile = (type: string) => type.startsWith('image/')
return (
<div className='mb-2 flex flex-wrap gap-1.5'>
<div className='mb-2 flex gap-1.5 overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
{files.map((file) => (
<div
key={file.id}
className='group relative h-16 w-16 cursor-pointer overflow-hidden rounded-md border border-border/50 bg-muted/20 transition-all hover:bg-muted/40'
className='group relative h-16 w-16 flex-shrink-0 cursor-pointer overflow-hidden rounded-md border border-border/50 bg-muted/20 transition-all hover:bg-muted/40'
title={`${file.name} (${formatFileSize(file.size)})`}
onClick={() => onFileClick(file)}
>
@@ -103,12 +103,11 @@ export function AttachedFilesDisplay({
{!file.uploading && (
<Button
variant='ghost'
size='icon'
onClick={(e) => {
e.stopPropagation()
onFileRemove(file.id)
}}
className='absolute top-0.5 right-0.5 h-5 w-5 bg-black/50 text-white opacity-0 transition-opacity hover:bg-black/70 group-hover:opacity-100'
className='absolute top-0.5 right-0.5 h-5 w-5 p-0 opacity-0 transition-opacity group-hover:opacity-100'
>
<X className='h-3 w-3' />
</Button>

View File

@@ -1,5 +1,5 @@
export { AttachedFilesDisplay } from './attached-files-display'
export { ContextPills } from './context-pills'
export { MentionMenuPortal } from './mention-menu-portal'
export { ModeSelector } from './mode-selector'
export { ModelSelector } from './model-selector'
export { AttachedFilesDisplay } from './attached-files-display/attached-files-display'
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'

View File

@@ -10,9 +10,9 @@ import {
PopoverItem,
PopoverScrollArea,
} from '@/components/emcn'
import type { useMentionData } from '../hooks/use-mention-data'
import type { useMentionMenu } from '../hooks/use-mention-menu'
import { formatTimestamp } from '../utils'
import type { useMentionData } from '../../hooks/use-mention-data'
import type { useMentionMenu } from '../../hooks/use-mention-menu'
import { formatTimestamp } from '../../utils'
/**
* Common text styling for loading and empty states
@@ -50,7 +50,7 @@ interface AggregatedItem {
icon?: React.ReactNode
}
interface MentionMenuPortalProps {
interface MentionMenuProps {
mentionMenu: ReturnType<typeof useMentionMenu>
mentionData: ReturnType<typeof useMentionData>
message: string
@@ -67,19 +67,19 @@ interface MentionMenuPortalProps {
}
/**
* Portal component for mention menu dropdown.
* MentionMenu component for mention menu dropdown.
* Handles rendering of mention options, submenus, and aggregated search results.
* Manages keyboard navigation and selection of mentions.
*
* @param props - Component props
* @returns Rendered mention menu portal
* @returns Rendered mention menu
*/
export function MentionMenuPortal({
export function MentionMenu({
mentionMenu,
mentionData,
message,
insertHandlers,
}: MentionMenuPortalProps) {
}: MentionMenuProps) {
const {
mentionMenuRef,
menuListRef,

View File

@@ -9,7 +9,6 @@ import {
PopoverContent,
PopoverItem,
PopoverScrollArea,
Tooltip,
} from '@/components/emcn'
import { cn } from '@/lib/utils'
@@ -114,44 +113,19 @@ export function ModeSelector({ mode, onModeChange, isNearTop, disabled }: ModeSe
side={isNearTop ? 'bottom' : 'top'}
align='start'
sideOffset={4}
className='w-[160px]'
style={{ width: '120px', minWidth: '120px' }}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<PopoverScrollArea className='space-y-[2px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<PopoverItem active={mode === 'ask'} onClick={() => handleSelect('ask')}>
<MessageSquare className='h-3.5 w-3.5' />
<span>Ask</span>
</PopoverItem>
</Tooltip.Trigger>
<Tooltip.Content
side='right'
sideOffset={6}
align='center'
className='max-w-[220px] border bg-popover p-2 text-[11px] text-popover-foreground leading-snug shadow-md'
>
Ask mode can help answer questions about your workflow, tell you about Sim, and guide
you in building/editing.
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<PopoverItem active={mode === 'build'} onClick={() => handleSelect('build')}>
<Package className='h-3.5 w-3.5' />
<span>Build</span>
</PopoverItem>
</Tooltip.Trigger>
<Tooltip.Content
side='right'
sideOffset={6}
align='center'
className='max-w-[220px] border bg-popover p-2 text-[11px] text-popover-foreground leading-snug shadow-md'
>
Build mode can build, edit, and interact with your workflows (Recommended)
</Tooltip.Content>
</Tooltip.Root>
<PopoverItem active={mode === 'ask'} onClick={() => handleSelect('ask')}>
<MessageSquare className='h-3.5 w-3.5' />
<span>Ask</span>
</PopoverItem>
<PopoverItem active={mode === 'build'} onClick={() => handleSelect('build')}>
<Package className='h-3.5 w-3.5' />
<span>Build</span>
</PopoverItem>
</PopoverScrollArea>
</PopoverContent>
</Popover>

View File

@@ -10,7 +10,7 @@ import {
PopoverScrollArea,
} from '@/components/emcn'
import { getProviderIcon } from '@/providers/utils'
import { MODEL_OPTIONS } from '../constants'
import { MODEL_OPTIONS } from '../../constants'
interface ModelSelectorProps {
/** Currently selected model */
@@ -120,7 +120,6 @@ export function ModelSelector({ selectedModel, isNearTop, onModelSelect }: Model
align='start'
sideOffset={4}
maxHeight={280}
className='w-[220px]'
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
>

View File

@@ -21,7 +21,7 @@ import type { ChatContext } from '@/stores/panel-new/copilot/types'
import {
AttachedFilesDisplay,
ContextPills,
MentionMenuPortal,
MentionMenu,
ModelSelector,
ModeSelector,
} from './components'
@@ -569,8 +569,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
ref={setInputContainerRef}
className={cn(
'relative rounded-[4px] border border-[#3D3D3D] bg-[#282828] px-[6px] py-[6px] transition-colors dark:bg-[#363636]',
fileAttachments.isDragging &&
'border-[var(--brand-primary-hover-hex)] bg-purple-50/50 dark:border-[var(--brand-primary-hover-hex)] dark:bg-purple-950/20'
fileAttachments.isDragging && 'ring-[#33B4FF] ring-[1.75px]'
)}
onDragEnter={fileAttachments.handleDragEnter}
onDragLeave={fileAttachments.handleDragLeave}
@@ -642,7 +641,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
{/* Mention Menu Portal */}
{mentionMenu.showMentionMenu &&
createPortal(
<MentionMenuPortal
<MentionMenu
mentionMenu={mentionMenu}
mentionData={mentionData}
message={message}

View File

@@ -21,14 +21,10 @@ import {
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components'
import type { MessageFileAttachment } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/hooks/use-file-attachments'
import type { UserInputRef } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/user-input'
import { useScrollManagement } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { useCopilotStore } from '@/stores/panel-new/copilot/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import {
useChatHistory,
useCopilotInitialization,
useScrollManagement,
useTodoManagement,
} from './hooks'
import { useChatHistory, useCopilotInitialization, useTodoManagement } from './hooks'
const logger = createLogger('Copilot')

View File

@@ -1,4 +1,3 @@
export { useChatHistory } from './use-chat-history'
export { useCopilotInitialization } from './use-copilot-initialization'
export { useScrollManagement } from './use-scroll-management'
export { useTodoManagement } from './use-todo-management'

View File

@@ -0,0 +1,106 @@
'use client'
import { useCallback, useState } from 'react'
import { Loader2 } from 'lucide-react'
import { Button, Rocket } from '@/components/emcn'
import { DeployModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components'
import type { WorkspaceUserPermissions } from '@/hooks/use-user-permissions'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useChangeDetection, useDeployedState, useDeployment } from './hooks'
interface DeployProps {
activeWorkflowId: string | null
userPermissions: WorkspaceUserPermissions
className?: string
}
/**
* Deploy component that handles workflow deployment
* Manages deployed state, change detection, and deployment operations
*/
export function Deploy({ activeWorkflowId, userPermissions, className }: DeployProps) {
const [isModalOpen, setIsModalOpen] = useState(false)
const { isLoading: isRegistryLoading } = useWorkflowRegistry()
// Get deployment status from registry
const deploymentStatus = useWorkflowRegistry((state) =>
state.getWorkflowDeploymentStatus(activeWorkflowId)
)
const isDeployed = deploymentStatus?.isDeployed || false
// Fetch and manage deployed state
const { deployedState, isLoadingDeployedState, refetchDeployedState } = useDeployedState({
workflowId: activeWorkflowId,
isDeployed,
isRegistryLoading,
})
// Detect changes between current and deployed state
const { changeDetected, setChangeDetected } = useChangeDetection({
workflowId: activeWorkflowId,
deployedState,
isLoadingDeployedState,
})
// Handle deployment operations
const { isDeploying, handleDeployClick } = useDeployment({
workflowId: activeWorkflowId,
isDeployed,
refetchDeployedState,
})
const canDeploy = userPermissions.canAdmin
const isDisabled = isDeploying || !canDeploy
const isPreviousVersionActive = isDeployed && changeDetected
/**
* Handle deploy button click
*/
const onDeployClick = useCallback(async () => {
if (!canDeploy || !activeWorkflowId) return
const result = await handleDeployClick()
if (result.shouldOpenModal) {
setIsModalOpen(true)
}
}, [canDeploy, activeWorkflowId, handleDeployClick])
const refetchWithErrorHandling = async () => {
if (!activeWorkflowId) return
try {
await refetchDeployedState()
} catch (error) {
// Error already logged in hook
}
}
return (
<>
<Button
className='h-[32px] gap-[8px] px-[10px]'
variant='active'
onClick={onDeployClick}
disabled={isDisabled}
>
{isDeploying ? (
<Loader2 className='h-[13px] w-[13px] animate-spin' />
) : (
<Rocket className='h-[13px] w-[13px]' />
)}
{changeDetected ? 'Update' : isDeployed ? 'Active' : 'Deploy'}
</Button>
<DeployModal
open={isModalOpen}
onOpenChange={setIsModalOpen}
workflowId={activeWorkflowId}
needsRedeployment={changeDetected}
setNeedsRedeployment={setChangeDetected}
deployedState={deployedState!}
isLoadingDeployedState={isLoadingDeployedState}
refetchDeployedState={refetchWithErrorHandling}
/>
</>
)
}

View File

@@ -0,0 +1,3 @@
export { useChangeDetection } from './use-change-detection'
export { useDeployedState } from './use-deployed-state'
export { useDeployment } from './use-deployment'

View File

@@ -0,0 +1,111 @@
import { useEffect, useMemo, useState } from 'react'
import { createLogger } from '@/lib/logs/console/logger'
import { useDebounce } from '@/hooks/use-debounce'
import { useOperationQueueStore } from '@/stores/operation-queue/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('useChangeDetection')
interface UseChangeDetectionProps {
workflowId: string | null
deployedState: WorkflowState | null
isLoadingDeployedState: boolean
}
/**
* Hook to detect changes between current workflow state and deployed state
* Uses API-based change detection for accuracy
*/
export function useChangeDetection({
workflowId,
deployedState,
isLoadingDeployedState,
}: UseChangeDetectionProps) {
const [changeDetected, setChangeDetected] = useState(false)
const [blockStructureVersion, setBlockStructureVersion] = useState(0)
const [edgeStructureVersion, setEdgeStructureVersion] = useState(0)
const [subBlockStructureVersion, setSubBlockStructureVersion] = useState(0)
// Get current store state for change detection
const currentBlocks = useWorkflowStore((state) => state.blocks)
const currentEdges = useWorkflowStore((state) => state.edges)
const lastSaved = useWorkflowStore((state) => state.lastSaved)
const subBlockValues = useSubBlockStore((state) =>
workflowId ? state.workflowValues[workflowId] : null
)
// Track structure changes
useEffect(() => {
setBlockStructureVersion((version) => version + 1)
}, [currentBlocks])
useEffect(() => {
setEdgeStructureVersion((version) => version + 1)
}, [currentEdges])
useEffect(() => {
setSubBlockStructureVersion((version) => version + 1)
}, [subBlockValues])
// Reset version counters when workflow changes
useEffect(() => {
setBlockStructureVersion(0)
setEdgeStructureVersion(0)
setSubBlockStructureVersion(0)
}, [workflowId])
// Create trigger for status check
const statusCheckTrigger = useMemo(() => {
return JSON.stringify({
lastSaved: lastSaved ?? 0,
blockVersion: blockStructureVersion,
edgeVersion: edgeStructureVersion,
subBlockVersion: subBlockStructureVersion,
})
}, [lastSaved, blockStructureVersion, edgeStructureVersion, subBlockStructureVersion])
const debouncedStatusCheckTrigger = useDebounce(statusCheckTrigger, 500)
useEffect(() => {
// Avoid off-by-one false positives: wait until operation queue is idle
const { operations, isProcessing } = useOperationQueueStore.getState()
const hasPendingOps =
isProcessing || operations.some((op) => op.status === 'pending' || op.status === 'processing')
if (!workflowId || !deployedState) {
setChangeDetected(false)
return
}
if (isLoadingDeployedState || hasPendingOps) {
return
}
// Use the workflow status API to get accurate change detection
// This uses the same logic as the deployment API (reading from normalized tables)
const checkForChanges = async () => {
try {
const response = await fetch(`/api/workflows/${workflowId}/status`)
if (response.ok) {
const data = await response.json()
setChangeDetected(data.needsRedeployment || false)
} else {
logger.error('Failed to fetch workflow status:', response.status, response.statusText)
setChangeDetected(false)
}
} catch (error) {
logger.error('Error fetching workflow status:', error)
setChangeDetected(false)
}
}
checkForChanges()
}, [workflowId, deployedState, debouncedStatusCheckTrigger, isLoadingDeployedState])
return {
changeDetected,
setChangeDetected,
}
}

View File

@@ -0,0 +1,112 @@
import { useEffect, useState } from 'react'
import { createLogger } from '@/lib/logs/console/logger'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('useDeployedState')
interface UseDeployedStateProps {
workflowId: string | null
isDeployed: boolean
isRegistryLoading: boolean
}
/**
* Hook to fetch and manage deployed workflow state
* Includes race condition protection for workflow changes
*/
export function useDeployedState({
workflowId,
isDeployed,
isRegistryLoading,
}: UseDeployedStateProps) {
const [deployedState, setDeployedState] = useState<WorkflowState | null>(null)
const [isLoadingDeployedState, setIsLoadingDeployedState] = useState<boolean>(false)
const setNeedsRedeploymentFlag = useWorkflowRegistry(
(state) => state.setWorkflowNeedsRedeployment
)
/**
* Fetches the deployed state of the workflow from the server
* This is the single source of truth for deployed workflow state
*/
const fetchDeployedState = async () => {
if (!workflowId || !isDeployed) {
setDeployedState(null)
return
}
// Store the workflow ID at the start of the request to prevent race conditions
const requestWorkflowId = workflowId
// Helper to get current active workflow ID for race condition checks
const getCurrentActiveWorkflowId = () => useWorkflowRegistry.getState().activeWorkflowId
try {
setIsLoadingDeployedState(true)
const response = await fetch(`/api/workflows/${requestWorkflowId}/deployed`)
// Check if the workflow ID changed during the request (user navigated away)
if (requestWorkflowId !== getCurrentActiveWorkflowId()) {
logger.debug('Workflow changed during deployed state fetch, ignoring response')
return
}
if (!response.ok) {
if (response.status === 404) {
setDeployedState(null)
return
}
throw new Error(`Failed to fetch deployed state: ${response.statusText}`)
}
const data = await response.json()
if (requestWorkflowId === getCurrentActiveWorkflowId()) {
setDeployedState(data.deployedState || null)
} else {
logger.debug('Workflow changed after deployed state response, ignoring result')
}
} catch (error) {
logger.error('Error fetching deployed state:', { error })
if (requestWorkflowId === getCurrentActiveWorkflowId()) {
setDeployedState(null)
}
} finally {
if (requestWorkflowId === getCurrentActiveWorkflowId()) {
setIsLoadingDeployedState(false)
}
}
}
useEffect(() => {
if (!workflowId) {
setDeployedState(null)
setIsLoadingDeployedState(false)
return
}
if (isRegistryLoading) {
setDeployedState(null)
setIsLoadingDeployedState(false)
return
}
if (isDeployed) {
setNeedsRedeploymentFlag(workflowId, false)
fetchDeployedState()
} else {
setDeployedState(null)
setIsLoadingDeployedState(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workflowId, isDeployed, isRegistryLoading, setNeedsRedeploymentFlag])
return {
deployedState,
isLoadingDeployedState,
refetchDeployedState: fetchDeployedState,
}
}

View File

@@ -0,0 +1,76 @@
import { useCallback, useState } from 'react'
import { createLogger } from '@/lib/logs/console/logger'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('useDeployment')
interface UseDeploymentProps {
workflowId: string | null
isDeployed: boolean
refetchDeployedState: () => Promise<void>
}
/**
* Hook to manage deployment operations (deploy, undeploy, redeploy)
*/
export function useDeployment({
workflowId,
isDeployed,
refetchDeployedState,
}: UseDeploymentProps) {
const [isDeploying, setIsDeploying] = useState(false)
const setDeploymentStatus = useWorkflowRegistry((state) => state.setDeploymentStatus)
/**
* Handle initial deployment and open modal
*/
const handleDeployClick = useCallback(async () => {
if (!workflowId) return { success: false, shouldOpenModal: false }
// If undeployed, deploy first then open modal
if (!isDeployed) {
setIsDeploying(true)
try {
const response = await fetch(`/api/workflows/${workflowId}/deploy`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
deployChatEnabled: false,
}),
})
if (response.ok) {
const responseData = await response.json()
const isDeployedStatus = responseData.isDeployed ?? false
const deployedAtTime = responseData.deployedAt
? new Date(responseData.deployedAt)
: undefined
setDeploymentStatus(
workflowId,
isDeployedStatus,
deployedAtTime,
responseData.apiKey || ''
)
await refetchDeployedState()
return { success: true, shouldOpenModal: true }
}
return { success: false, shouldOpenModal: true }
} catch (error) {
logger.error('Error deploying workflow:', error)
return { success: false, shouldOpenModal: true }
} finally {
setIsDeploying(false)
}
}
// If already deployed, just signal to open modal
return { success: true, shouldOpenModal: true }
}, [workflowId, isDeployed, refetchDeployedState, setDeploymentStatus])
return {
isDeploying,
handleDeployClick,
}
}

View File

@@ -0,0 +1 @@
export { Deploy } from './deploy'

View File

@@ -1,7 +1,7 @@
'use client'
import { Check } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/emcn/components/button/button'
import {
Dialog,
DialogContent,
@@ -29,13 +29,13 @@ export interface OAuthRequiredModalProps {
toolName: string
requiredScopes?: string[]
serviceId?: string
newScopes?: string[]
}
const SCOPE_DESCRIPTIONS: Record<string, string> = {
'https://www.googleapis.com/auth/gmail.send': 'Send emails on your behalf',
'https://www.googleapis.com/auth/gmail.labels': 'View and manage your email labels',
'https://www.googleapis.com/auth/gmail.modify': 'View and manage your email messages',
'https://www.googleapis.com/auth/gmail.readonly': 'View and read your email messages',
'https://www.googleapis.com/auth/drive.readonly': 'View and read your Google Drive files',
'https://www.googleapis.com/auth/drive.file': 'View and manage your Google Drive files',
'https://www.googleapis.com/auth/calendar': 'View and manage your calendar',
@@ -202,6 +202,7 @@ export function OAuthRequiredModal({
toolName,
requiredScopes = [],
serviceId,
newScopes = [],
}: OAuthRequiredModalProps) {
const effectiveServiceId = serviceId || getServiceIdFromScopes(provider, requiredScopes)
const { baseProvider } = parseProvider(provider)
@@ -223,6 +224,11 @@ export function OAuthRequiredModal({
const displayScopes = requiredScopes.filter(
(scope) => !scope.includes('userinfo.email') && !scope.includes('userinfo.profile')
)
const newScopesSet = new Set(
(newScopes || []).filter(
(scope) => !scope.includes('userinfo.email') && !scope.includes('userinfo.profile')
)
)
const handleConnectDirectly = async () => {
try {
@@ -278,7 +284,14 @@ export function OAuthRequiredModal({
<div className='mt-1 rounded-full bg-muted p-0.5'>
<Check className='h-3 w-3' />
</div>
<span className='text-muted-foreground'>{getScopeDescription(scope)}</span>
<div className='text-muted-foreground'>
<span>{getScopeDescription(scope)}</span>
{newScopesSet.has(scope) && (
<span className='ml-2 rounded-[4px] border border-amber-500/30 bg-amber-500/10 px-1.5 py-0.5 text-[10px] text-amber-300'>
New
</span>
)}
</div>
</li>
))}
</ul>
@@ -289,7 +302,12 @@ export function OAuthRequiredModal({
<Button variant='outline' onClick={onClose} className='sm:order-1'>
Cancel
</Button>
<Button type='button' onClick={handleConnectDirectly} className='sm:order-3'>
<Button
variant='primary'
type='button'
onClick={handleConnectDirectly}
className='sm:order-3'
>
Connect Now
</Button>
</DialogFooter>

View File

@@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Check, ChevronDown, ExternalLink, RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/emcn/components/button/button'
import {
Command,
CommandEmpty,
@@ -15,6 +15,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { createLogger } from '@/lib/logs/console/logger'
import {
type Credential,
getCanonicalScopesForProvider,
getProviderIdFromServiceId,
getServiceIdFromScopes,
OAUTH_PROVIDERS,
@@ -25,6 +26,7 @@ import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('CredentialSelector')
@@ -210,6 +212,14 @@ export function CredentialSelector({
? 'Saved by collaborator'
: undefined
// Determine if additional permissions are required for the selected credential
const hasSelection = !!selectedCredential
const missingRequiredScopes = hasSelection
? getMissingRequiredScopes(selectedCredential, requiredScopes || [])
: []
const needsUpdate =
hasSelection && missingRequiredScopes.length > 0 && !disabled && !isPreview && !isLoading
// Handle selection
const handleSelect = (credentialId: string) => {
const previousId = selectedId || (effectiveValue as string) || ''
@@ -331,13 +341,21 @@ export function CredentialSelector({
</PopoverContent>
</Popover>
{needsUpdate && (
<div className='mt-2 flex items-center justify-between rounded-[6px] border border-amber-300/40 bg-amber-50/60 px-2 py-1 font-medium text-[12px] transition-colors dark:bg-amber-950/10'>
<span>Additional permissions required</span>
{!isForeign && <Button onClick={() => setShowOAuthModal(true)}>Update access</Button>}
</div>
)}
{showOAuthModal && (
<OAuthRequiredModal
isOpen={showOAuthModal}
onClose={() => setShowOAuthModal(false)}
provider={provider}
toolName={getProviderName(provider)}
requiredScopes={requiredScopes}
requiredScopes={getCanonicalScopesForProvider(effectiveProviderId)}
newScopes={missingRequiredScopes}
serviceId={effectiveServiceId}
/>
)}

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { AlertCircle, Check, Save, Trash2 } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Tooltip } from '@/components/emcn/components/tooltip/tooltip'
import { Button } from '@/components/emcn/components'
import { Trash } from '@/components/emcn/icons/trash'
import { Alert, AlertDescription } from '@/components/ui/alert'
import {
AlertDialog,
@@ -13,8 +13,6 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { createLogger } from '@/lib/logs/console/logger'
import { parseCronToHumanReadable } from '@/lib/schedules/utils'
import { cn } from '@/lib/utils'
@@ -377,6 +375,7 @@ export function ScheduleSave({ blockId, isPreview = false, disabled = false }: S
<div className='mt-2'>
<div className='flex gap-2'>
<Button
variant='default'
onClick={handleSave}
disabled={disabled || isPreview || isSaving || saveStatus === 'saving' || isLoadingStatus}
className={cn(
@@ -391,37 +390,22 @@ export function ScheduleSave({ blockId, isPreview = false, disabled = false }: S
Saving...
</>
)}
{saveStatus === 'saved' && (
<>
<Check className='mr-2 h-4 w-4' />
Saved
</>
)}
{saveStatus === 'idle' && (
<>
<Save className='mr-2 h-4 w-4' />
{scheduleId ? 'Update Schedule' : 'Save Schedule'}
</>
)}
{saveStatus === 'error' && (
<>
<AlertCircle className='mr-2 h-4 w-4' />
Error
</>
)}
{saveStatus === 'saved' && 'Saved'}
{saveStatus === 'idle' && (scheduleId ? 'Update Schedule' : 'Save Schedule')}
{saveStatus === 'error' && 'Error'}
</Button>
{scheduleId && (
<Button
variant='default'
onClick={() => setShowDeleteDialog(true)}
disabled={disabled || isPreview || deleteStatus === 'deleting' || isSaving}
variant='outline'
className='h-9 rounded-[8px] px-3 text-destructive hover:bg-destructive/10'
className='h-9 rounded-[8px] px-3'
>
{deleteStatus === 'deleting' ? (
<div className='h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
) : (
<Trash2 className='h-4 w-4' />
<Trash className='h-[14px] w-[14px]' />
)}
</Button>
)}
@@ -442,54 +426,21 @@ export function ScheduleSave({ blockId, isPreview = false, disabled = false }: S
</div>
) : (
<>
<div className='flex items-center gap-2'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Badge
variant='outline'
className={cn(
'flex cursor-pointer items-center gap-1 font-normal text-xs',
scheduleStatus === 'disabled'
? 'border-amber-200 bg-amber-50 text-amber-600 hover:bg-amber-100 dark:bg-amber-900/20 dark:text-amber-400'
: 'border-green-200 bg-green-50 text-green-600 hover:bg-green-100 dark:bg-green-900/20 dark:text-green-400'
)}
onClick={handleToggleStatus}
>
<div className='relative mr-0.5 flex items-center justify-center'>
<div
className={cn(
'absolute h-3 w-3 rounded-full',
scheduleStatus === 'disabled' ? 'bg-amber-500/20' : 'bg-green-500/20'
)}
/>
<div
className={cn(
'relative h-2 w-2 rounded-full',
scheduleStatus === 'disabled' ? 'bg-amber-500' : 'bg-green-500'
)}
/>
</div>
{scheduleStatus === 'active' ? 'Active' : 'Disabled'}
</Badge>
</Tooltip.Trigger>
<Tooltip.Content side='top' className='max-w-[300px]'>
{scheduleStatus === 'disabled' ? (
<p className='text-sm'>Click to reactivate this schedule</p>
) : (
<p className='text-sm'>Click to disable this schedule</p>
)}
</Tooltip.Content>
</Tooltip.Root>
{failedCount > 0 && (
{failedCount > 0 && (
<div className='flex items-center gap-2'>
<span className='text-destructive text-sm'>
{failedCount} failed run{failedCount !== 1 ? 's' : ''}
</span>
)}
</div>
</div>
)}
{savedCronExpression && (
<p className='text-muted-foreground text-sm'>
{parseCronToHumanReadable(savedCronExpression, scheduleTimezone || 'UTC')}
Runs{' '}
{parseCronToHumanReadable(
savedCronExpression,
scheduleTimezone || 'UTC'
).toLowerCase()}
</p>
)}

View File

@@ -435,7 +435,7 @@ export function ShortInput({
<div
ref={overlayRef}
className={cn(
'pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[7px] font-medium font-sans text-foreground text-sm [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
'pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-foreground text-sm [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
showCopyButton ? 'pr-14' : 'pr-3'
)}
>

View File

@@ -337,7 +337,7 @@ export function FieldFormat({
ref={(el) => {
if (el) overlayRefs.current[field.id] = el
}}
className='pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[7px] font-medium font-sans text-sm'
className='pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm'
style={{ overflowX: 'auto' }}
>
<div

View File

@@ -27,10 +27,10 @@ export function Text({ blockId, subBlockId, content, className }: TextProps) {
return (
<div
id={`${blockId}-${subBlockId}`}
className={`rounded-md border bg-card p-4 shadow-sm ${className || ''}`}
className={`rounded-md border bg-[#232323] p-4 shadow-sm ${className || ''}`}
>
<div
className='prose prose-sm dark:prose-invert max-w-none text-sm [&_a]:text-blue-600 [&_a]:underline [&_a]:hover:text-blue-700 [&_a]:dark:text-blue-400 [&_a]:dark:hover:text-blue-300 [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:text-xs [&_strong]:font-semibold [&_ul]:ml-5 [&_ul]:list-disc'
className='prose prose-sm dark:prose-invert max-w-none break-words text-sm [&_a]:text-blue-600 [&_a]:underline [&_a]:hover:text-blue-700 [&_a]:dark:text-blue-400 [&_a]:dark:hover:text-blue-300 [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:text-xs [&_strong]:font-semibold [&_ul]:ml-5 [&_ul]:list-disc'
dangerouslySetInnerHTML={{ __html: content }}
/>
</div>
@@ -40,7 +40,7 @@ export function Text({ blockId, subBlockId, content, className }: TextProps) {
return (
<div
id={`${blockId}-${subBlockId}`}
className={`whitespace-pre-wrap rounded-md border bg-card p-4 text-muted-foreground text-sm shadow-sm ${className || ''}`}
className={`whitespace-pre-wrap break-words rounded-md border bg-[#232323] p-4 text-muted-foreground text-sm shadow-sm ${className || ''}`}
>
{content}
</div>

View File

@@ -1,10 +1,7 @@
'use client'
import * as React from 'react'
import { Clock } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Button, Input, Popover, PopoverContent, PopoverTrigger } from '@/components/emcn'
import { cn } from '@/lib/utils'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
@@ -91,18 +88,15 @@ export function TimeInput({
}}
>
<PopoverTrigger asChild>
<Button
variant='outline'
disabled={isPreview || disabled}
className={cn(
'w-full justify-start text-left font-normal',
!value && 'text-muted-foreground',
className
)}
>
<Clock className='mr-1 h-4 w-4' />
{value ? formatDisplayTime(value) : <span>{placeholder || 'Select time'}</span>}
</Button>
<div className='relative w-full cursor-pointer'>
<Input
readOnly
disabled={isPreview || disabled}
value={value ? formatDisplayTime(value) : ''}
placeholder={placeholder || 'Select time'}
className={cn('cursor-pointer', !value && 'text-muted-foreground', className)}
/>
</div>
</PopoverTrigger>
<PopoverContent className='w-auto p-4'>
<div className='flex items-center space-x-2'>
@@ -129,7 +123,7 @@ export function TimeInput({
}}
type='text'
/>
<span>:</span>
<span className='text-[#E6E6E6]'>:</span>
<Input
className='w-[4rem]'
value={minute}

View File

@@ -1,4 +1,5 @@
import type React from 'react'
import { PopoverSection } from '@/components/emcn'
import { ToolCommand } from './tool-command/tool-command'
const IconComponent = ({ icon: Icon, className }: { icon: any; className?: string }) => {
@@ -38,6 +39,9 @@ interface McpToolsListProps {
disabled?: boolean
}
/**
* Displays a filtered list of MCP tools with proper section header and separator
*/
export function McpToolsList({
mcpTools,
searchQuery,
@@ -47,55 +51,48 @@ export function McpToolsList({
}: McpToolsListProps) {
const filteredTools = mcpTools.filter((tool) => customFilter(tool.name, searchQuery || '') > 0)
if (mcpTools.length === 0 || filteredTools.length === 0) {
if (filteredTools.length === 0) {
return null
}
return (
<>
<div className='px-2 pt-2.5 pb-0.5 font-medium text-muted-foreground text-xs'>MCP Tools</div>
<ToolCommand.Group className='-mx-1 -px-1'>
{filteredTools.map((mcpTool) => (
<ToolCommand.Item
key={mcpTool.id}
value={mcpTool.name}
onSelect={() => {
if (disabled) return
<PopoverSection>MCP Tools</PopoverSection>
{filteredTools.map((mcpTool) => (
<ToolCommand.Item
key={mcpTool.id}
value={mcpTool.name}
onSelect={() => {
if (disabled) return
const newTool: StoredTool = {
type: 'mcp',
title: mcpTool.name,
toolId: mcpTool.id,
params: {
serverId: mcpTool.serverId,
toolName: mcpTool.name,
serverName: mcpTool.serverName,
},
isExpanded: true,
usageControl: 'auto',
schema: mcpTool.inputSchema,
}
const newTool: StoredTool = {
type: 'mcp',
title: mcpTool.name,
toolId: mcpTool.id,
params: {
serverId: mcpTool.serverId,
toolName: mcpTool.name,
serverName: mcpTool.serverName,
},
isExpanded: true,
usageControl: 'auto',
schema: mcpTool.inputSchema,
}
onToolSelect(newTool)
}}
className='flex cursor-pointer items-center gap-2'
onToolSelect(newTool)
}}
>
<div
className='flex h-[15px] w-[15px] flex-shrink-0 items-center justify-center rounded'
style={{ backgroundColor: mcpTool.bgColor }}
>
<div
className='flex h-6 w-6 items-center justify-center rounded'
style={{ backgroundColor: mcpTool.bgColor }}
>
<IconComponent icon={mcpTool.icon} className='h-4 w-4 text-white' />
</div>
<span
className='max-w-[140px] truncate'
title={`${mcpTool.name} (${mcpTool.serverName})`}
>
{mcpTool.name}
</span>
</ToolCommand.Item>
))}
</ToolCommand.Group>
<ToolCommand.Separator />
<IconComponent icon={mcpTool.icon} className='h-[11px] w-[11px] text-white' />
</div>
<span className='truncate' title={`${mcpTool.name} (${mcpTool.serverName})`}>
{mcpTool.name}
</span>
</ToolCommand.Item>
))}
</>
)
}

View File

@@ -6,10 +6,8 @@ import {
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { Search } from 'lucide-react'
import { cn } from '@/lib/utils'
type CommandContextType = {
@@ -37,12 +35,7 @@ interface CommandProps {
children: ReactNode
className?: string
filter?: (value: string, search: string) => number
}
interface CommandInputProps {
placeholder?: string
className?: string
onValueChange?: (value: string) => void
searchQuery?: string
}
interface CommandListProps {
@@ -55,12 +48,6 @@ interface CommandEmptyProps {
className?: string
}
interface CommandGroupProps {
children: ReactNode
className?: string
heading?: string
}
interface CommandItemProps {
children: ReactNode
className?: string
@@ -73,12 +60,20 @@ interface CommandSeparatorProps {
className?: string
}
export function Command({ children, className, filter }: CommandProps) {
const [searchQuery, setSearchQuery] = useState('')
export function Command({
children,
className,
filter,
searchQuery: externalSearchQuery,
}: CommandProps) {
const [internalSearchQuery, setInternalSearchQuery] = useState('')
const [activeIndex, setActiveIndex] = useState(-1)
const [items, setItems] = useState<string[]>([])
const [filteredItems, setFilteredItems] = useState<string[]>([])
// Use external searchQuery if provided, otherwise use internal state
const searchQuery = externalSearchQuery ?? internalSearchQuery
const registerItem = useCallback((id: string) => {
setItems((prev) => {
if (prev.includes(id)) return prev
@@ -156,7 +151,7 @@ export function Command({ children, className, filter }: CommandProps) {
const contextValue = useMemo(
() => ({
searchQuery,
setSearchQuery,
setSearchQuery: setInternalSearchQuery,
activeIndex,
setActiveIndex,
filteredItems,
@@ -169,60 +164,15 @@ export function Command({ children, className, filter }: CommandProps) {
return (
<CommandContext.Provider value={contextValue}>
<div
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5',
className
)}
onKeyDown={handleKeyDown}
>
<div className={cn('flex w-full flex-col', className)} onKeyDown={handleKeyDown}>
{children}
</div>
</CommandContext.Provider>
)
}
export function CommandInput({
placeholder = 'Search...',
className,
onValueChange,
}: CommandInputProps) {
const { searchQuery, setSearchQuery } = useCommandContext()
const inputRef = useRef<HTMLInputElement>(null)
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
setSearchQuery(value)
onValueChange?.(value)
}
useEffect(() => {
inputRef.current?.focus()
}, [])
return (
<div className='flex items-center border-b px-3'>
<Search className='mr-2 h-4 w-4 shrink-0 opacity-50' />
<input
ref={inputRef}
className={cn(
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
className
)}
placeholder={placeholder}
value={searchQuery}
onChange={handleChange}
/>
</div>
)
}
export function CommandList({ children, className }: CommandListProps) {
return (
<div className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}>
{children}
</div>
)
return <div className={cn(className)}>{children}</div>
}
export function CommandEmpty({ children, className }: CommandEmptyProps) {
@@ -231,23 +181,7 @@ export function CommandEmpty({ children, className }: CommandEmptyProps) {
if (filteredItems.length > 0) return null
return (
<div className={cn('pt-3.5 pb-2 text-center text-muted-foreground text-sm', className)}>
{children}
</div>
)
}
export function CommandGroup({ children, className, heading }: CommandGroupProps) {
return (
<div
className={cn(
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:text-xs',
className
)}
>
{heading && (
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>{heading}</div>
)}
<div className={cn('px-[6px] py-[8px] text-[#FFFFFF]/60 text-[12px]', className)}>
{children}
</div>
)
@@ -279,8 +213,8 @@ export function CommandItem({
<button
id={value}
className={cn(
'relative flex w-full cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50',
isActive && 'bg-accent text-accent-foreground',
'flex h-[25px] w-full cursor-pointer select-none items-center gap-[8px] rounded-[6px] px-[6px] font-base text-[#E6E6E6] text-[12px] outline-none transition-colors hover:bg-[#363636] hover:text-[#E6E6E6] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 data-[disabled=true]:pointer-events-none data-[selected=true]:bg-[#363636] data-[selected=true]:text-[#E6E6E6] data-[disabled=true]:opacity-50',
(isActive || isHovered) && 'bg-[#363636] text-[#E6E6E6]',
className
)}
onClick={() => !disabled && onSelect?.()}
@@ -301,10 +235,8 @@ export function CommandSeparator({ className }: CommandSeparatorProps) {
export const ToolCommand = {
Root: Command,
Input: CommandInput,
List: CommandList,
Empty: CommandEmpty,
Group: CommandGroup,
Item: CommandItem,
Separator: CommandSeparator,
}

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react'
import { Check, ChevronDown, ExternalLink, Plus, RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/emcn/components/button/button'
import {
Command,
CommandEmpty,
@@ -12,17 +12,19 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { createLogger } from '@/lib/logs/console/logger'
import {
type Credential,
getCanonicalScopesForProvider,
getProviderIdFromServiceId,
OAUTH_PROVIDERS,
type OAuthProvider,
type OAuthService,
parseProvider,
} from '@/lib/oauth'
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('ToolCredentialSelector')
// Helper functions for provider icons and names
const getProviderIcon = (providerName: OAuthProvider) => {
const { baseProvider } = parseProvider(providerName)
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
@@ -158,6 +160,13 @@ export function ToolCredentialSelector({
const selectedCredential = credentials.find((cred) => cred.id === selectedId)
const isForeign = !!(selectedId && !selectedCredential)
// Determine if additional permissions are required for the selected credential
const hasSelection = !!selectedCredential
const missingRequiredScopes = hasSelection
? getMissingRequiredScopes(selectedCredential, requiredScopes || [])
: []
const needsUpdate = hasSelection && missingRequiredScopes.length > 0 && !disabled && !isLoading
return (
<>
<Popover open={open} onOpenChange={handleOpenChange}>
@@ -240,12 +249,23 @@ export function ToolCredentialSelector({
</PopoverContent>
</Popover>
{needsUpdate && (
<div className='mt-2 flex items-center justify-between rounded-[6px] border border-amber-300/40 bg-amber-50/60 px-2 py-1 font-medium text-[12px] transition-colors dark:bg-amber-950/10'>
<span>Additional permissions required</span>
{/* We don't have reliable foreign detection context here; always show CTA */}
<Button onClick={() => setShowOAuthModal(true)}>Update access</Button>
</div>
)}
<OAuthRequiredModal
isOpen={showOAuthModal}
onClose={handleOAuthClose}
provider={provider}
toolName={label}
requiredScopes={requiredScopes}
requiredScopes={getCanonicalScopesForProvider(
serviceId ? getProviderIdFromServiceId(serviceId) : (provider as string)
)}
newScopes={missingRequiredScopes}
serviceId={serviceId}
/>
</>

View File

@@ -2,9 +2,15 @@ import type React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { PlusIcon, Server, WrenchIcon, XIcon } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Tooltip } from '@/components/emcn'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import {
Popover,
PopoverContent,
PopoverScrollArea,
PopoverSearch,
PopoverSection,
PopoverTrigger,
Tooltip,
} from '@/components/emcn'
import {
Select,
SelectContent,
@@ -1083,13 +1089,13 @@ export function ToolInput({
case 'dropdown':
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger className='w-full rounded-[4px] border border-[#3D3D3D] bg-[#282828] px-[8px] py-[7px] text-left font-medium font-sans text-sm dark:bg-[#363636]'>
<SelectTrigger className='w-full rounded-[4px] border border-[#303030] bg-[#1F1F1F] px-[10px] py-[8px] text-left font-medium text-sm'>
<SelectValue
placeholder={uiComponent.placeholder || 'Select option'}
className='truncate'
/>
</SelectTrigger>
<SelectContent className='border-[#3D3D3D] bg-[#282828] dark:bg-[#353535]'>
<SelectContent className='border-[#303030] bg-[#1F1F1F]'>
{uiComponent.options
?.filter((option: any) => option.id !== '')
.map((option: any) => (
@@ -1303,28 +1309,29 @@ export function ToolInput({
}
return (
<div className='w-full'>
<div className='w-full space-y-[8px]'>
{selectedTools.length === 0 ? (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<div className='flex w-full cursor-pointer items-center justify-center rounded-[4px] border border-[#3D3D3D] bg-[#282828] px-[8px] py-[7px] font-medium font-sans text-sm transition-colors hover:bg-accent hover:text-accent-foreground dark:bg-[#363636]'>
<div className='flex items-center text-muted-foreground/50 text-sm'>
<div className='flex w-full cursor-pointer items-center justify-center rounded-[4px] border border-[#303030] bg-[#1F1F1F] px-[10px] py-[6px] font-medium text-sm transition-colors hover:bg-[#252525]'>
<div className='flex items-center text-[#787878] text-[13px]'>
<PlusIcon className='mr-2 h-4 w-4' />
Add Tool
</div>
</div>
</PopoverTrigger>
<PopoverContent
className='h-[300px] w-full border-[#3D3D3D] bg-[#282828] p-0 dark:bg-[#363636]'
maxHeight={240}
className='w-[var(--radix-popover-trigger-width)]'
align='start'
sideOffset={6}
avoidCollisions={false}
>
<ToolCommand.Root filter={customFilter} className='bg-[#282828] dark:bg-[#363636]'>
<ToolCommand.Input placeholder='Search tools...' onValueChange={setSearchQuery} />
<ToolCommand.List>
<ToolCommand.Empty>No tools found</ToolCommand.Empty>
<ToolCommand.Group>
<PopoverSearch placeholder='Search tools...' onValueChange={setSearchQuery} />
<PopoverScrollArea>
<ToolCommand.Root filter={customFilter} searchQuery={searchQuery}>
<ToolCommand.List>
<ToolCommand.Empty>No tools found</ToolCommand.Empty>
<ToolCommand.Item
value='Create Tool'
onSelect={() => {
@@ -1333,13 +1340,12 @@ export function ToolInput({
setOpen(false)
}
}}
className='mb-1 flex cursor-pointer items-center gap-2'
disabled={isPreview}
>
<div className='flex h-6 w-6 items-center justify-center rounded border border-muted-foreground/50 border-dashed bg-transparent'>
<WrenchIcon className='h-4 w-4 text-muted-foreground' />
<div className='flex h-[15px] w-[15px] flex-shrink-0 items-center justify-center rounded border border-muted-foreground/50 border-dashed bg-transparent'>
<WrenchIcon className='h-[11px] w-[11px] text-muted-foreground' />
</div>
<span>Create Tool</span>
<span className='truncate'>Create Tool</span>
</ToolCommand.Item>
<ToolCommand.Item
@@ -1352,24 +1358,25 @@ export function ToolInput({
)
}
}}
className='mb-1 flex cursor-pointer items-center gap-2'
disabled={isPreview}
>
<div className='flex h-6 w-6 items-center justify-center rounded border border-muted-foreground/50 border-dashed bg-transparent'>
<Server className='h-4 w-4 text-muted-foreground' />
<div className='flex h-[15px] w-[15px] flex-shrink-0 items-center justify-center rounded border border-muted-foreground/50 border-dashed bg-transparent'>
<Server className='h-[11px] w-[11px] text-muted-foreground' />
</div>
<span>Add MCP Server</span>
<span className='truncate'>Add MCP Server</span>
</ToolCommand.Item>
{/* Display saved custom tools at the top */}
{customTools.length > 0 && (
<>
<ToolCommand.Separator />
<div className='px-2 pt-2.5 pb-0.5 font-medium text-muted-foreground text-xs'>
Custom Tools
</div>
<ToolCommand.Group className='-mx-1 -px-1'>
{customTools.map((customTool) => (
{(() => {
const matchingCustomTools = customTools.filter(
(tool) => customFilter(tool.title, searchQuery || '') > 0
)
if (matchingCustomTools.length === 0) return null
return (
<>
<PopoverSection>Custom Tools</PopoverSection>
{matchingCustomTools.map((customTool) => (
<ToolCommand.Item
key={customTool.id}
value={customTool.title}
@@ -1394,18 +1401,16 @@ export function ToolInput({
])
setOpen(false)
}}
className='flex cursor-pointer items-center gap-2'
>
<div className='flex h-6 w-6 items-center justify-center rounded bg-blue-500'>
<WrenchIcon className='h-4 w-4 text-white' />
<div className='flex h-[15px] w-[15px] flex-shrink-0 items-center justify-center rounded bg-blue-500'>
<WrenchIcon className='h-[11px] w-[11px] text-white' />
</div>
<span className='max-w-[140px] truncate'>{customTool.title}</span>
<span className='truncate'>{customTool.title}</span>
</ToolCommand.Item>
))}
</ToolCommand.Group>
<ToolCommand.Separator />
</>
)}
</>
)
})()}
{/* Display MCP tools */}
<McpToolsList
@@ -1417,38 +1422,43 @@ export function ToolInput({
/>
{/* Display built-in tools */}
{toolBlocks.some((block) => customFilter(block.name, searchQuery || '') > 0) && (
<>
<div className='px-2 pt-2.5 pb-0.5 font-medium text-muted-foreground text-xs'>
Built-in Tools
</div>
<ToolCommand.Group className='-mx-1 -px-1'>
{toolBlocks.map((block) => (
{(() => {
const matchingBlocks = toolBlocks.filter(
(block) => customFilter(block.name, searchQuery || '') > 0
)
if (matchingBlocks.length === 0) return null
return (
<>
<PopoverSection>Built-in Tools</PopoverSection>
{matchingBlocks.map((block) => (
<ToolCommand.Item
key={block.type}
value={block.name}
onSelect={() => handleSelectTool(block)}
className='flex cursor-pointer items-center gap-2'
>
<div
className='flex h-6 w-6 items-center justify-center rounded'
className='flex h-[15px] w-[15px] flex-shrink-0 items-center justify-center rounded'
style={{ backgroundColor: block.bgColor }}
>
<IconComponent icon={block.icon} className='h-4 w-4 text-white' />
<IconComponent
icon={block.icon}
className='h-[11px] w-[11px] text-white'
/>
</div>
<span className='max-w-[140px] truncate'>{block.name}</span>
<span className='truncate'>{block.name}</span>
</ToolCommand.Item>
))}
</ToolCommand.Group>
</>
)}
</ToolCommand.Group>
</ToolCommand.List>
</ToolCommand.Root>
</>
)
})()}
</ToolCommand.List>
</ToolCommand.Root>
</PopoverScrollArea>
</PopoverContent>
</Popover>
) : (
<div className='flex min-h-[2.5rem] w-full flex-wrap gap-2 rounded-[4px] border border-[#3D3D3D] bg-[#282828] px-[8px] py-[7px] font-medium font-sans text-sm dark:bg-[#363636]'>
<>
{selectedTools.map((tool, toolIndex) => {
// Handle custom tools and MCP tools differently
const isCustomTool = tool.type === 'custom-tool'
@@ -1522,11 +1532,10 @@ export function ToolInput({
<div
key={`${tool.toolId}-${toolIndex}`}
className={cn(
'group relative flex flex-col transition-all duration-200 ease-in-out',
'w-full',
'group relative flex flex-col overflow-visible rounded-[4px] border border-[#303030] bg-[#1F1F1F] transition-all duration-200 ease-in-out',
draggedIndex === toolIndex ? 'scale-95 opacity-40' : '',
dragOverIndex === toolIndex && draggedIndex !== toolIndex && draggedIndex !== null
? 'translate-y-1 transform'
? 'translate-y-1 transform border-t-2 border-t-muted-foreground/40'
: '',
selectedTools.length > 1 && !isPreview && !disabled
? 'cursor-grab active:cursor-grabbing'
@@ -1540,361 +1549,332 @@ export function ToolInput({
>
<div
className={cn(
'flex flex-col overflow-visible rounded-md border bg-card',
dragOverIndex === toolIndex &&
draggedIndex !== toolIndex &&
draggedIndex !== null
? 'border-t-2 border-t-muted-foreground/40'
: ''
'flex items-center justify-between px-[10px] py-[8px]',
isExpandedForDisplay && !isCustomTool && 'border-[#303030] border-b',
'cursor-pointer'
)}
onClick={() => {
if (isCustomTool) {
handleEditCustomTool(toolIndex)
} else {
toggleToolExpansion(toolIndex)
}
}}
>
<div
className={cn(
'flex items-center justify-between bg-accent/50 p-2',
'cursor-pointer'
)}
onClick={() => {
if (isCustomTool) {
handleEditCustomTool(toolIndex)
} else {
toggleToolExpansion(toolIndex)
}
}}
>
<div className='flex min-w-0 flex-shrink-1 items-center gap-2 overflow-hidden'>
<div
className='flex h-5 w-5 flex-shrink-0 items-center justify-center rounded'
style={{
backgroundColor: isCustomTool
? '#3B82F6' // blue-500 for custom tools
: isMcpTool
? mcpTool?.bgColor || '#6366F1' // Indigo for MCP tools
: toolBlock?.bgColor,
}}
>
{isCustomTool ? (
<WrenchIcon className='h-3 w-3 text-white' />
) : isMcpTool ? (
<IconComponent icon={Server} className='h-3 w-3 text-white' />
) : (
<IconComponent icon={toolBlock?.icon} className='h-3 w-3 text-white' />
)}
</div>
<span className='truncate font-medium text-sm'>{tool.title}</span>
</div>
<div className='ml-2 flex flex-shrink-0 items-center gap-1'>
{/* Only render the tool usage control if the provider supports it */}
{supportsToolControl && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Toggle
className='group flex h-6 items-center justify-center rounded-sm px-2 py-0 hover:bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=on]:bg-transparent'
pressed={true}
onPressedChange={() => {}}
onClick={(e: React.MouseEvent) => {
e.stopPropagation()
// Cycle through the states: auto -> force -> none -> auto
const currentState = tool.usageControl || 'auto'
const nextState =
currentState === 'auto'
? 'force'
: currentState === 'force'
? 'none'
: 'auto'
handleUsageControlChange(toolIndex, nextState)
}}
aria-label='Toggle tool usage control'
>
<span
className={`font-medium text-xs ${
tool.usageControl === 'auto'
? 'block text-muted-foreground'
: 'hidden'
}`}
>
Auto
</span>
<span
className={`font-medium text-xs ${tool.usageControl === 'force' ? 'block text-muted-foreground' : 'hidden'}`}
>
Force
</span>
<span
className={`font-medium text-xs ${tool.usageControl === 'none' ? 'block text-muted-foreground' : 'hidden'}`}
>
None
</span>
</Toggle>
</Tooltip.Trigger>
<Tooltip.Content className='max-w-[280px] p-2' side='top'>
<p className='text-xs'>
{tool.usageControl === 'auto' && (
<span>
{' '}
<span className='font-medium'> Auto:</span> The model decides when
to use the tool
</span>
)}
{tool.usageControl === 'force' && (
<span>
<span className='font-medium'> Force:</span> Always use this tool
in the response
</span>
)}
{tool.usageControl === 'none' && (
<span>
<span className='font-medium'> Deny:</span> Never use this tool
</span>
)}
</p>
</Tooltip.Content>
</Tooltip.Root>
<div className='flex min-w-0 flex-1 items-center gap-[10px]'>
<div
className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px]'
style={{
backgroundColor: isCustomTool
? '#3B82F6'
: isMcpTool
? mcpTool?.bgColor || '#6366F1'
: toolBlock?.bgColor,
}}
>
{isCustomTool ? (
<WrenchIcon className='h-[10px] w-[10px] text-white' />
) : isMcpTool ? (
<IconComponent icon={Server} className='h-[10px] w-[10px] text-white' />
) : (
<IconComponent
icon={toolBlock?.icon}
className='h-[10px] w-[10px] text-white'
/>
)}
<button
onClick={(e) => {
e.stopPropagation()
handleRemoveTool(toolIndex)
}}
className='text-muted-foreground hover:text-foreground'
>
<XIcon className='h-4 w-4' />
</button>
</div>
<span className='truncate font-medium text-[#EEEEEE] text-[13px]'>
{tool.title}
</span>
</div>
<div className='flex flex-shrink-0 items-center gap-[8px]'>
{supportsToolControl && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Toggle
className='group flex h-auto items-center justify-center rounded-sm p-0 hover:bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=on]:bg-transparent'
pressed={true}
onPressedChange={() => {}}
onClick={(e: React.MouseEvent) => {
e.stopPropagation()
const currentState = tool.usageControl || 'auto'
const nextState =
currentState === 'auto'
? 'force'
: currentState === 'force'
? 'none'
: 'auto'
handleUsageControlChange(toolIndex, nextState)
}}
aria-label='Toggle tool usage control'
>
<span
className={`font-medium text-[#AEAEAE] text-xs ${
tool.usageControl === 'auto' ? 'block' : 'hidden'
}`}
>
Auto
</span>
<span
className={`font-medium text-[#AEAEAE] text-xs ${
tool.usageControl === 'force' ? 'block' : 'hidden'
}`}
>
Force
</span>
<span
className={`font-medium text-[#AEAEAE] text-xs ${
tool.usageControl === 'none' ? 'block' : 'hidden'
}`}
>
None
</span>
</Toggle>
</Tooltip.Trigger>
<Tooltip.Content className='max-w-[280px] p-2' side='top'>
<p className='text-xs'>
{tool.usageControl === 'auto' && (
<span>
<span className='font-medium'>Auto:</span> The model decides when to
use the tool
</span>
)}
{tool.usageControl === 'force' && (
<span>
<span className='font-medium'>Force:</span> Always use this tool in
the response
</span>
)}
{tool.usageControl === 'none' && (
<span>
<span className='font-medium'>Deny:</span> Never use this tool
</span>
)}
</p>
</Tooltip.Content>
</Tooltip.Root>
)}
<button
onClick={(e) => {
e.stopPropagation()
handleRemoveTool(toolIndex)
}}
className='text-[#AEAEAE] transition-colors hover:text-[#EEEEEE]'
aria-label='Remove tool'
>
<XIcon className='h-[14px] w-[14px]' />
</button>
</div>
</div>
{!isCustomTool && isExpandedForDisplay && (
<div className='space-y-3 overflow-visible p-3'>
{/* Operation dropdown for tools with multiple operations */}
{(() => {
const hasOperations = hasMultipleOperations(tool.type)
const operationOptions = hasOperations ? getOperationOptions(tool.type) : []
{!isCustomTool && isExpandedForDisplay && (
<div className='space-y-[12px] overflow-visible p-[10px]'>
{/* Operation dropdown for tools with multiple operations */}
{(() => {
const hasOperations = hasMultipleOperations(tool.type)
const operationOptions = hasOperations ? getOperationOptions(tool.type) : []
return hasOperations && operationOptions.length > 0 ? (
<div className='relative min-w-0 space-y-1.5'>
<div className='font-medium text-muted-foreground text-xs'>
Operation
</div>
<div className='w-full min-w-0'>
<Select
value={tool.operation || operationOptions[0].id}
onValueChange={(value) => handleOperationChange(toolIndex, value)}
>
<SelectTrigger className='w-full min-w-0 rounded-[4px] border border-[#3D3D3D] bg-[#282828] px-[8px] py-[7px] text-left font-medium font-sans text-sm dark:bg-[#363636]'>
<SelectValue
placeholder='Select operation'
className='truncate'
/>
</SelectTrigger>
<SelectContent className='border-[#3D3D3D] bg-[#282828] dark:bg-[#353535]'>
{operationOptions
.filter((option) => option.id !== '')
.map((option) => (
<SelectItem key={option.id} value={option.id}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
) : null
})()}
{/* OAuth credential selector if required */}
{requiresOAuth && oauthConfig && (
<div className='relative min-w-0 space-y-1.5'>
<div className='font-medium text-muted-foreground text-xs'>Account</div>
return hasOperations && operationOptions.length > 0 ? (
<div className='relative min-w-0 space-y-[6px]'>
<div className='font-medium text-[#AEAEAE] text-[13px]'>Operation</div>
<div className='w-full min-w-0'>
<ToolCredentialSelector
value={tool.params.credential || ''}
onChange={(value) =>
handleParamChange(toolIndex, 'credential', value)
}
provider={oauthConfig.provider as OAuthProvider}
requiredScopes={oauthConfig.additionalScopes || []}
label={`Select ${oauthConfig.provider} account`}
serviceId={oauthConfig.provider}
disabled={disabled}
/>
<Select
value={tool.operation || operationOptions[0].id}
onValueChange={(value) => handleOperationChange(toolIndex, value)}
>
<SelectTrigger className='w-full min-w-0 rounded-[4px] border border-[#303030] bg-[#1F1F1F] px-[10px] py-[8px] text-left font-medium text-sm'>
<SelectValue placeholder='Select operation' className='truncate' />
</SelectTrigger>
<SelectContent className='border-[#303030] bg-[#1F1F1F]'>
{operationOptions
.filter((option) => option.id !== '')
.map((option) => (
<SelectItem key={option.id} value={option.id}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
) : null
})()}
{/* Tool parameters */}
{(() => {
const filteredParams = displayParams.filter((param) =>
evaluateParameterCondition(param, tool)
)
const groupedParams: { [key: string]: ToolParameterConfig[] } = {}
const standaloneParams: ToolParameterConfig[] = []
{/* OAuth credential selector if required */}
{requiresOAuth && oauthConfig && (
<div className='relative min-w-0 space-y-[6px]'>
<div className='font-medium text-[#AEAEAE] text-[13px]'>Account</div>
<div className='w-full min-w-0'>
<ToolCredentialSelector
value={tool.params.credential || ''}
onChange={(value) => handleParamChange(toolIndex, 'credential', value)}
provider={oauthConfig.provider as OAuthProvider}
requiredScopes={oauthConfig.additionalScopes || []}
label={`Select ${oauthConfig.provider} account`}
serviceId={oauthConfig.provider}
disabled={disabled}
/>
</div>
</div>
)}
// Group checkbox-list parameters by their UI component title
filteredParams.forEach((param) => {
const paramConfig = param as ToolParameterConfig
if (
paramConfig.uiComponent?.type === 'checkbox-list' &&
paramConfig.uiComponent?.title
) {
const groupKey = paramConfig.uiComponent.title
if (!groupedParams[groupKey]) {
groupedParams[groupKey] = []
}
groupedParams[groupKey].push(paramConfig)
} else {
standaloneParams.push(paramConfig)
{/* Tool parameters */}
{(() => {
const filteredParams = displayParams.filter((param) =>
evaluateParameterCondition(param, tool)
)
const groupedParams: { [key: string]: ToolParameterConfig[] } = {}
const standaloneParams: ToolParameterConfig[] = []
// Group checkbox-list parameters by their UI component title
filteredParams.forEach((param) => {
const paramConfig = param as ToolParameterConfig
if (
paramConfig.uiComponent?.type === 'checkbox-list' &&
paramConfig.uiComponent?.title
) {
const groupKey = paramConfig.uiComponent.title
if (!groupedParams[groupKey]) {
groupedParams[groupKey] = []
}
})
groupedParams[groupKey].push(paramConfig)
} else {
standaloneParams.push(paramConfig)
}
})
const renderedElements: React.ReactNode[] = []
const renderedElements: React.ReactNode[] = []
// Render grouped checkbox-lists
Object.entries(groupedParams).forEach(([groupTitle, params]) => {
const firstParam = params[0] as ToolParameterConfig
const groupValue = JSON.stringify(
params.reduce(
(acc, p) => ({ ...acc, [p.id]: tool.params[p.id] === 'true' }),
{}
)
// Render grouped checkbox-lists
Object.entries(groupedParams).forEach(([groupTitle, params]) => {
const firstParam = params[0] as ToolParameterConfig
const groupValue = JSON.stringify(
params.reduce(
(acc, p) => ({ ...acc, [p.id]: tool.params[p.id] === 'true' }),
{}
)
)
renderedElements.push(
<div
key={`group-${groupTitle}`}
className='relative min-w-0 space-y-1.5'
>
<div className='flex items-center font-medium text-muted-foreground text-xs'>
{groupTitle}
</div>
<div className='relative w-full min-w-0'>
<CheckboxListSyncWrapper
renderedElements.push(
<div
key={`group-${groupTitle}`}
className='relative min-w-0 space-y-[6px]'
>
<div className='flex items-center font-medium text-[#AEAEAE] text-[13px]'>
{groupTitle}
</div>
<div className='relative w-full min-w-0'>
<CheckboxListSyncWrapper
blockId={blockId}
paramId={`group-${groupTitle}`}
value={groupValue}
onChange={(value) => {
try {
const parsed = JSON.parse(value)
params.forEach((param) => {
handleParamChange(
toolIndex,
param.id,
parsed[param.id] ? 'true' : 'false'
)
})
} catch (e) {
// Handle error
}
}}
uiComponent={firstParam.uiComponent}
disabled={disabled}
/>
</div>
</div>
)
})
// Render standalone parameters
standaloneParams.forEach((param) => {
renderedElements.push(
<div key={param.id} className='relative min-w-0 space-y-[6px]'>
<div className='flex items-center font-medium text-[#AEAEAE] text-[13px]'>
{param.uiComponent?.title || formatParameterLabel(param.id)}
{param.required && param.visibility === 'user-only' && (
<span className='ml-1 text-red-500'>*</span>
)}
{(!param.required || param.visibility !== 'user-only') && (
<span className='ml-1 text-[#787878] text-xs'>(Optional)</span>
)}
</div>
<div className='relative w-full min-w-0'>
{param.uiComponent ? (
renderParameterInput(
param,
tool.params[param.id] || '',
(value) => handleParamChange(toolIndex, param.id, value),
toolIndex,
tool.params
)
) : (
<ShortInput
blockId={blockId}
paramId={`group-${groupTitle}`}
value={groupValue}
onChange={(value) => {
try {
const parsed = JSON.parse(value)
params.forEach((param) => {
handleParamChange(
toolIndex,
param.id,
parsed[param.id] ? 'true' : 'false'
)
})
} catch (e) {
// Handle error
}
subBlockId={`${subBlockId}-tool-${toolIndex}-${param.id}`}
placeholder={param.description}
password={isPasswordParameter(param.id)}
config={{
id: `${subBlockId}-tool-${toolIndex}-${param.id}`,
type: 'short-input',
title: param.id,
}}
uiComponent={firstParam.uiComponent}
disabled={disabled}
value={tool.params[param.id] || ''}
onChange={(value) =>
handleParamChange(toolIndex, param.id, value)
}
/>
</div>
)}
</div>
)
})
</div>
)
})
// Render standalone parameters
standaloneParams.forEach((param) => {
renderedElements.push(
<div key={param.id} className='relative min-w-0 space-y-1.5'>
<div className='flex items-center font-medium text-muted-foreground text-xs'>
{param.uiComponent?.title || formatParameterLabel(param.id)}
{param.required && param.visibility === 'user-only' && (
<span className='ml-1 text-red-500'>*</span>
)}
{(!param.required || param.visibility !== 'user-only') && (
<span className='ml-1 text-muted-foreground/60 text-xs'>
(Optional)
</span>
)}
</div>
<div className='relative w-full min-w-0'>
{param.uiComponent ? (
renderParameterInput(
param,
tool.params[param.id] || '',
(value) => handleParamChange(toolIndex, param.id, value),
toolIndex,
tool.params
)
) : (
<ShortInput
blockId={blockId}
subBlockId={`${subBlockId}-tool-${toolIndex}-${param.id}`}
placeholder={param.description}
password={isPasswordParameter(param.id)}
config={{
id: `${subBlockId}-tool-${toolIndex}-${param.id}`,
type: 'short-input',
title: param.id,
}}
value={tool.params[param.id] || ''}
onChange={(value) =>
handleParamChange(toolIndex, param.id, value)
}
/>
)}
</div>
</div>
)
})
return renderedElements
})()}
</div>
)}
</div>
return renderedElements
})()}
</div>
)}
</div>
)
})}
{/* Drop zone for the end of the list */}
{selectedTools.length > 0 && draggedIndex !== null && (
<div
className={cn(
'h-2 w-full rounded transition-all duration-200 ease-in-out',
dragOverIndex === selectedTools.length
? 'border-b-2 border-b-muted-foreground/40'
: ''
)}
onDragOver={(e) => handleDragOver(e, selectedTools.length)}
onDrop={(e) => handleDrop(e, selectedTools.length)}
/>
)}
{/* Add Tool Button */}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-6 px-2 text-muted-foreground text-xs hover:text-foreground'
>
<PlusIcon className='h-3 w-3' />
Add Tool
</Button>
<div className='flex w-full cursor-pointer items-center justify-center rounded-[4px] border border-[#303030] bg-[#1F1F1F] px-[10px] py-[6px] font-medium text-sm transition-colors hover:bg-[#252525]'>
<div className='flex items-center text-[#787878] text-[13px]'>
<PlusIcon className='mr-2 h-4 w-4' />
Add Tool
</div>
</div>
</PopoverTrigger>
<PopoverContent
className='h-[300px] w-full border-[#3D3D3D] bg-[#282828] p-0 dark:bg-[#363636]'
maxHeight={240}
className='w-[var(--radix-popover-trigger-width)]'
align='start'
sideOffset={6}
avoidCollisions={false}
>
<ToolCommand.Root filter={customFilter} className='bg-[#282828] dark:bg-[#363636]'>
<ToolCommand.Input placeholder='Search tools...' onValueChange={setSearchQuery} />
<ToolCommand.List>
<ToolCommand.Empty>No tools found.</ToolCommand.Empty>
<ToolCommand.Group>
<PopoverSearch placeholder='Search tools...' onValueChange={setSearchQuery} />
<PopoverScrollArea>
<ToolCommand.Root filter={customFilter} searchQuery={searchQuery}>
<ToolCommand.List>
<ToolCommand.Empty>No tools found</ToolCommand.Empty>
<ToolCommand.Item
value='Create Tool'
onSelect={() => {
setOpen(false)
setCustomToolModalOpen(true)
}}
className='mb-1 flex cursor-pointer items-center gap-2'
>
<div className='flex h-6 w-6 items-center justify-center rounded border border-muted-foreground/50 border-dashed bg-transparent'>
<WrenchIcon className='h-4 w-4 text-muted-foreground' />
<div className='flex h-[15px] w-[15px] flex-shrink-0 items-center justify-center rounded border border-muted-foreground/50 border-dashed bg-transparent'>
<WrenchIcon className='h-[11px] w-[11px] text-muted-foreground' />
</div>
<span>Create Tool</span>
<span className='truncate'>Create Tool</span>
</ToolCommand.Item>
<ToolCommand.Item
@@ -1905,23 +1885,24 @@ export function ToolInput({
new CustomEvent('open-settings', { detail: { tab: 'mcp' } })
)
}}
className='mb-1 flex cursor-pointer items-center gap-2'
>
<div className='flex h-6 w-6 items-center justify-center rounded border border-muted-foreground/50 border-dashed bg-transparent'>
<Server className='h-4 w-4 text-muted-foreground' />
<div className='flex h-[15px] w-[15px] flex-shrink-0 items-center justify-center rounded border border-muted-foreground/50 border-dashed bg-transparent'>
<Server className='h-[11px] w-[11px] text-muted-foreground' />
</div>
<span>Add MCP Server</span>
<span className='truncate'>Add MCP Server</span>
</ToolCommand.Item>
{/* Display saved custom tools at the top */}
{customTools.length > 0 && (
<>
<ToolCommand.Separator />
<div className='px-2 pt-2.5 pb-0.5 font-medium text-muted-foreground text-xs'>
Custom Tools
</div>
<ToolCommand.Group className='-mx-1 -px-1'>
{customTools.map((customTool) => (
{(() => {
const matchingCustomTools = customTools.filter(
(tool) => customFilter(tool.title, searchQuery || '') > 0
)
if (matchingCustomTools.length === 0) return null
return (
<>
<PopoverSection>Custom Tools</PopoverSection>
{matchingCustomTools.map((customTool) => (
<ToolCommand.Item
key={customTool.id}
value={customTool.title}
@@ -1946,18 +1927,16 @@ export function ToolInput({
])
setOpen(false)
}}
className='flex cursor-pointer items-center gap-2'
>
<div className='flex h-6 w-6 items-center justify-center rounded bg-blue-500'>
<WrenchIcon className='h-4 w-4 text-white' />
<div className='flex h-[15px] w-[15px] flex-shrink-0 items-center justify-center rounded bg-blue-500'>
<WrenchIcon className='h-[11px] w-[11px] text-white' />
</div>
<span className='max-w-[140px] truncate'>{customTool.title}</span>
<span className='truncate'>{customTool.title}</span>
</ToolCommand.Item>
))}
</ToolCommand.Group>
<ToolCommand.Separator />
</>
)}
</>
)
})()}
{/* Display MCP tools */}
<McpToolsList
@@ -1969,39 +1948,42 @@ export function ToolInput({
/>
{/* Display built-in tools */}
{toolBlocks.some(
(block) => customFilter(block.name, searchQuery || '') > 0
) && (
<>
<div className='px-2 pt-2.5 pb-0.5 font-medium text-muted-foreground text-xs'>
Built-in Tools
</div>
<ToolCommand.Group className='-mx-1 -px-1'>
{toolBlocks.map((block) => (
{(() => {
const matchingBlocks = toolBlocks.filter(
(block) => customFilter(block.name, searchQuery || '') > 0
)
if (matchingBlocks.length === 0) return null
return (
<>
<PopoverSection>Built-in Tools</PopoverSection>
{matchingBlocks.map((block) => (
<ToolCommand.Item
key={block.type}
value={block.name}
onSelect={() => handleSelectTool(block)}
className='flex cursor-pointer items-center gap-2'
>
<div
className='flex h-6 w-6 items-center justify-center rounded'
className='flex h-[15px] w-[15px] flex-shrink-0 items-center justify-center rounded'
style={{ backgroundColor: block.bgColor }}
>
<IconComponent icon={block.icon} className='h-4 w-4 text-white' />
<IconComponent
icon={block.icon}
className='h-[11px] w-[11px] text-white'
/>
</div>
<span className='max-w-[140px] truncate'>{block.name}</span>
<span className='truncate'>{block.name}</span>
</ToolCommand.Item>
))}
</ToolCommand.Group>
</>
)}
</ToolCommand.Group>
</ToolCommand.List>
</ToolCommand.Root>
</>
)
})()}
</ToolCommand.List>
</ToolCommand.Root>
</PopoverScrollArea>
</PopoverContent>
</Popover>
</div>
</>
)}
{/* Custom Tool Modal */}

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { AlertCircle, Check, Copy, Save, Trash2 } from 'lucide-react'
import { Button } from '@/components/emcn/components'
import { Trash } from '@/components/emcn/icons/trash'
import { Alert, AlertDescription } from '@/components/ui/alert'
import {
AlertDialog,
@@ -11,8 +12,6 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
@@ -21,6 +20,7 @@ import { useWebhookManagement } from '@/hooks/use-webhook-management'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { getTrigger, isTriggerValid } from '@/triggers'
import { SYSTEM_SUBBLOCK_IDS } from '@/triggers/consts'
import { ShortInput } from '../short-input/short-input'
const logger = createLogger('TriggerSave')
@@ -45,10 +45,20 @@ export function TriggerSave({
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [deleteStatus, setDeleteStatus] = useState<'idle' | 'deleting'>('idle')
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [testUrl, setTestUrl] = useState<string | null>(null)
const [testUrlExpiresAt, setTestUrlExpiresAt] = useState<string | null>(null)
const [isGeneratingTestUrl, setIsGeneratingTestUrl] = useState(false)
const [copied, setCopied] = useState<string | null>(null)
const storedTestUrl = useSubBlockStore((state) => state.getValue(blockId, 'testUrl'))
const storedTestUrlExpiresAt = useSubBlockStore((state) =>
state.getValue(blockId, 'testUrlExpiresAt')
)
const isTestUrlExpired = useMemo(() => {
if (!storedTestUrlExpiresAt) return true
return new Date(storedTestUrlExpiresAt) < new Date()
}, [storedTestUrlExpiresAt])
const testUrl = isTestUrlExpired ? null : (storedTestUrl as string | null)
const testUrlExpiresAt = isTestUrlExpired ? null : (storedTestUrlExpiresAt as string | null)
const effectiveTriggerId = useMemo(() => {
if (triggerId && isTriggerValid(triggerId)) {
@@ -203,6 +213,13 @@ export function TriggerSave({
validateRequiredFields,
])
useEffect(() => {
if (isTestUrlExpired && storedTestUrl) {
useSubBlockStore.getState().setValue(blockId, 'testUrl', null)
useSubBlockStore.getState().setValue(blockId, 'testUrlExpiresAt', null)
}
}, [blockId, isTestUrlExpired, storedTestUrl])
const handleSave = async () => {
if (isPreview || disabled) return
@@ -276,8 +293,10 @@ export function TriggerSave({
throw new Error(err?.error || 'Failed to generate test URL')
}
const json = await res.json()
setTestUrl(json.url)
setTestUrlExpiresAt(json.expiresAt)
useSubBlockStore.getState().setValue(blockId, 'testUrl', json.url)
useSubBlockStore.getState().setValue(blockId, 'testUrlExpiresAt', json.expiresAt)
collaborativeSetSubblockValue(blockId, 'testUrl', json.url)
collaborativeSetSubblockValue(blockId, 'testUrlExpiresAt', json.expiresAt)
} catch (e) {
logger.error('Failed to generate test webhook URL', { error: e })
setErrorMessage(
@@ -288,12 +307,6 @@ export function TriggerSave({
}
}
const copyToClipboard = (text: string, type: string): void => {
navigator.clipboard.writeText(text)
setCopied(type)
setTimeout(() => setCopied(null), 2000)
}
const handleDeleteClick = () => {
if (isPreview || disabled || !webhookId) return
setShowDeleteDialog(true)
@@ -311,12 +324,15 @@ export function TriggerSave({
setDeleteStatus('idle')
setSaveStatus('idle')
setErrorMessage(null)
setTestUrl(null)
setTestUrlExpiresAt(null)
useSubBlockStore.getState().setValue(blockId, 'testUrl', null)
useSubBlockStore.getState().setValue(blockId, 'testUrlExpiresAt', null)
collaborativeSetSubblockValue(blockId, 'triggerPath', '')
collaborativeSetSubblockValue(blockId, 'webhookId', null)
collaborativeSetSubblockValue(blockId, 'triggerConfig', null)
collaborativeSetSubblockValue(blockId, 'testUrl', null)
collaborativeSetSubblockValue(blockId, 'testUrlExpiresAt', null)
logger.info('Trigger configuration deleted successfully', {
blockId,
@@ -344,6 +360,7 @@ export function TriggerSave({
<div id={`${blockId}-${subBlockId}`}>
<div className='flex gap-2'>
<Button
variant='default'
onClick={handleSave}
disabled={disabled || isProcessing}
className={cn(
@@ -358,37 +375,22 @@ export function TriggerSave({
Saving...
</>
)}
{saveStatus === 'saved' && (
<>
<Check className='mr-2 h-4 w-4' />
Saved
</>
)}
{saveStatus === 'error' && (
<>
<AlertCircle className='mr-2 h-4 w-4' />
Error
</>
)}
{saveStatus === 'idle' && (
<>
<Save className='mr-2 h-4 w-4' />
{webhookId ? 'Update Configuration' : 'Save Configuration'}
</>
)}
{saveStatus === 'saved' && 'Saved'}
{saveStatus === 'error' && 'Error'}
{saveStatus === 'idle' && (webhookId ? 'Update Configuration' : 'Save Configuration')}
</Button>
{webhookId && (
<Button
variant='default'
onClick={handleDeleteClick}
disabled={disabled || isProcessing}
variant='outline'
className='h-9 rounded-[8px] px-3 text-destructive hover:bg-destructive/10'
className='h-9 rounded-[8px] px-3'
>
{deleteStatus === 'deleting' ? (
<div className='h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
) : (
<Trash2 className='h-4 w-4' />
<Trash className='h-[14px] w-[14px]' />
)}
</Button>
)}
@@ -406,7 +408,6 @@ export function TriggerSave({
<span className='font-medium text-sm'>Test Webhook URL</span>
<Button
variant='outline'
size='sm'
onClick={generateTestUrl}
disabled={isGeneratingTestUrl || isProcessing}
className='h-8 rounded-[8px]'
@@ -424,29 +425,21 @@ export function TriggerSave({
</Button>
</div>
{testUrl ? (
<div className='flex items-center gap-2'>
<Input
readOnly
value={testUrl}
className='h-9 flex-1 rounded-[8px] font-mono text-xs'
onClick={(e: React.MouseEvent<HTMLInputElement>) =>
(e.target as HTMLInputElement).select()
}
/>
<Button
type='button'
size='icon'
variant='outline'
className='h-9 w-9 rounded-[8px]'
onClick={() => copyToClipboard(testUrl, 'testUrl')}
>
{copied === 'testUrl' ? (
<Check className='h-4 w-4 text-green-500' />
) : (
<Copy className='h-4 w-4' />
)}
</Button>
</div>
<ShortInput
blockId={blockId}
subBlockId={`${subBlockId}-test-url`}
config={{
id: `${subBlockId}-test-url`,
type: 'short-input',
readOnly: true,
showCopyButton: true,
}}
value={testUrl}
readOnly={true}
showCopyButton={true}
disabled={isPreview || disabled}
isPreview={isPreview}
/>
) : (
<p className='text-muted-foreground text-xs'>
Generate a temporary URL that executes this webhook against the live (un-deployed)

View File

@@ -1,13 +1,11 @@
import { useEffect, useState } from 'react'
import { Info } from 'lucide-react'
import { Tooltip } from '@/components/emcn'
import { GmailIcon } from '@/components/icons'
import {
Badge,
Button,
Checkbox,
Label,
Notice,
Select,
SelectContent,
SelectItem,
@@ -16,7 +14,6 @@ import {
Skeleton,
} from '@/components/ui'
import { createLogger } from '@/lib/logs/console/logger'
import { JSONView } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components'
import { ConfigSection } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/webhook/components'
const logger = createLogger('GmailConfig')
@@ -56,76 +53,6 @@ const formatLabelName = (label: GmailLabel): string => {
return formattedName
}
const getExampleEmailEvent = (includeRawEmail: boolean) => {
const baseExample = {
email: {
id: '18e0ffabd5b5a0f4',
threadId: '18e0ffabd5b5a0f4',
subject: 'Monthly Report - April 2025',
from: 'sender@example.com',
to: 'recipient@example.com',
cc: 'team@example.com',
date: '2025-05-10T10:15:23.000Z',
bodyText:
'Hello,\n\nPlease find attached the monthly report for April 2025.\n\nBest regards,\nSender',
bodyHtml:
'<div><p>Hello,</p><p>Please find attached the monthly report for April 2025.</p><p>Best regards,<br>Sender</p></div>',
labels: ['INBOX', 'IMPORTANT'],
hasAttachments: true,
attachments: [
{
filename: 'report-april-2025.pdf',
mimeType: 'application/pdf',
size: 2048576,
},
],
},
timestamp: '2025-05-10T10:15:30.123Z',
}
if (includeRawEmail) {
return {
...baseExample,
rawEmail: {
id: '18e0ffabd5b5a0f4',
threadId: '18e0ffabd5b5a0f4',
labelIds: ['INBOX', 'IMPORTANT'],
snippet: 'Hello, Please find attached the monthly report...',
historyId: '123456',
internalDate: '1715337323000',
payload: {
partId: '',
mimeType: 'multipart/mixed',
filename: '',
headers: [
{ name: 'From', value: 'sender@example.com' },
{ name: 'To', value: 'recipient@example.com' },
{ name: 'Subject', value: 'Monthly Report - April 2025' },
{ name: 'Date', value: 'Fri, 10 May 2025 10:15:23 +0000' },
{ name: 'Message-ID', value: '<abc123@example.com>' },
],
body: { size: 0 },
parts: [
{
partId: '0',
mimeType: 'text/plain',
filename: '',
headers: [{ name: 'Content-Type', value: 'text/plain; charset=UTF-8' }],
body: {
size: 85,
data: 'SGVsbG8sDQoNClBsZWFzZSBmaW5kIGF0dGFjaGVkIHRoZSBtb250aGx5IHJlcG9ydA==',
},
},
],
},
sizeEstimate: 4156,
},
}
}
return baseExample
}
interface GmailConfigProps {
selectedLabels: string[]
setSelectedLabels: (labels: string[]) => void
@@ -364,17 +291,6 @@ export function GmailConfig({
</div>
</div>
</ConfigSection>
<Notice
variant='default'
className='border-slate-200 bg-white dark:border-border dark:bg-background'
icon={<GmailIcon className='mt-0.5 mr-3.5 h-5 w-5 flex-shrink-0 text-red-500' />}
title='Gmail Event Payload Example'
>
<div className='overflow-wrap-anywhere mt-2 whitespace-normal break-normal font-mono text-sm'>
<JSONView data={getExampleEmailEvent(includeRawEmail)} />
</div>
</Notice>
</div>
)
}

View File

@@ -1,7 +1,6 @@
import { useEffect, useState } from 'react'
import { Info } from 'lucide-react'
import { Tooltip } from '@/components/emcn'
import { OutlookIcon } from '@/components/icons'
import {
Badge,
Button,
@@ -16,7 +15,6 @@ import {
Skeleton,
} from '@/components/ui'
import { createLogger } from '@/lib/logs/console/logger'
import { JSONView } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components'
import { ConfigSection } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/webhook/components'
const logger = createLogger('OutlookConfig')
@@ -40,70 +38,6 @@ const TOOLTIPS = {
}
// Generate example payload for Outlook
const generateOutlookExamplePayload = (includeRawEmail: boolean) => {
const baseExample: any = {
email: {
id: 'AAMkAGVmMDEzMTM4LTZmYWUtNDdkNC1hMDZiLTU1OGY5OTZhYmY4OABGAAAAAAAiQ8W967B7TKBjgx9rVEURBwAiIsqMbYjsT5e-T4KzowKTAAAAAAEMAAAiIsqMbYjsT5e-T4KzowKTAAAYbvZDAAA=',
conversationId:
'AAQkAGVmMDEzMTM4LTZmYWUtNDdkNC1hMDZiLTU1OGY5OTZhYmY4OAAQAOH_y8jLzUGIn-HVkHUBrEE=',
subject: 'Monthly Report - January 2024',
from: 'sender@company.com',
to: 'recipient@company.com',
cc: '',
date: '2024-01-15T10:30:00Z',
bodyText: 'Hello, Please find attached the monthly report for January 2024.',
bodyHtml: '<p>Hello,</p><p>Please find attached the monthly report for January 2024.</p>',
hasAttachments: true,
isRead: false,
folderId: 'inbox',
},
timestamp: '2024-01-15T10:30:15.123Z',
}
if (includeRawEmail) {
baseExample.rawEmail = {
id: 'AAMkAGVmMDEzMTM4LTZmYWUtNDdkNC1hMDZiLTU1OGY5OTZhYmY4OABGAAAAAAAiQ8W967B7TKBjgx9rVEURBwAiIsqMbYjsT5e-T4KzowKTAAAAAAEMAAAiIsqMbYjsT5e-T4KzowKTAAAYbvZDAAA=',
conversationId:
'AAQkAGVmMDEzMTM4LTZmYWUtNDdkNC1hMDZiLTU1OGY5OTZhYmY4OAAQAOH_y8jLzUGIn-HVkHUBrEE=',
subject: 'Monthly Report - January 2024',
bodyPreview: 'Hello, Please find attached the monthly report for January 2024.',
body: {
contentType: 'html',
content: '<p>Hello,</p><p>Please find attached the monthly report for January 2024.</p>',
},
from: {
emailAddress: {
name: 'John Doe',
address: 'sender@company.com',
},
},
toRecipients: [
{
emailAddress: {
name: 'Jane Smith',
address: 'recipient@company.com',
},
},
],
ccRecipients: [],
bccRecipients: [],
receivedDateTime: '2024-01-15T10:30:00Z',
sentDateTime: '2024-01-15T10:29:45Z',
hasAttachments: true,
isRead: false,
isDraft: false,
importance: 'normal',
parentFolderId: 'inbox',
internetMessageId: '<message-id@company.com>',
webLink: 'https://outlook.office365.com/owa/?ItemID=...',
createdDateTime: '2024-01-15T10:30:00Z',
lastModifiedDateTime: '2024-01-15T10:30:15Z',
changeKey: 'CQAAABYAAAAiIsqMbYjsT5e-T4KzowKTAAAYbvZE',
}
}
return baseExample
}
interface OutlookConfigProps {
selectedLabels: string[]
@@ -368,16 +302,6 @@ export function OutlookConfig({
</div>
</div>
</ConfigSection>
<ConfigSection>
<div className='mb-3 flex items-center gap-2'>
<OutlookIcon className='h-4 w-4' />
<h3 className='font-medium text-sm'>Outlook Event Payload Example</h3>
</div>
<div className='rounded-md border bg-muted/50 p-3'>
<JSONView data={generateOutlookExamplePayload(includeRawEmail)} />
</div>
</ConfigSection>
</div>
)
}

View File

@@ -1,6 +1,3 @@
import { SlackIcon } from '@/components/icons'
import { Notice } from '@/components/ui'
import { JSONView } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components'
import {
ConfigSection,
InstructionsSection,
@@ -23,24 +20,6 @@ interface SlackConfigProps {
webhookUrl: string
}
const exampleEvent = JSON.stringify(
{
type: 'event_callback',
event: {
type: 'message',
channel: 'C0123456789',
user: 'U0123456789',
text: 'Hello from Slack!',
ts: '1234567890.123456',
},
team_id: 'T0123456789',
event_id: 'Ev0123456789',
event_time: 1234567890,
},
null,
2
)
export function SlackConfig({
signingSecret,
setSigningSecret,
@@ -144,20 +123,6 @@ export function SlackConfig({
<li>Save changes in both Slack and here.</li>
</ol>
</InstructionsSection>
<Notice
variant='default'
className='border-slate-200 bg-white dark:border-border dark:bg-background'
icon={
<SlackIcon className='mt-0.5 mr-3.5 h-5 w-5 flex-shrink-0 text-[#611f69] dark:text-[#e01e5a]' />
}
title='Slack Event Payload Example'
>
Your workflow will receive a payload similar to this when a subscribed event occurs:
<div className='overflow-wrap-anywhere mt-2 whitespace-normal break-normal font-mono text-sm'>
<JSONView data={JSON.parse(exampleEvent)} />
</div>
</Notice>
</div>
)
}

View File

@@ -200,7 +200,7 @@ export function Editor() {
</div>
{!currentBlockId || !currentBlock ? (
<div className='flex flex-1 items-center justify-center text-muted-foreground text-sm'>
<div className='flex flex-1 items-center justify-center text-[#8D8D8D] text-[13px]'>
Select a block to edit
</div>
) : (
@@ -212,7 +212,7 @@ export function Editor() {
>
<div className='flex-1 overflow-y-auto overflow-x-hidden px-[8px] pt-[8px] pb-[8px]'>
{subBlocks.length === 0 ? (
<div className='flex h-full items-center justify-center text-center text-muted-foreground text-sm'>
<div className='flex h-full items-center justify-center text-center text-[#8D8D8D] text-[13px]'>
This block has no subblocks
</div>
) : (

View File

@@ -1,4 +1,5 @@
export { Copilot } from './copilot/copilot'
export { Deploy } from './deploy/deploy'
export { Editor } from './editor/editor'
export { Toolbar } from './toolbar'
export { Toolbar } from './toolbar/toolbar'
export { WorkflowControls } from './workflow-controls/workflow-controls'

View File

@@ -0,0 +1,74 @@
/**
* Information needed to create a drag preview for a toolbar item
*/
export interface DragItemInfo {
name: string
bgColor: string
iconElement?: HTMLElement | null
}
/**
* Creates a custom drag preview element that looks like a workflow block.
* This provides a consistent visual experience when dragging items from the toolbar to the canvas.
*
* @param info - Information about the item being dragged
* @returns HTML element to use as drag preview
*/
export function createDragPreview(info: DragItemInfo): HTMLElement {
const preview = document.createElement('div')
preview.style.cssText = `
width: 250px;
background: #232323;
border-radius: 8px;
padding: 8px 9px;
display: flex;
align-items: center;
gap: 10px;
font-family: system-ui, -apple-system, sans-serif;
position: fixed;
top: -500px;
left: 0;
pointer-events: none;
z-index: 9999;
`
// Create icon container
const iconContainer = document.createElement('div')
iconContainer.style.cssText = `
width: 24px;
height: 24px;
border-radius: 6px;
background: ${info.bgColor};
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
`
// Clone the actual icon if provided
if (info.iconElement) {
const clonedIcon = info.iconElement.cloneNode(true) as HTMLElement
clonedIcon.style.width = '16px'
clonedIcon.style.height = '16px'
clonedIcon.style.color = 'white'
clonedIcon.style.flexShrink = '0'
iconContainer.appendChild(clonedIcon)
}
// Create text element
const text = document.createElement('span')
text.textContent = info.name
text.style.cssText = `
color: #FFFFFF;
font-size: 16px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`
preview.appendChild(iconContainer)
preview.appendChild(text)
return preview
}

View File

@@ -0,0 +1 @@
export { createDragPreview, type DragItemInfo } from './drag-preview'

View File

@@ -1,2 +1,2 @@
export { useToolbarItemInteractions } from './use-toolbar-item-interactions'
export { useToolbarResize } from './use-toolbar-resize'
export { calculateTriggerHeights, useToolbarResize } from './use-toolbar-resize'

View File

@@ -1,5 +1,6 @@
import { useCallback } from 'react'
import { useCallback, useRef } from 'react'
import { createLogger } from '@/lib/logs/console/logger'
import { createDragPreview, type DragItemInfo } from '../components'
const logger = createLogger('ToolbarItemInteractions')
@@ -20,15 +21,23 @@ interface UseToolbarItemInteractionsProps {
export function useToolbarItemInteractions({
disabled = false,
}: UseToolbarItemInteractionsProps = {}) {
const dragPreviewRef = useRef<HTMLElement | null>(null)
/**
* Handle drag start for toolbar items
* Handle drag start for toolbar items with custom drag preview
*
* @param e - React drag event
* @param type - Block type identifier
* @param enableTriggerMode - Whether to enable trigger mode for the block
* @param dragItemInfo - Information for creating custom drag preview
*/
const handleDragStart = useCallback(
(e: React.DragEvent<HTMLElement>, type: string, enableTriggerMode = false) => {
(
e: React.DragEvent<HTMLElement>,
type: string,
enableTriggerMode = false,
dragItemInfo?: DragItemInfo
) => {
if (disabled) {
e.preventDefault()
return
@@ -43,6 +52,36 @@ export function useToolbarItemInteractions({
})
)
e.dataTransfer.effectAllowed = 'move'
// Create and set custom drag preview if item info is provided
if (dragItemInfo) {
// Clean up any existing preview first
if (dragPreviewRef.current && document.body.contains(dragPreviewRef.current)) {
document.body.removeChild(dragPreviewRef.current)
}
const preview = createDragPreview(dragItemInfo)
document.body.appendChild(preview)
dragPreviewRef.current = preview
// Force browser to render the element by triggering reflow
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
preview.offsetHeight
// Set the custom drag image with offset to center it on cursor
e.dataTransfer.setDragImage(preview, 125, 20)
// Clean up the preview element after drag ends
const cleanup = () => {
if (dragPreviewRef.current && document.body.contains(dragPreviewRef.current)) {
document.body.removeChild(dragPreviewRef.current)
dragPreviewRef.current = null
}
}
// Schedule cleanup after a short delay to ensure drag has started
setTimeout(cleanup, 100)
}
} catch (error) {
logger.error('Failed to set drag data:', error)
}

View File

@@ -2,9 +2,50 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { useToolbarStore } from '@/stores/panel-new/toolbar/store'
/**
* Minimum height for a section (in pixels)
* Minimum height for the blocks section (in pixels)
* The triggers section minimum will be calculated dynamically based on header height
*/
const MIN_SECTION_HEIGHT = 100
export const MIN_BLOCKS_SECTION_HEIGHT = 100
/**
* Calculates height boundaries and optimal height for the triggers section
*
* @param containerRef - Reference to the container element
* @param triggersContentRef - Reference to the triggers content element
* @param triggersHeaderRef - Reference to the triggers header element
* @returns Object containing minHeight, maxHeight, and optimalHeight for triggers section
*/
export function calculateTriggerHeights(
containerRef: React.RefObject<HTMLDivElement | null>,
triggersContentRef: React.RefObject<HTMLDivElement | null>,
triggersHeaderRef: React.RefObject<HTMLDivElement | null>
): { minHeight: number; maxHeight: number; optimalHeight: number } {
const defaultHeight = MIN_BLOCKS_SECTION_HEIGHT
if (!containerRef.current || !triggersHeaderRef.current) {
return { minHeight: defaultHeight, maxHeight: defaultHeight, optimalHeight: defaultHeight }
}
const parentHeight = containerRef.current.getBoundingClientRect().height
const headerHeight = triggersHeaderRef.current.getBoundingClientRect().height
// Minimum triggers height is just the header
const minHeight = headerHeight
// Calculate optimal and maximum heights based on actual content
let maxHeight = parentHeight - MIN_BLOCKS_SECTION_HEIGHT
let optimalHeight = minHeight
if (triggersContentRef.current) {
const contentHeight = triggersContentRef.current.scrollHeight
// Optimal height = header + actual content (shows all triggers without scrolling)
optimalHeight = Math.min(headerHeight + contentHeight, maxHeight)
// Maximum height shouldn't exceed full content height
maxHeight = Math.min(maxHeight, headerHeight + contentHeight)
}
return { minHeight, maxHeight, optimalHeight }
}
/**
* Props for the useToolbarResize hook
@@ -65,28 +106,15 @@ export function useToolbarResize({
const deltaY = e.clientY - startYRef.current
let newHeight = startHeightRef.current + deltaY
const parentContainer = containerRef.current
if (parentContainer) {
const parentHeight = parentContainer.getBoundingClientRect().height
// Calculate height boundaries and clamp the new height
const { minHeight, maxHeight } = calculateTriggerHeights(
containerRef,
triggersContentRef,
triggersHeaderRef
)
// Calculate maximum triggers height based on actual content
let maxTriggersHeight = parentHeight - MIN_SECTION_HEIGHT
if (triggersContentRef.current && triggersHeaderRef.current) {
const contentHeight = triggersContentRef.current.scrollHeight
const headerHeight = triggersHeaderRef.current.getBoundingClientRect().height
// Maximum height = header + content (this shows all triggers without scrolling)
const fullContentHeight = headerHeight + contentHeight
// Don't allow triggers to exceed its full content height
maxTriggersHeight = Math.min(maxTriggersHeight, fullContentHeight)
}
// Ensure minimum for triggers section and respect maximum
newHeight = Math.max(MIN_SECTION_HEIGHT, Math.min(maxTriggersHeight, newHeight))
setToolbarTriggersHeight(newHeight)
}
newHeight = Math.max(minHeight, Math.min(maxHeight, newHeight))
setToolbarTriggersHeight(newHeight)
}
const handleMouseUp = () => {
@@ -108,5 +136,6 @@ export function useToolbarResize({
return {
handleMouseDown,
isResizing,
}
}

View File

@@ -1,6 +1,6 @@
'use client'
import { useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import { Search } from 'lucide-react'
import { Button } from '@/components/emcn'
@@ -12,7 +12,8 @@ import {
import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config'
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
import type { BlockConfig } from '@/blocks/types'
import { useToolbarItemInteractions, useToolbarResize } from './hooks'
import { useToolbarStore } from '@/stores/panel-new/toolbar/store'
import { calculateTriggerHeights, useToolbarItemInteractions, useToolbarResize } from './hooks'
interface BlockItem {
name: string
@@ -102,27 +103,48 @@ function getBlocks() {
return cachedBlocks
}
interface ToolbarProps {
/** Whether the toolbar tab is currently active */
isActive?: boolean
}
/**
* Toolbar component displaying triggers and blocks in a resizable split view.
* Top half shows triggers, bottom half shows blocks, with a resizable divider between them.
*
* @param props - Component props
* @param props.isActive - Whether the toolbar tab is currently active
* @returns Toolbar view with triggers and blocks
*/
export function Toolbar() {
/**
* Threshold for determining if triggers are at minimum height (in pixels)
* Triggers slightly above header height are considered at minimum
*/
const TRIGGERS_MIN_THRESHOLD = 50
export function Toolbar({ isActive = true }: ToolbarProps) {
const containerRef = useRef<HTMLDivElement>(null)
const triggersContentRef = useRef<HTMLDivElement>(null)
const triggersHeaderRef = useRef<HTMLDivElement>(null)
const blocksHeaderRef = useRef<HTMLDivElement>(null)
const searchInputRef = useRef<HTMLInputElement>(null)
// Search state
const [isSearchActive, setIsSearchActive] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
// Toggle animation state
const [isToggling, setIsToggling] = useState(false)
// Toolbar store
const { toolbarTriggersHeight, setToolbarTriggersHeight, preSearchHeight, setPreSearchHeight } =
useToolbarStore()
// Toolbar item interactions hook
const { handleDragStart, handleItemClick } = useToolbarItemInteractions({ disabled: false })
// Toolbar resize hook
const { handleMouseDown } = useToolbarResize({
const { handleMouseDown, isResizing } = useToolbarResize({
containerRef,
triggersContentRef,
triggersHeaderRef,
@@ -132,6 +154,19 @@ export function Toolbar() {
const triggers = getTriggers()
const blocks = getBlocks()
// Determine if triggers are at minimum height (blocks are fully expanded)
const isTriggersAtMinimum = toolbarTriggersHeight <= TRIGGERS_MIN_THRESHOLD
/**
* Clear search when tab becomes inactive
*/
useEffect(() => {
if (!isActive) {
setIsSearchActive(false)
setSearchQuery('')
}
}, [isActive])
/**
* Filter items based on search query
*/
@@ -147,6 +182,58 @@ export function Toolbar() {
return blocks.filter((block) => block.name.toLowerCase().includes(query))
}, [blocks, searchQuery])
/**
* Adjust heights based on search results
* - If no triggers found, collapse triggers to minimum (expand blocks)
* - If no blocks found, expand triggers to maximum (collapse blocks)
* - If triggers are found, dynamically resize to show all filtered triggers without scrolling
*/
useEffect(() => {
const hasSearchQuery = searchQuery.trim().length > 0
const triggersCount = filteredTriggers.length
const blocksCount = filteredBlocks.length
// Save pre-search height when search starts
if (hasSearchQuery && preSearchHeight === null) {
setPreSearchHeight(toolbarTriggersHeight)
}
// Restore pre-search height when search is cleared
if (!hasSearchQuery && preSearchHeight !== null) {
setToolbarTriggersHeight(preSearchHeight)
setPreSearchHeight(null)
return
}
// Adjust heights based on search results
if (hasSearchQuery) {
const { minHeight, maxHeight, optimalHeight } = calculateTriggerHeights(
containerRef,
triggersContentRef,
triggersHeaderRef
)
if (triggersCount === 0 && blocksCount > 0) {
// No triggers found - collapse triggers to minimum (expand blocks)
setToolbarTriggersHeight(minHeight)
} else if (blocksCount === 0 && triggersCount > 0) {
// No blocks found - expand triggers to maximum (collapse blocks)
setToolbarTriggersHeight(maxHeight)
} else if (triggersCount > 0) {
// Triggers are present - use optimal height to show all filtered triggers
setToolbarTriggersHeight(optimalHeight)
}
}
}, [
searchQuery,
filteredTriggers.length,
filteredBlocks.length,
preSearchHeight,
toolbarTriggersHeight,
setToolbarTriggersHeight,
setPreSearchHeight,
])
/**
* Handle search icon click to activate search mode
*/
@@ -166,10 +253,38 @@ export function Toolbar() {
}
}
/**
* Handle blocks header click - toggle between min and max.
* If triggers are greater than minimum, collapse to minimum (just header).
* If triggers are at minimum, expand to maximum (full content height).
*/
const handleBlocksHeaderClick = useCallback(() => {
setIsToggling(true)
const { minHeight, maxHeight } = calculateTriggerHeights(
containerRef,
triggersContentRef,
triggersHeaderRef
)
// Toggle between min and max
setToolbarTriggersHeight(isTriggersAtMinimum ? maxHeight : minHeight)
}, [isTriggersAtMinimum, setToolbarTriggersHeight])
/**
* Handle transition end - reset toggling state
*/
const handleTransitionEnd = useCallback(() => {
setIsToggling(false)
}, [])
return (
<div className='flex h-full flex-col'>
{/* Header */}
<div className='flex flex-shrink-0 items-center justify-between rounded-[4px] bg-[#2A2A2A] px-[12px] py-[8px] dark:bg-[#2A2A2A]'>
<div
className='flex flex-shrink-0 cursor-pointer items-center justify-between rounded-[4px] bg-[#2A2A2A] px-[12px] py-[8px] dark:bg-[#2A2A2A]'
onClick={handleSearchClick}
>
<h2 className='font-medium text-[#FFFFFF] text-[14px] dark:text-[#FFFFFF]'>Toolbar</h2>
<div className='flex shrink-0 items-center gap-[8px]'>
{!isSearchActive ? (
@@ -197,8 +312,12 @@ export function Toolbar() {
<div ref={containerRef} className='flex flex-1 flex-col overflow-hidden pt-[0px]'>
{/* Triggers Section */}
<div
className='triggers-section flex flex-col overflow-hidden'
className={clsx(
'triggers-section flex flex-col overflow-hidden',
isToggling && !isResizing && 'transition-100ms transition-[height]'
)}
style={{ height: 'var(--toolbar-triggers-height)' }}
onTransitionEnd={handleTransitionEnd}
>
<div
ref={triggersHeaderRef}
@@ -210,14 +329,20 @@ export function Toolbar() {
<div ref={triggersContentRef} className='space-y-[4px] pb-[8px]'>
{filteredTriggers.map((trigger) => {
const Icon = trigger.icon
const isTriggerCapable = hasTriggerCapability(trigger)
return (
<div
key={trigger.type}
draggable
onDragStart={(e) =>
handleDragStart(e, trigger.type, hasTriggerCapability(trigger))
}
onClick={() => handleItemClick(trigger.type, hasTriggerCapability(trigger))}
onDragStart={(e) => {
const iconElement = e.currentTarget.querySelector('.toolbar-item-icon')
handleDragStart(e, trigger.type, isTriggerCapable, {
name: trigger.name,
bgColor: trigger.bgColor,
iconElement: iconElement as HTMLElement | null,
})
}}
onClick={() => handleItemClick(trigger.type, isTriggerCapable)}
className={clsx(
'group flex h-[25px] items-center gap-[8px] rounded-[8px] px-[5px] text-[14px]',
'cursor-pointer hover:bg-[#2C2C2C] active:cursor-grabbing dark:hover:bg-[#2C2C2C]'
@@ -230,7 +355,7 @@ export function Toolbar() {
{Icon && (
<Icon
className={clsx(
'text-white transition-transform duration-200',
'toolbar-item-icon text-white transition-transform duration-200',
'group-hover:scale-110',
'!h-[10px] !w-[10px]'
)}
@@ -262,7 +387,11 @@ export function Toolbar() {
{/* Blocks Section */}
<div className='blocks-section flex flex-1 flex-col overflow-hidden'>
<div className='px-[10px] pt-[5px] pb-[5px] font-medium text-[#E6E6E6] text-[13px] dark:text-[#E6E6E6]'>
<div
ref={blocksHeaderRef}
onClick={handleBlocksHeaderClick}
className='cursor-pointer px-[10px] pt-[5px] pb-[5px] font-medium text-[#E6E6E6] text-[13px] dark:text-[#E6E6E6]'
>
Blocks
</div>
<div className='flex-1 overflow-y-auto overflow-x-hidden px-[6px]'>
@@ -273,7 +402,14 @@ export function Toolbar() {
<div
key={block.type}
draggable
onDragStart={(e) => handleDragStart(e, block.type, false)}
onDragStart={(e) => {
const iconElement = e.currentTarget.querySelector('.toolbar-item-icon')
handleDragStart(e, block.type, false, {
name: block.name,
bgColor: block.bgColor ?? '#666666',
iconElement: iconElement as HTMLElement | null,
})
}}
onClick={() => handleItemClick(block.type, false)}
className={clsx(
'group flex h-[25px] items-center gap-[8px] rounded-[8px] px-[5.5px] text-[14px]',
@@ -287,7 +423,7 @@ export function Toolbar() {
{Icon && (
<Icon
className={clsx(
'text-white transition-transform duration-200',
'toolbar-item-icon text-white transition-transform duration-200',
'group-hover:scale-110',
'!h-[10px] !w-[10px]'
)}

View File

@@ -20,17 +20,18 @@ import {
PopoverContent,
PopoverItem,
PopoverTrigger,
Rocket,
Trash,
} from '@/components/emcn'
import { createLogger } from '@/lib/logs/console/logger'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useDeleteWorkflow } from '@/app/workspace/[workspaceId]/w/hooks'
import { useChatStore } from '@/stores/chat/store'
import { usePanelStore } from '@/stores/panel-new/store'
import type { PanelTab } from '@/stores/panel-new/types'
import { useWorkflowJsonStore } from '@/stores/workflows/json/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { Copilot, Editor, Toolbar } from './components'
import { Copilot, Deploy, Editor, Toolbar } from './components'
import { usePanelResize, useRunWorkflow, useUsageLimits } from './hooks'
const logger = createLogger('Panel')
@@ -69,7 +70,6 @@ export function Panel() {
const [isExporting, setIsExporting] = useState(false)
const [isDuplicating, setIsDuplicating] = useState(false)
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
// Hooks
const userPermissions = useUserPermissionsContext()
@@ -82,6 +82,14 @@ export function Panel() {
const { getJson } = useWorkflowJsonStore()
const { blocks } = useWorkflowStore()
// Delete workflow hook
const { isDeleting, handleDeleteWorkflow } = useDeleteWorkflow({
workspaceId,
workflowId: activeWorkflowId || '',
isActive: true,
onSuccess: () => setIsDeleteModalOpen(false),
})
// Usage limits hook
const { usageExceeded } = useUsageLimits({
context: 'user',
@@ -94,6 +102,9 @@ export function Panel() {
// Panel resize hook
const { handleMouseDown } = usePanelResize()
// Chat state
const { isChatOpen, setIsChatOpen } = useChatStore()
const currentWorkflow = activeWorkflowId ? workflows[activeWorkflowId] : null
/**
@@ -215,49 +226,6 @@ export function Panel() {
workspaceId,
])
/**
* Handles deleting the current workflow after confirmation
*/
const handleDeleteWorkflow = useCallback(async () => {
if (!activeWorkflowId || !userPermissions.canEdit || isDeleting) {
return
}
setIsDeleting(true)
try {
// Find next workflow to navigate to
const sidebarWorkflows = Object.values(workflows).filter((w) => w.workspaceId === workspaceId)
const currentIndex = sidebarWorkflows.findIndex((w) => w.id === activeWorkflowId)
let nextWorkflowId: string | null = null
if (sidebarWorkflows.length > 1) {
if (currentIndex < sidebarWorkflows.length - 1) {
nextWorkflowId = sidebarWorkflows[currentIndex + 1].id
} else if (currentIndex > 0) {
nextWorkflowId = sidebarWorkflows[currentIndex - 1].id
}
}
// Navigate first
if (nextWorkflowId) {
router.push(`/workspace/${workspaceId}/w/${nextWorkflowId}`)
} else {
router.push(`/workspace/${workspaceId}`)
}
// Then delete
const { removeWorkflow: registryRemoveWorkflow } = useWorkflowRegistry.getState()
await registryRemoveWorkflow(activeWorkflowId)
setIsDeleteModalOpen(false)
logger.info('Workflow deleted successfully')
} catch (error) {
logger.error('Error deleting workflow:', error)
} finally {
setIsDeleting(false)
}
}, [activeWorkflowId, userPermissions.canEdit, isDeleting, workflows, workspaceId, router])
// Compute run button state
const canRun = userPermissions.canRead // Running only requires read permissions
const isLoadingPermissions = userPermissions.isLoading
@@ -325,17 +293,18 @@ export function Panel() {
</PopoverItem>
</PopoverContent>
</Popover>
<Button className='h-[32px] w-[32px]'>
<Button
className='h-[32px] w-[32px]'
variant={isChatOpen ? 'active' : 'default'}
onClick={() => setIsChatOpen(!isChatOpen)}
>
<BubbleChatPreview />
</Button>
</div>
{/* Deploy and Run */}
<div className='flex gap-[4px]'>
<Button className='h-[32px] gap-[8px] px-[10px]' variant='active'>
<Rocket className='h-[13px] w-[13px]' />
Deploy
</Button>
<Deploy activeWorkflowId={activeWorkflowId} userPermissions={userPermissions} />
<Button
className='h-[32px] w-[61.5px] gap-[8px]'
variant={isExecuting ? 'active' : 'primary'}
@@ -421,7 +390,7 @@ export function Panel() {
}
data-tab-content='toolbar'
>
<Toolbar />
<Toolbar isActive={activeTab === 'toolbar'} />
</div>
</div>
</div>

View File

@@ -1,897 +0,0 @@
'use client'
import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { AlertCircle, ArrowDown, ArrowUp, File, FileText, Image, Paperclip, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import { createLogger } from '@/lib/logs/console/logger'
import {
extractBlockIdFromOutputId,
extractPathFromOutputId,
parseOutputContentSafely,
} from '@/lib/response-format'
import {
ChatMessage,
OutputSelect,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components'
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
import type { BlockLog, ExecutionResult } from '@/executor/types'
import { useExecutionStore } from '@/stores/execution/store'
import { useChatStore } from '@/stores/panel/chat/store'
import { useTerminalConsoleStore } from '@/stores/terminal'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('ChatPanel')
interface ChatFile {
id: string
name: string
size: number
type: string
file: File
}
interface ChatProps {
chatMessage: string
setChatMessage: (message: string) => void
}
export function Chat({ chatMessage, setChatMessage }: ChatProps) {
const { activeWorkflowId } = useWorkflowRegistry()
const {
messages,
addMessage,
selectedWorkflowOutputs,
setSelectedWorkflowOutput,
appendMessageContent,
finalizeMessageStream,
getConversationId,
} = useChatStore()
const { entries } = useTerminalConsoleStore()
const messagesEndRef = useRef<HTMLDivElement>(null)
const scrollAreaRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const abortControllerRef = useRef<AbortController | null>(null)
// Debug component lifecycle
useEffect(() => {
logger.info('[ChatPanel] Component mounted', { activeWorkflowId })
return () => {
logger.info('[ChatPanel] Component unmounting', { activeWorkflowId })
}
}, [])
// Prompt history state
const [promptHistory, setPromptHistory] = useState<string[]>([])
const [historyIndex, setHistoryIndex] = useState(-1)
// File upload state
const [chatFiles, setChatFiles] = useState<ChatFile[]>([])
const [isUploadingFiles, setIsUploadingFiles] = useState(false)
const [uploadErrors, setUploadErrors] = useState<string[]>([])
const [dragCounter, setDragCounter] = useState(0)
const isDragOver = dragCounter > 0
// Scroll state
const [isNearBottom, setIsNearBottom] = useState(true)
const [showScrollButton, setShowScrollButton] = useState(false)
// Use the execution store state to track if a workflow is executing
const { isExecuting } = useExecutionStore()
// Get workflow execution functionality
const { handleRunWorkflow } = useWorkflowExecution()
// Get output entries from console for the dropdown
const outputEntries = useMemo(() => {
if (!activeWorkflowId) return []
return entries.filter((entry) => entry.workflowId === activeWorkflowId && entry.output)
}, [entries, activeWorkflowId])
// Get filtered messages for current workflow
const workflowMessages = useMemo(() => {
if (!activeWorkflowId) return []
return messages
.filter((msg) => msg.workflowId === activeWorkflowId)
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())
}, [messages, activeWorkflowId])
// Memoize user messages for performance
const userMessages = useMemo(() => {
return workflowMessages
.filter((msg) => msg.type === 'user')
.map((msg) => msg.content)
.filter((content): content is string => typeof content === 'string')
}, [workflowMessages])
// Update prompt history when workflow changes
useEffect(() => {
if (!activeWorkflowId) {
setPromptHistory([])
setHistoryIndex(-1)
return
}
setPromptHistory(userMessages)
setHistoryIndex(-1)
}, [activeWorkflowId, userMessages])
// Get selected workflow outputs
const selectedOutputs = useMemo(() => {
if (!activeWorkflowId) return []
const selected = selectedWorkflowOutputs[activeWorkflowId]
if (!selected || selected.length === 0) {
// Return empty array when nothing is explicitly selected
return []
}
// Ensure we have no duplicates in the selection
const dedupedSelection = [...new Set(selected)]
// If deduplication removed items, update the store
if (dedupedSelection.length !== selected.length) {
setSelectedWorkflowOutput(activeWorkflowId, dedupedSelection)
return dedupedSelection
}
return selected
}, [selectedWorkflowOutputs, activeWorkflowId, setSelectedWorkflowOutput])
// Focus input helper with proper cleanup
const focusInput = useCallback((delay = 0) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
timeoutRef.current = setTimeout(() => {
if (inputRef.current && document.contains(inputRef.current)) {
inputRef.current.focus({ preventScroll: true })
}
}, delay)
}, [])
// Scroll to bottom function
const scrollToBottom = useCallback(() => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' })
}
}, [])
// Handle scroll events to track user position
const handleScroll = useCallback(() => {
const scrollArea = scrollAreaRef.current
if (!scrollArea) return
// Find the viewport element inside the ScrollArea
const viewport = scrollArea.querySelector('[data-radix-scroll-area-viewport]')
if (!viewport) return
const { scrollTop, scrollHeight, clientHeight } = viewport
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
// Consider "near bottom" if within 100px of bottom
const nearBottom = distanceFromBottom <= 100
setIsNearBottom(nearBottom)
setShowScrollButton(!nearBottom)
}, [])
// Cleanup on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
}
}, [])
// Attach scroll listener
useEffect(() => {
const scrollArea = scrollAreaRef.current
if (!scrollArea) return
// Find the viewport element inside the ScrollArea
const viewport = scrollArea.querySelector('[data-radix-scroll-area-viewport]')
if (!viewport) return
viewport.addEventListener('scroll', handleScroll, { passive: true })
// Also listen for scrollend event if available (for smooth scrolling)
if ('onscrollend' in viewport) {
viewport.addEventListener('scrollend', handleScroll, { passive: true })
}
// Initial scroll state check with small delay to ensure DOM is ready
setTimeout(handleScroll, 100)
return () => {
viewport.removeEventListener('scroll', handleScroll)
if ('onscrollend' in viewport) {
viewport.removeEventListener('scrollend', handleScroll)
}
}
}, [handleScroll])
// Auto-scroll to bottom when new messages are added, but only if user is near bottom
// Exception: Always scroll when sending a new message
useEffect(() => {
if (workflowMessages.length === 0) return
const lastMessage = workflowMessages[workflowMessages.length - 1]
const isNewUserMessage = lastMessage?.type === 'user'
// Always scroll for new user messages, or only if near bottom for assistant messages
if ((isNewUserMessage || isNearBottom) && messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' })
// Let the scroll event handler update the state naturally after animation completes
}
}, [workflowMessages, isNearBottom])
// Handle send message
const handleSendMessage = useCallback(async () => {
if (
(!chatMessage.trim() && chatFiles.length === 0) ||
!activeWorkflowId ||
isExecuting ||
isUploadingFiles
)
return
// Store the message being sent for reference
const sentMessage = chatMessage.trim()
// Add to prompt history if it's not already the most recent
if (
sentMessage &&
(promptHistory.length === 0 || promptHistory[promptHistory.length - 1] !== sentMessage)
) {
setPromptHistory((prev) => [...prev, sentMessage])
}
// Reset history index
setHistoryIndex(-1)
// Cancel any existing operations
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
abortControllerRef.current = new AbortController()
// Get the conversationId for this workflow before adding the message
const conversationId = getConversationId(activeWorkflowId)
let result: any = null
try {
// Read files as data URLs for display in chat (only images to avoid localStorage quota issues)
const attachmentsWithData = await Promise.all(
chatFiles.map(async (file) => {
let dataUrl = ''
// Only read images as data URLs to avoid storing large files in localStorage
if (file.type.startsWith('image/')) {
try {
dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(file.file)
})
} catch (error) {
logger.error('Error reading file as data URL:', error)
}
}
return {
id: file.id,
name: file.name,
type: file.type,
size: file.size,
dataUrl,
}
})
)
// Add user message with attachments (include all files, even non-images without dataUrl)
addMessage({
content:
sentMessage || (chatFiles.length > 0 ? `Uploaded ${chatFiles.length} file(s)` : ''),
workflowId: activeWorkflowId,
type: 'user',
attachments: attachmentsWithData,
})
// Prepare workflow input
const workflowInput: any = {
input: sentMessage,
conversationId: conversationId,
}
// Add files if any (pass the File objects directly)
if (chatFiles.length > 0) {
workflowInput.files = chatFiles.map((chatFile) => ({
name: chatFile.name,
size: chatFile.size,
type: chatFile.type,
file: chatFile.file, // Pass the actual File object
}))
workflowInput.onUploadError = (message: string) => {
setUploadErrors((prev) => [...prev, message])
}
}
// Clear input and files, refocus immediately
setChatMessage('')
setChatFiles([])
setUploadErrors([])
focusInput(10)
// Execute the workflow to generate a response
logger.info('[ChatPanel] Executing workflow with input', { workflowInput, activeWorkflowId })
result = await handleRunWorkflow(workflowInput)
logger.info('[ChatPanel] Workflow execution completed', {
hasStream: result && 'stream' in result,
})
} catch (error) {
logger.error('Error in handleSendMessage:', error)
setIsUploadingFiles(false)
// You might want to show an error message to the user here
return
}
// Check if we got a streaming response
if (result && 'stream' in result && result.stream instanceof ReadableStream) {
// Create a single message for all outputs (like chat client does)
const responseMessageId = crypto.randomUUID()
let accumulatedContent = ''
// Add initial streaming message
logger.info('[ChatPanel] Creating streaming message', { responseMessageId })
addMessage({
id: responseMessageId,
content: '',
workflowId: activeWorkflowId,
type: 'workflow',
isStreaming: true,
})
const reader = result.stream.getReader()
const decoder = new TextDecoder()
const processStream = async () => {
while (true) {
const { done, value } = await reader.read()
if (done) {
// Finalize the streaming message
finalizeMessageStream(responseMessageId)
break
}
const chunk = decoder.decode(value)
const lines = chunk.split('\n\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.substring(6)
if (data === '[DONE]') {
continue
}
try {
const json = JSON.parse(data)
const { blockId, chunk: contentChunk, event, data: eventData } = json
if (event === 'final' && eventData) {
const result = eventData as ExecutionResult
// If final result is a failure, surface error and stop
if ('success' in result && !result.success) {
// Update the existing message with error
appendMessageContent(
responseMessageId,
`${accumulatedContent ? '\n\n' : ''}Error: ${result.error || 'Workflow execution failed'}`
)
finalizeMessageStream(responseMessageId)
// Stop processing
return
}
// Final event just marks completion, content already streamed
finalizeMessageStream(responseMessageId)
} else if (blockId && contentChunk) {
// Accumulate all content into the single message
accumulatedContent += contentChunk
logger.debug('[ChatPanel] Appending chunk', {
blockId,
chunkLength: contentChunk.length,
responseMessageId,
chunk: contentChunk.substring(0, 20),
})
appendMessageContent(responseMessageId, contentChunk)
}
} catch (e) {
logger.error('Error parsing stream data:', e)
}
}
}
}
}
processStream()
.catch((e) => logger.error('Error processing stream:', e))
.finally(() => {
// Restore focus after streaming completes
focusInput(100)
})
} else if (result && 'success' in result && result.success && 'logs' in result) {
const finalOutputs: any[] = []
if (selectedOutputs?.length > 0) {
for (const outputId of selectedOutputs) {
const blockIdForOutput = extractBlockIdFromOutputId(outputId)
const path = extractPathFromOutputId(outputId, blockIdForOutput)
const log = result.logs?.find((l: BlockLog) => l.blockId === blockIdForOutput)
if (log) {
let output = log.output
if (path) {
// Parse JSON content safely
output = parseOutputContentSafely(output)
const pathParts = path.split('.')
let current = output
for (const part of pathParts) {
if (current && typeof current === 'object' && part in current) {
current = current[part]
} else {
current = undefined
break
}
}
output = current
}
if (output !== undefined) {
finalOutputs.push(output)
}
}
}
}
// Only show outputs if something was explicitly selected
// If no outputs are selected, don't show anything
// Add a new message for each resolved output
finalOutputs.forEach((output) => {
let content = ''
if (typeof output === 'string') {
content = output
} else if (output && typeof output === 'object') {
// For structured responses, pretty print the JSON
content = `\`\`\`json\n${JSON.stringify(output, null, 2)}\n\`\`\``
}
if (content) {
addMessage({
content,
workflowId: activeWorkflowId,
type: 'workflow',
})
}
})
} else if (result && 'success' in result && !result.success) {
addMessage({
content: `Error: ${'error' in result ? result.error : 'Workflow execution failed.'}`,
workflowId: activeWorkflowId,
type: 'workflow',
})
}
// Restore focus after workflow execution completes
focusInput(100)
}, [
chatMessage,
chatFiles,
isUploadingFiles,
activeWorkflowId,
isExecuting,
promptHistory,
getConversationId,
addMessage,
handleRunWorkflow,
selectedOutputs,
setSelectedWorkflowOutput,
appendMessageContent,
finalizeMessageStream,
focusInput,
setChatMessage,
setChatFiles,
setUploadErrors,
])
// Handle key press
const handleKeyPress = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSendMessage()
} else if (e.key === 'ArrowUp') {
e.preventDefault()
if (promptHistory.length > 0) {
const newIndex =
historyIndex === -1 ? promptHistory.length - 1 : Math.max(0, historyIndex - 1)
setHistoryIndex(newIndex)
setChatMessage(promptHistory[newIndex])
}
} else if (e.key === 'ArrowDown') {
e.preventDefault()
if (historyIndex >= 0) {
const newIndex = historyIndex + 1
if (newIndex >= promptHistory.length) {
setHistoryIndex(-1)
setChatMessage('')
} else {
setHistoryIndex(newIndex)
setChatMessage(promptHistory[newIndex])
}
}
}
},
[handleSendMessage, promptHistory, historyIndex, setChatMessage]
)
// Handle output selection
const handleOutputSelection = useCallback(
(values: string[]) => {
// Ensure no duplicates in selection
const dedupedValues = [...new Set(values)]
if (activeWorkflowId) {
// If array is empty, explicitly set to empty array to ensure complete reset
if (dedupedValues.length === 0) {
setSelectedWorkflowOutput(activeWorkflowId, [])
} else {
setSelectedWorkflowOutput(activeWorkflowId, dedupedValues)
}
}
},
[activeWorkflowId, setSelectedWorkflowOutput]
)
return (
<div className='flex h-full flex-col'>
{/* Output Source Dropdown */}
<div className='flex-none py-2'>
<OutputSelect
workflowId={activeWorkflowId}
selectedOutputs={selectedOutputs}
onOutputSelect={handleOutputSelection}
disabled={!activeWorkflowId}
placeholder='Select output sources'
/>
</div>
{/* Main layout with fixed heights to ensure input stays visible */}
<div className='flex flex-1 flex-col overflow-hidden'>
{/* Chat messages section - Scrollable area */}
<div className='flex-1 overflow-hidden'>
{workflowMessages.length === 0 ? (
<div className='flex h-full items-center justify-center text-muted-foreground text-sm'>
No messages yet
</div>
) : (
<div ref={scrollAreaRef} className='h-full'>
<ScrollArea className='h-full pb-2' hideScrollbar={true}>
<div>
{workflowMessages.map((message) => (
<ChatMessage key={message.id} message={message} />
))}
<div ref={messagesEndRef} />
</div>
</ScrollArea>
</div>
)}
{/* Scroll to bottom button */}
{showScrollButton && (
<div className='-translate-x-1/2 absolute bottom-20 left-1/2 z-10'>
<Button
onClick={scrollToBottom}
size='sm'
variant='outline'
className='flex items-center gap-1 rounded-full border border-gray-200 bg-white px-3 py-1 shadow-lg transition-all hover:bg-gray-50'
>
<ArrowDown className='h-3.5 w-3.5' />
<span className='sr-only'>Scroll to bottom</span>
</Button>
</div>
)}
</div>
{/* Input section - Fixed height */}
<div
className='-mt-[1px] relative flex-none pt-3 pb-4'
onDragEnter={(e) => {
e.preventDefault()
e.stopPropagation()
if (!(!activeWorkflowId || isExecuting || isUploadingFiles)) {
setDragCounter((prev) => prev + 1)
}
}}
onDragOver={(e) => {
e.preventDefault()
e.stopPropagation()
if (!(!activeWorkflowId || isExecuting || isUploadingFiles)) {
e.dataTransfer.dropEffect = 'copy'
}
}}
onDragLeave={(e) => {
e.preventDefault()
e.stopPropagation()
setDragCounter((prev) => Math.max(0, prev - 1))
}}
onDrop={(e) => {
e.preventDefault()
e.stopPropagation()
setDragCounter(0)
if (!(!activeWorkflowId || isExecuting || isUploadingFiles)) {
const droppedFiles = Array.from(e.dataTransfer.files)
if (droppedFiles.length > 0) {
const remainingSlots = Math.max(0, 15 - chatFiles.length)
const candidateFiles = droppedFiles.slice(0, remainingSlots)
const errors: string[] = []
const validNewFiles: ChatFile[] = []
for (const file of candidateFiles) {
if (file.size > 10 * 1024 * 1024) {
errors.push(`${file.name} is too large (max 10MB)`)
continue
}
const isDuplicate = chatFiles.some(
(existingFile) =>
existingFile.name === file.name && existingFile.size === file.size
)
if (isDuplicate) {
errors.push(`${file.name} already added`)
continue
}
validNewFiles.push({
id: crypto.randomUUID(),
name: file.name,
size: file.size,
type: file.type,
file,
})
}
if (errors.length > 0) {
setUploadErrors(errors)
}
if (validNewFiles.length > 0) {
setChatFiles([...chatFiles, ...validNewFiles])
setUploadErrors([]) // Clear errors when files are successfully added
}
}
}
}}
>
{/* Error messages */}
{uploadErrors.length > 0 && (
<div className='mb-2'>
<div className='rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-800/50 dark:bg-red-950/20'>
<div className='flex items-start gap-2'>
<AlertCircle className='mt-0.5 h-4 w-4 shrink-0 text-red-600 dark:text-red-400' />
<div className='flex-1'>
<div className='mb-1 font-medium text-red-800 text-sm dark:text-red-300'>
File upload error
</div>
<div className='space-y-1'>
{uploadErrors.map((err, idx) => (
<div key={idx} className='text-red-700 text-sm dark:text-red-400'>
{err}
</div>
))}
</div>
</div>
</div>
</div>
</div>
)}
{/* Combined input container matching copilot style */}
<div
className={`rounded-[8px] border border-[#E5E5E5] bg-[#FFFFFF] p-2 shadow-xs transition-all duration-200 dark:border-[#414141] dark:bg-[var(--surface-elevated)] ${
isDragOver
? 'border-[var(--brand-primary-hover-hex)] bg-purple-50/50 dark:border-[var(--brand-primary-hover-hex)] dark:bg-purple-950/20'
: ''
}`}
>
{/* File thumbnails */}
{chatFiles.length > 0 && (
<div className='mb-2 flex flex-wrap gap-1.5'>
{chatFiles.map((file) => {
const isImage = file.type.startsWith('image/')
let previewUrl: string | null = null
if (isImage) {
const blobUrl = URL.createObjectURL(file.file)
if (blobUrl.startsWith('blob:')) {
previewUrl = blobUrl
}
}
const getFileIcon = (type: string) => {
if (type.includes('pdf'))
return <FileText className='h-5 w-5 text-muted-foreground' />
if (type.startsWith('image/'))
return <Image className='h-5 w-5 text-muted-foreground' />
if (type.includes('text') || type.includes('json'))
return <FileText className='h-5 w-5 text-muted-foreground' />
return <File className='h-5 w-5 text-muted-foreground' />
}
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${Math.round((bytes / k ** i) * 10) / 10} ${sizes[i]}`
}
return (
<div
key={file.id}
className={`group relative overflow-hidden rounded-md border border-border/50 bg-muted/20 ${
previewUrl
? 'h-16 w-16'
: 'flex h-16 min-w-[120px] max-w-[200px] items-center gap-2 px-2'
}`}
>
{previewUrl ? (
<img
src={previewUrl}
alt={file.name}
className='h-full w-full object-cover'
/>
) : (
<>
<div className='flex h-8 w-8 flex-shrink-0 items-center justify-center rounded bg-background/50'>
{getFileIcon(file.type)}
</div>
<div className='min-w-0 flex-1'>
<div className='truncate font-medium text-foreground text-xs'>
{file.name}
</div>
<div className='text-[10px] text-muted-foreground'>
{formatFileSize(file.size)}
</div>
</div>
</>
)}
{/* Remove button */}
<Button
variant='ghost'
size='icon'
onClick={(e) => {
e.stopPropagation()
if (previewUrl) URL.revokeObjectURL(previewUrl)
setChatFiles(chatFiles.filter((f) => f.id !== file.id))
}}
className='absolute top-0.5 right-0.5 h-5 w-5 bg-gray-800/80 p-0 text-white opacity-0 transition-opacity hover:bg-gray-800/80 hover:text-white group-hover:opacity-100 dark:bg-black/70 dark:hover:bg-black/70 dark:hover:text-white'
>
<X className='h-3 w-3' />
</Button>
</div>
)
})}
</div>
)}
{/* Input row */}
<div className='flex items-center gap-1'>
{/* Attach button */}
<Button
variant='ghost'
size='icon'
onClick={() => document.getElementById('chat-file-input')?.click()}
disabled={
!activeWorkflowId || isExecuting || isUploadingFiles || chatFiles.length >= 15
}
className='h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground'
title='Attach files'
>
<Paperclip className='h-3 w-3' />
</Button>
{/* Hidden file input */}
<input
id='chat-file-input'
type='file'
multiple
accept='.pdf,.csv,.doc,.docx,.txt,.md,.xlsx,.xls,.html,.htm,.pptx,.ppt,.json,.xml,.rtf,image/*'
onChange={(e) => {
const files = e.target.files
if (!files) return
const newFiles: ChatFile[] = []
const errors: string[] = []
for (let i = 0; i < files.length; i++) {
if (chatFiles.length + newFiles.length >= 15) {
errors.push('Maximum 15 files allowed')
break
}
const file = files[i]
if (file.size > 10 * 1024 * 1024) {
errors.push(`${file.name} is too large (max 10MB)`)
continue
}
// Check for duplicates
const isDuplicate = chatFiles.some(
(existingFile) =>
existingFile.name === file.name && existingFile.size === file.size
)
if (isDuplicate) {
errors.push(`${file.name} already added`)
continue
}
newFiles.push({
id: crypto.randomUUID(),
name: file.name,
size: file.size,
type: file.type,
file,
})
}
if (errors.length > 0) setUploadErrors(errors)
if (newFiles.length > 0) {
setChatFiles([...chatFiles, ...newFiles])
setUploadErrors([]) // Clear errors when files are successfully added
}
e.target.value = ''
}}
className='hidden'
disabled={!activeWorkflowId || isExecuting || isUploadingFiles}
/>
{/* Text input */}
<Input
ref={inputRef}
value={chatMessage}
onChange={(e) => {
setChatMessage(e.target.value)
setHistoryIndex(-1)
}}
onKeyDown={handleKeyPress}
placeholder={isDragOver ? 'Drop files here...' : 'Type a message...'}
className='h-8 flex-1 border-0 bg-transparent font-sans text-foreground text-sm shadow-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
disabled={!activeWorkflowId || isExecuting || isUploadingFiles}
/>
{/* Send button */}
<Button
onClick={handleSendMessage}
size='icon'
disabled={
(!chatMessage.trim() && chatFiles.length === 0) ||
!activeWorkflowId ||
isExecuting ||
isUploadingFiles
}
className='h-6 w-6 shrink-0 rounded-full bg-[var(--brand-primary-hover-hex)] text-white shadow-[0_0_0_0_var(--brand-primary-hover-hex)] transition-all duration-200 hover:bg-[var(--brand-primary-hover-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
>
<ArrowUp className='h-3 w-3' />
</Button>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,234 +0,0 @@
'use client'
import { useRef, useState } from 'react'
import { File, FileText, Image, Paperclip, X } from 'lucide-react'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('ChatFileUpload')
interface ChatFile {
id: string
name: string
size: number
type: string
file: File
}
interface ChatFileUploadProps {
files: ChatFile[]
onFilesChange: (files: ChatFile[]) => void
maxFiles?: number
maxSize?: number // in MB
acceptedTypes?: string[]
disabled?: boolean
onError?: (errors: string[]) => void
}
export function ChatFileUpload({
files,
onFilesChange,
maxFiles = 15,
maxSize = 10,
acceptedTypes = ['*'],
disabled = false,
onError,
}: ChatFileUploadProps) {
const [isDragOver, setIsDragOver] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const handleFileSelect = (selectedFiles: FileList | null) => {
if (!selectedFiles || disabled) return
const newFiles: ChatFile[] = []
const errors: string[] = []
for (let i = 0; i < selectedFiles.length; i++) {
const file = selectedFiles[i]
// Check file count limit
if (files.length + newFiles.length >= maxFiles) {
errors.push(`Maximum ${maxFiles} files allowed`)
break
}
// Check file size
if (file.size > maxSize * 1024 * 1024) {
errors.push(`${file.name} is too large (max ${maxSize}MB)`)
continue
}
// Check file type if specified
if (acceptedTypes.length > 0 && !acceptedTypes.includes('*')) {
const isAccepted = acceptedTypes.some((type) => {
if (type.endsWith('/*')) {
return file.type.startsWith(type.slice(0, -1))
}
return file.type === type
})
if (!isAccepted) {
errors.push(`${file.name} type not supported`)
continue
}
}
// Check for duplicates
const isDuplicate = files.some(
(existingFile) => existingFile.name === file.name && existingFile.size === file.size
)
if (isDuplicate) {
errors.push(`${file.name} already added`)
continue
}
newFiles.push({
id: crypto.randomUUID(),
name: file.name,
size: file.size,
type: file.type,
file,
})
}
if (errors.length > 0) {
logger.warn('File upload errors:', errors)
onError?.(errors)
}
if (newFiles.length > 0) {
onFilesChange([...files, ...newFiles])
}
}
const handleRemoveFile = (fileId: string) => {
onFilesChange(files.filter((f) => f.id !== fileId))
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (!disabled) {
setIsDragOver(true)
e.dataTransfer.dropEffect = 'copy'
}
}
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (!disabled) {
setIsDragOver(true)
}
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragOver(false)
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragOver(false)
if (!disabled) {
handleFileSelect(e.dataTransfer.files)
}
}
const getFileIcon = (type: string) => {
if (type.startsWith('image/')) return <Image className='h-4 w-4' />
if (type.includes('text') || type.includes('json')) return <FileText className='h-4 w-4' />
return <File className='h-4 w-4' />
}
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${Number.parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`
}
return (
<div className='space-y-2'>
{/* File Upload Button */}
<div className='flex items-center gap-2'>
<button
type='button'
onClick={() => fileInputRef.current?.click()}
disabled={disabled || files.length >= maxFiles}
className='flex items-center gap-1 rounded-md px-2 py-1 text-gray-600 text-sm transition-colors hover:bg-gray-100 hover:text-gray-800 disabled:cursor-not-allowed disabled:opacity-50'
title={files.length >= maxFiles ? `Maximum ${maxFiles} files` : 'Attach files'}
>
<Paperclip className='h-4 w-4' />
<span className='hidden sm:inline'>Attach</span>
</button>
<input
ref={fileInputRef}
type='file'
multiple
onChange={(e) => {
handleFileSelect(e.target.files)
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}}
className='hidden'
accept={acceptedTypes.join(',')}
disabled={disabled}
/>
{files.length > 0 && (
<span className='text-gray-500 text-xs'>
{files.length}/{maxFiles} files
</span>
)}
</div>
{/* File List */}
{files.length > 0 && (
<div className='space-y-1'>
{files.map((file) => (
<div
key={file.id}
className='flex items-center gap-2 rounded-md bg-gray-50 px-2 py-1 text-sm dark:bg-gray-800'
>
{getFileIcon(file.type)}
<span className='flex-1 truncate dark:text-white' title={file.name}>
{file.name}
</span>
<span className='text-gray-500 text-xs dark:text-gray-400'>
{formatFileSize(file.size)}
</span>
<button
type='button'
onClick={() => handleRemoveFile(file.id)}
className='p-0.5 text-gray-400 transition-colors hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400'
title='Remove file'
>
<X className='h-3 w-3' />
</button>
</div>
))}
</div>
)}
{/* Drag and Drop Area (when dragging) */}
{isDragOver && (
<div
className='fixed inset-0 z-50 flex items-center justify-center border-2 border-blue-500 border-dashed bg-blue-500/10'
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<div className='rounded-lg bg-white p-4 shadow-lg'>
<p className='font-medium text-blue-600'>Drop files here to attach</p>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,190 +0,0 @@
import { useMemo } from 'react'
import { File, FileText, Image as ImageIcon } from 'lucide-react'
interface ChatAttachment {
id: string
name: string
type: string
dataUrl: string
size?: number
}
interface ChatMessageProps {
message: {
id: string
content: any
timestamp: string | Date
type: 'user' | 'workflow'
isStreaming?: boolean
attachments?: ChatAttachment[]
}
}
// Maximum character length for a word before it's broken up
const MAX_WORD_LENGTH = 25
const WordWrap = ({ text }: { text: string }) => {
if (!text) return null
// Split text into words, keeping spaces and punctuation
const parts = text.split(/(\s+)/g)
return (
<>
{parts.map((part, index) => {
// If the part is whitespace or shorter than the max length, render it as is
if (part.match(/\s+/) || part.length <= MAX_WORD_LENGTH) {
return <span key={index}>{part}</span>
}
// For long words, break them up into chunks
const chunks = []
for (let i = 0; i < part.length; i += MAX_WORD_LENGTH) {
chunks.push(part.substring(i, i + MAX_WORD_LENGTH))
}
return (
<span key={index} className='break-all'>
{chunks.map((chunk, chunkIndex) => (
<span key={chunkIndex}>{chunk}</span>
))}
</span>
)
})}
</>
)
}
export function ChatMessage({ message }: ChatMessageProps) {
// Format message content as text
const formattedContent = useMemo(() => {
if (typeof message.content === 'object' && message.content !== null) {
return JSON.stringify(message.content, null, 2)
}
return String(message.content || '')
}, [message.content])
// Render human messages as chat bubbles
if (message.type === 'user') {
return (
<div className='w-full py-2'>
{/* File attachments displayed above the message, completely separate from message box */}
{message.attachments && message.attachments.length > 0 && (
<div className='mb-1 flex justify-end'>
<div className='flex flex-wrap gap-1.5'>
{message.attachments.map((attachment) => {
const isImage = attachment.type.startsWith('image/')
const getFileIcon = (type: string) => {
if (type.includes('pdf'))
return <FileText className='h-5 w-5 text-muted-foreground' />
if (type.startsWith('image/'))
return <ImageIcon className='h-5 w-5 text-muted-foreground' />
if (type.includes('text') || type.includes('json'))
return <FileText className='h-5 w-5 text-muted-foreground' />
return <File className='h-5 w-5 text-muted-foreground' />
}
const formatFileSize = (bytes?: number) => {
if (!bytes || bytes === 0) return ''
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${Math.round((bytes / k ** i) * 10) / 10} ${sizes[i]}`
}
return (
<div
key={attachment.id}
className={`relative overflow-hidden rounded-md border border-border/50 bg-muted/20 ${
attachment.dataUrl?.trim() && attachment.dataUrl.startsWith('data:')
? 'cursor-pointer'
: ''
} ${isImage ? 'h-16 w-16' : 'flex h-16 min-w-[120px] max-w-[200px] items-center gap-2 px-2'}`}
onClick={(e) => {
const validDataUrl = attachment.dataUrl?.trim()
if (validDataUrl?.startsWith('data:')) {
e.preventDefault()
e.stopPropagation()
const newWindow = window.open('', '_blank')
if (newWindow) {
newWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<title>${attachment.name}</title>
<style>
body { margin: 0; display: flex; justify-content: center; align-items: center; min-height: 100vh; background: #000; }
img { max-width: 100%; max-height: 100vh; object-fit: contain; }
</style>
</head>
<body>
<img src="${validDataUrl}" alt="${attachment.name}" />
</body>
</html>
`)
newWindow.document.close()
}
}
}}
>
{isImage &&
attachment.dataUrl?.trim() &&
attachment.dataUrl.startsWith('data:') ? (
<img
src={attachment.dataUrl}
alt={attachment.name}
className='h-full w-full object-cover'
/>
) : (
<>
<div className='flex h-8 w-8 flex-shrink-0 items-center justify-center rounded bg-background/50'>
{getFileIcon(attachment.type)}
</div>
<div className='min-w-0 flex-1'>
<div className='truncate font-medium text-foreground text-xs'>
{attachment.name}
</div>
{attachment.size && (
<div className='text-[10px] text-muted-foreground'>
{formatFileSize(attachment.size)}
</div>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)}
{/* Only render message bubble if there's actual text content (not just file count message) */}
{formattedContent && !formattedContent.startsWith('Uploaded') && (
<div className='flex justify-end'>
<div className='max-w-[80%]'>
<div className='rounded-[10px] bg-secondary px-3 py-2'>
<div className='whitespace-pre-wrap break-words font-normal text-foreground text-sm leading-normal'>
<WordWrap text={formattedContent} />
</div>
</div>
</div>
</div>
)}
</div>
)
}
// Render agent/workflow messages as full-width text
return (
<div className='w-full py-2 pl-[2px]'>
<div className='overflow-wrap-anywhere relative whitespace-normal break-normal font-normal text-sm leading-normal'>
<div className='whitespace-pre-wrap break-words text-foreground'>
<WordWrap text={formattedContent} />
{message.isStreaming && (
<span className='ml-1 inline-block h-4 w-2 animate-pulse bg-gray-400 dark:bg-gray-300' />
)}
</div>
</div>
</div>
)
}

View File

@@ -1,515 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Check, ChevronDown } from 'lucide-react'
import { createPortal } from 'react-dom'
import { extractFieldsFromSchema, parseResponseFormatSafely } from '@/lib/response-format'
import { cn } from '@/lib/utils'
import { getBlock } from '@/blocks'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
interface OutputSelectProps {
workflowId: string | null
selectedOutputs: string[]
onOutputSelect: (outputIds: string[]) => void
disabled?: boolean
placeholder?: string
valueMode?: 'id' | 'label'
}
export function OutputSelect({
workflowId,
selectedOutputs = [],
onOutputSelect,
disabled = false,
placeholder = 'Select output sources',
valueMode = 'id',
}: OutputSelectProps) {
const [isOutputDropdownOpen, setIsOutputDropdownOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
const portalRef = useRef<HTMLDivElement>(null)
const [portalStyle, setPortalStyle] = useState<{
top: number
left: number
width: number
height: number
} | null>(null)
const blocks = useWorkflowStore((state) => state.blocks)
const { isShowingDiff, isDiffReady, diffWorkflow } = useWorkflowDiffStore()
// Find all scrollable ancestors so the dropdown can stay pinned on scroll
const getScrollableAncestors = (el: HTMLElement | null): (HTMLElement | Window)[] => {
const ancestors: (HTMLElement | Window)[] = []
let node: HTMLElement | null = el?.parentElement || null
const isScrollable = (elem: HTMLElement) => {
const style = window.getComputedStyle(elem)
const overflowY = style.overflowY
const overflow = style.overflow
const hasScroll = elem.scrollHeight > elem.clientHeight
return (
hasScroll &&
(overflowY === 'auto' ||
overflowY === 'scroll' ||
overflow === 'auto' ||
overflow === 'scroll')
)
}
while (node && node !== document.body) {
if (isScrollable(node)) ancestors.push(node)
node = node.parentElement
}
// Always include window as a fallback
ancestors.push(window)
return ancestors
}
// Track subblock store state to ensure proper reactivity
const subBlockValues = useSubBlockStore((state) =>
workflowId ? state.workflowValues[workflowId] : null
)
// Use diff blocks when in diff mode AND diff is ready, otherwise use main blocks
const workflowBlocks = isShowingDiff && isDiffReady && diffWorkflow ? diffWorkflow.blocks : blocks
// Get workflow outputs for the dropdown
const workflowOutputs = useMemo(() => {
const outputs: {
id: string
label: string
blockId: string
blockName: string
blockType: string
path: string
}[] = []
if (!workflowId) return outputs
// Check if workflowBlocks is defined
if (!workflowBlocks || typeof workflowBlocks !== 'object') {
return outputs
}
// Check if we actually have blocks to process
const blockArray = Object.values(workflowBlocks)
if (blockArray.length === 0) {
return outputs
}
// Process blocks to extract outputs
blockArray.forEach((block) => {
// Skip starter/start blocks
if (block.type === 'starter') return
// Add defensive check to ensure block exists and has required properties
if (!block || !block.id || !block.type) {
return
}
// Add defensive check to ensure block.name exists and is a string
const blockName =
block.name && typeof block.name === 'string'
? block.name.replace(/\s+/g, '').toLowerCase()
: `block-${block.id}`
// Get block configuration from registry to get outputs
const blockConfig = getBlock(block.type)
// Check for custom response format first
// In diff mode, get value from diff blocks; otherwise use store
const responseFormatValue =
isShowingDiff && isDiffReady && diffWorkflow
? diffWorkflow.blocks[block.id]?.subBlocks?.responseFormat?.value
: subBlockValues?.[block.id]?.responseFormat
const responseFormat = parseResponseFormatSafely(responseFormatValue, block.id)
let outputsToProcess: Record<string, any> = {}
if (responseFormat) {
// Use custom schema properties if response format is specified
const schemaFields = extractFieldsFromSchema(responseFormat)
if (schemaFields.length > 0) {
// Convert schema fields to output structure
schemaFields.forEach((field) => {
outputsToProcess[field.name] = { type: field.type }
})
} else {
// Fallback to block config outputs if schema extraction failed
outputsToProcess = blockConfig?.outputs || {}
}
} else {
// Use block config outputs instead of block.outputs
outputsToProcess = blockConfig?.outputs || {}
}
// Add response outputs
if (Object.keys(outputsToProcess).length > 0) {
const addOutput = (path: string, outputObj: any, prefix = '') => {
const fullPath = prefix ? `${prefix}.${path}` : path
// If not an object or is null, treat as leaf node
if (typeof outputObj !== 'object' || outputObj === null) {
const output = {
id: `${block.id}_${fullPath}`,
label: `${blockName}.${fullPath}`,
blockId: block.id,
blockName: block.name || `Block ${block.id}`,
blockType: block.type,
path: fullPath,
}
outputs.push(output)
return
}
// If has 'type' property, treat as schema definition (leaf node)
if ('type' in outputObj && typeof outputObj.type === 'string') {
const output = {
id: `${block.id}_${fullPath}`,
label: `${blockName}.${fullPath}`,
blockId: block.id,
blockName: block.name || `Block ${block.id}`,
blockType: block.type,
path: fullPath,
}
outputs.push(output)
return
}
// For objects without type, recursively add each property
if (!Array.isArray(outputObj)) {
Object.entries(outputObj).forEach(([key, value]) => {
addOutput(key, value, fullPath)
})
} else {
// For arrays, treat as leaf node
outputs.push({
id: `${block.id}_${fullPath}`,
label: `${blockName}.${fullPath}`,
blockId: block.id,
blockName: block.name || `Block ${block.id}`,
blockType: block.type,
path: fullPath,
})
}
}
// Process all output properties directly (flattened structure)
Object.entries(outputsToProcess).forEach(([key, value]) => {
addOutput(key, value)
})
}
})
return outputs
}, [workflowBlocks, workflowId, isShowingDiff, isDiffReady, diffWorkflow, blocks, subBlockValues])
// Utility to check selected by id or label
const isSelectedValue = (o: { id: string; label: string }) =>
selectedOutputs.includes(o.id) || selectedOutputs.includes(o.label)
// Get selected outputs display text
const selectedOutputsDisplayText = useMemo(() => {
if (!selectedOutputs || selectedOutputs.length === 0) {
return placeholder
}
// Ensure all selected outputs exist in the workflowOutputs array by id or label
const validOutputs = selectedOutputs.filter((val) =>
workflowOutputs.some((o) => o.id === val || o.label === val)
)
if (validOutputs.length === 0) {
return placeholder
}
if (validOutputs.length === 1) {
const output = workflowOutputs.find(
(o) => o.id === validOutputs[0] || o.label === validOutputs[0]
)
if (output) {
return output.label
}
return placeholder
}
return `${validOutputs.length} outputs selected`
}, [selectedOutputs, workflowOutputs, placeholder])
// Get first selected output info for display icon
const selectedOutputInfo = useMemo(() => {
if (!selectedOutputs || selectedOutputs.length === 0) return null
const validOutputs = selectedOutputs.filter((val) =>
workflowOutputs.some((o) => o.id === val || o.label === val)
)
if (validOutputs.length === 0) return null
const output = workflowOutputs.find(
(o) => o.id === validOutputs[0] || o.label === validOutputs[0]
)
if (!output) return null
return {
blockName: output.blockName,
blockId: output.blockId,
blockType: output.blockType,
path: output.path,
}
}, [selectedOutputs, workflowOutputs])
// Group output options by block
const groupedOutputs = useMemo(() => {
const groups: Record<string, typeof workflowOutputs> = {}
const blockDistances: Record<string, number> = {}
const edges = useWorkflowStore.getState().edges
// Find the starter block
const starterBlock = Object.values(blocks).find((block) => block.type === 'starter')
const starterBlockId = starterBlock?.id
// Calculate distances from starter block if it exists
if (starterBlockId) {
// Build an adjacency list for faster traversal
const adjList: Record<string, string[]> = {}
for (const edge of edges) {
if (!adjList[edge.source]) {
adjList[edge.source] = []
}
adjList[edge.source].push(edge.target)
}
// BFS to find distances from starter block
const visited = new Set<string>()
const queue: [string, number][] = [[starterBlockId, 0]] // [nodeId, distance]
while (queue.length > 0) {
const [currentNodeId, distance] = queue.shift()!
if (visited.has(currentNodeId)) continue
visited.add(currentNodeId)
blockDistances[currentNodeId] = distance
// Get all outgoing edges from the adjacency list
const outgoingNodeIds = adjList[currentNodeId] || []
// Add all target nodes to the queue with incremented distance
for (const targetId of outgoingNodeIds) {
queue.push([targetId, distance + 1])
}
}
}
// Group by block name
workflowOutputs.forEach((output) => {
if (!groups[output.blockName]) {
groups[output.blockName] = []
}
groups[output.blockName].push(output)
})
// Convert to array of [blockName, outputs] for sorting
const groupsArray = Object.entries(groups).map(([blockName, outputs]) => {
// Find the blockId for this group (using the first output's blockId)
const blockId = outputs[0]?.blockId
// Get the distance for this block (or default to 0 if not found)
const distance = blockId ? blockDistances[blockId] || 0 : 0
return { blockName, outputs, distance }
})
// Sort by distance (descending - furthest first)
groupsArray.sort((a, b) => b.distance - a.distance)
// Convert back to record
return groupsArray.reduce(
(acc, { blockName, outputs }) => {
acc[blockName] = outputs
return acc
},
{} as Record<string, typeof workflowOutputs>
)
}, [workflowOutputs, blocks])
// Get block color for an output
const getOutputColor = (blockId: string, blockType: string) => {
// Try to get the block's color from its configuration
const blockConfig = getBlock(blockType)
return blockConfig?.bgColor || '#2F55FF' // Default blue if not found
}
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node
const insideTrigger = dropdownRef.current?.contains(target)
const insidePortal = portalRef.current?.contains(target)
if (!insideTrigger && !insidePortal) {
setIsOutputDropdownOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [])
// Position the portal dropdown relative to the trigger button
useEffect(() => {
const updatePosition = () => {
if (!isOutputDropdownOpen || !dropdownRef.current) return
const rect = dropdownRef.current.getBoundingClientRect()
const available = Math.max(140, window.innerHeight - rect.bottom - 12)
const height = Math.min(available, 240)
setPortalStyle({ top: rect.bottom + 4, left: rect.left, width: rect.width, height })
}
let attachedScrollTargets: (HTMLElement | Window)[] = []
let rafId: number | null = null
if (isOutputDropdownOpen) {
updatePosition()
window.addEventListener('resize', updatePosition)
attachedScrollTargets = getScrollableAncestors(dropdownRef.current)
attachedScrollTargets.forEach((target) =>
target.addEventListener('scroll', updatePosition, { passive: true })
)
const loop = () => {
updatePosition()
rafId = requestAnimationFrame(loop)
}
rafId = requestAnimationFrame(loop)
}
return () => {
window.removeEventListener('resize', updatePosition)
attachedScrollTargets.forEach((target) =>
target.removeEventListener('scroll', updatePosition)
)
if (rafId) cancelAnimationFrame(rafId)
}
}, [isOutputDropdownOpen])
// Handle output selection - toggle selection
const handleOutputSelection = (value: string) => {
const emittedValue =
valueMode === 'label' ? value : workflowOutputs.find((o) => o.label === value)?.id || value
let newSelectedOutputs: string[]
const index = selectedOutputs.indexOf(emittedValue)
if (index === -1) {
newSelectedOutputs = [...new Set([...selectedOutputs, emittedValue])]
} else {
newSelectedOutputs = selectedOutputs.filter((id) => id !== emittedValue)
}
onOutputSelect(newSelectedOutputs)
}
return (
<div className='relative w-full' ref={dropdownRef}>
<button
type='button'
onClick={() => setIsOutputDropdownOpen(!isOutputDropdownOpen)}
className={`flex h-9 w-full items-center justify-between rounded-[8px] border px-3 py-1.5 font-normal text-sm shadow-xs transition-colors ${
isOutputDropdownOpen
? 'border-[#E5E5E5] bg-[#FFFFFF] text-muted-foreground dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
: 'border-[#E5E5E5] bg-[#FFFFFF] text-muted-foreground hover:text-muted-foreground dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
}`}
disabled={workflowOutputs.length === 0 || disabled}
>
{selectedOutputInfo ? (
<div className='flex w-[calc(100%-24px)] items-center gap-2 overflow-hidden text-left'>
<div
className='flex h-5 w-5 flex-shrink-0 items-center justify-center rounded'
style={{
backgroundColor: getOutputColor(
selectedOutputInfo.blockId,
selectedOutputInfo.blockType
),
}}
>
<span className='h-3 w-3 font-bold text-white text-xs'>
{selectedOutputInfo.blockName.charAt(0).toUpperCase()}
</span>
</div>
<span className='truncate text-left'>{selectedOutputsDisplayText}</span>
</div>
) : (
<span className='w-[calc(100%-24px)] truncate text-left'>
{selectedOutputsDisplayText}
</span>
)}
<ChevronDown
className={`ml-1 h-4 w-4 flex-shrink-0 transition-transform ${isOutputDropdownOpen ? 'rotate-180' : ''}`}
/>
</button>
{isOutputDropdownOpen &&
workflowOutputs.length > 0 &&
portalStyle &&
createPortal(
<div
ref={portalRef}
style={{
position: 'fixed',
top: portalStyle.top - 1, // overlap border by 1px to avoid visible gap
left: portalStyle.left,
width: portalStyle.width,
zIndex: 2147483647,
pointerEvents: 'auto',
}}
className='mt-0'
data-rs-scroll-lock-ignore
>
<div className='overflow-hidden rounded-[8px] border border-[#E5E5E5] bg-[#FFFFFF] pt-1 shadow-xs dark:border-[#414141] dark:bg-[var(--surface-elevated)]'>
<div
className='overflow-y-auto overscroll-contain'
style={{ maxHeight: portalStyle.height }}
onWheel={(e) => {
// Keep wheel scroll inside the dropdown and avoid dialog/body scroll locks
e.stopPropagation()
}}
>
{Object.entries(groupedOutputs).map(([blockName, outputs]) => (
<div key={blockName}>
<div className='border-[#E5E5E5] border-t px-3 pt-1.5 pb-0.5 font-normal text-muted-foreground text-xs first:border-t-0 dark:border-[#414141]'>
{blockName}
</div>
<div>
{outputs.map((output) => (
<button
type='button'
key={output.id}
onClick={() => handleOutputSelection(output.label)}
className={cn(
'flex w-full items-center gap-2 px-3 py-1.5 text-left font-normal text-sm',
'hover:bg-accent hover:text-accent-foreground',
'focus:bg-accent focus:text-accent-foreground focus:outline-none'
)}
>
<div
className='flex h-5 w-5 flex-shrink-0 items-center justify-center rounded'
style={{
backgroundColor: getOutputColor(output.blockId, output.blockType),
}}
>
<span className='h-3 w-3 font-bold text-white text-xs'>
{blockName.charAt(0).toUpperCase()}
</span>
</div>
<span className='flex-1 truncate'>{output.path}</span>
{isSelectedValue(output) && (
<Check className='h-4 w-4 flex-shrink-0 text-muted-foreground' />
)}
</button>
))}
</div>
</div>
))}
</div>
</div>
</div>,
document.body
)}
</div>
)
}

View File

@@ -1,4 +1,4 @@
export { Chat } from '../../chat/chat'
export { Copilot } from '../../panel-new/components/copilot/copilot'
export { Chat } from './chat/chat'
export { Console } from './console/console'
export { Variables } from './variables/variables'

View File

@@ -10,13 +10,13 @@ import {
} from '@/components/ui/dropdown-menu'
import { LandingPromptStorage } from '@/lib/browser-storage'
import { createLogger } from '@/lib/logs/console/logger'
import { useChatStore } from '@/stores/panel/chat/store'
import { useChatStore } from '@/stores/chat/store'
import { usePanelStore } from '@/stores/panel/store'
import { useCopilotStore } from '@/stores/panel-new/copilot/store'
import { useTerminalConsoleStore } from '@/stores/terminal'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { Copilot } from '../panel-new/components/copilot/copilot'
import { Chat } from './components/chat/chat'
// import { Chat } from './components/chat/chat'
import { Console } from './components/console/console'
import { Variables } from './components/variables/variables'
@@ -605,7 +605,7 @@ export function Panel() {
<div className='flex-1 overflow-hidden px-3'>
{/* Keep all tabs mounted but hidden to preserve state and animations */}
<div style={{ display: activeTab === 'chat' ? 'block' : 'none', height: '100%' }}>
<Chat chatMessage={chatMessage} setChatMessage={setChatMessage} />
{/* <Chat chatMessage={chatMessage} setChatMessage={setChatMessage} /> */}
</div>
<div style={{ display: activeTab === 'console' ? 'block' : 'none', height: '100%' }}>
<Console panelWidth={panelWidth} />

View File

@@ -3,9 +3,10 @@ import { useTerminalStore } from '@/stores/terminal'
/**
* Constants for output panel sizing
* Must match MIN_OUTPUT_PANEL_WIDTH_PX and BLOCK_COLUMN_WIDTH_PX in terminal.tsx
*/
const MIN_WIDTH = 300
const BLOCK_COLUMN_WIDTH = 200 // Must match COLUMN_WIDTHS.BLOCK in terminal.tsx
const BLOCK_COLUMN_WIDTH = 240
/**
* Custom hook to handle output panel horizontal resize functionality.

View File

@@ -5,12 +5,12 @@ import { useTerminalStore } from '@/stores/terminal'
* Constants for terminal sizing
*/
const MIN_HEIGHT = 30
const MAX_HEIGHT_PERCENTAGE = 0.5 // 50% of viewport height
const MAX_HEIGHT_PERCENTAGE = 0.7 // 70% of viewport height
/**
* Custom hook to handle terminal resize functionality.
* Manages mouse events for resizing and enforces min/max height constraints.
* Maximum height is capped at 50% of the viewport height for optimal layout.
* Maximum height is capped at 70% of the viewport height for optimal layout.
*
* @returns Resize state and handlers
*/

View File

@@ -2,7 +2,15 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import { Check, ChevronDown, Clipboard, MoreHorizontal, RepeatIcon, SplitIcon } from 'lucide-react'
import {
Check,
ChevronDown,
Clipboard,
MoreHorizontal,
RepeatIcon,
SplitIcon,
Trash2,
} from 'lucide-react'
import {
Button,
Code,
@@ -30,20 +38,40 @@ const NEAR_MIN_THRESHOLD = 40
const DEFAULT_EXPANDED_HEIGHT = 300
/**
* Column width constants
* Column width constants - numeric values for calculations
*/
const BLOCK_COLUMN_WIDTH_PX = 240
const MIN_OUTPUT_PANEL_WIDTH_PX = 300
/**
* Column width constants - Tailwind classes for styling
*/
const COLUMN_WIDTHS = {
BLOCK: 'w-[200px]',
BLOCK: 'w-[240px]',
STATUS: 'w-[120px]',
DURATION: 'w-[120px]',
RUN_ID: 'w-[120px]',
TIMESTAMP: 'w-[120px]',
OUTPUT_PANEL: 'w-[400px]',
} as const
/**
* Color palette for run IDs - matching code syntax highlighting colors
*/
const RUN_ID_COLORS = [
{ text: '#4ADE80' }, // Green
{ text: '#F472B6' }, // Pink
{ text: '#60C5FF' }, // Blue
{ text: '#FF8533' }, // Orange
{ text: '#C084FC' }, // Purple
{ text: '#FCD34D' }, // Yellow
] as const
/**
* Shared styling constants
*/
const HEADER_TEXT_CLASS = 'font-medium text-[#8D8D8D] text-[13px] dark:text-[#8D8D8D]'
const ROW_TEXT_CLASS = 'font-medium text-[#D2D2D2] text-[13px] dark:text-[#D2D2D2]'
const HEADER_TEXT_CLASS = 'font-medium text-[#AEAEAE] text-[12px] dark:text-[#AEAEAE]'
const ROW_TEXT_CLASS = 'font-medium text-[#D2D2D2] text-[12px] dark:text-[#D2D2D2]'
const COLUMN_BASE_CLASS = 'flex-shrink-0'
/**
@@ -117,6 +145,43 @@ const ToggleButton = ({
</Button>
)
/**
* Formats timestamp to H:MM:SS AM/PM TZ format
*/
const formatTimestamp = (timestamp: string): string => {
const date = new Date(timestamp)
const fullString = date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
hour12: true,
timeZoneName: 'short',
})
// Format: "5:54:55 PM PST" - return as is
return fullString
}
/**
* Truncates execution ID for display as run ID
*/
const formatRunId = (executionId?: string): string => {
if (!executionId) return '-'
return executionId.slice(0, 8)
}
/**
* Gets color for a run ID based on its index in the execution ID order map
*/
const getRunIdColor = (
executionId: string | undefined,
executionIdOrderMap: Map<string, number>
) => {
if (!executionId) return null
const colorIndex = executionIdOrderMap.get(executionId)
if (colorIndex === undefined) return null
return RUN_ID_COLORS[colorIndex % RUN_ID_COLORS.length]
}
/**
* Terminal component with resizable height that persists across page refreshes.
*
@@ -141,12 +206,14 @@ export function Terminal() {
setHasHydrated,
} = useTerminalStore()
const entries = useTerminalConsoleStore((state) => state.entries)
const clearWorkflowConsole = useTerminalConsoleStore((state) => state.clearWorkflowConsole)
const { activeWorkflowId } = useWorkflowRegistry()
const [selectedEntry, setSelectedEntry] = useState<ConsoleEntry | null>(null)
const [isToggling, setIsToggling] = useState(false)
const [displayPopoverOpen, setDisplayPopoverOpen] = useState(false)
const [wrapText, setWrapText] = useState(true)
const [showCopySuccess, setShowCopySuccess] = useState(false)
const [showInput, setShowInput] = useState(false)
// Terminal resize hooks
const { handleMouseDown } = useTerminalResize()
@@ -162,6 +229,54 @@ export function Terminal() {
return entries.filter((entry) => entry.workflowId === activeWorkflowId)
}, [entries, activeWorkflowId])
/**
* Create stable execution ID to color index mapping based on order of first appearance.
* Once an execution ID is assigned a color index, it keeps that index.
*/
const executionIdOrderMap = useMemo(() => {
const orderMap = new Map<string, number>()
let colorIndex = 0
// Process entries in reverse order (oldest first) since entries array is newest-first
for (let i = filteredEntries.length - 1; i >= 0; i--) {
const entry = filteredEntries[i]
if (entry.executionId && !orderMap.has(entry.executionId)) {
orderMap.set(entry.executionId, colorIndex)
colorIndex++
}
}
return orderMap
}, [filteredEntries])
/**
* Check if input data exists for selected entry
*/
const hasInputData = useMemo(() => {
if (!selectedEntry?.input) return false
return typeof selectedEntry.input === 'object'
? Object.keys(selectedEntry.input).length > 0
: true
}, [selectedEntry])
/**
* Check if this is a function block with code input
*/
const shouldShowCodeDisplay = useMemo(() => {
if (!selectedEntry || !showInput || selectedEntry.blockType !== 'function') return false
const input = selectedEntry.input
return typeof input === 'object' && input && 'code' in input && typeof input.code === 'string'
}, [selectedEntry, showInput])
/**
* Get the data to display in the output panel
*/
const outputData = useMemo(() => {
if (!selectedEntry) return null
if (selectedEntry.error) return selectedEntry.error
return showInput ? selectedEntry.input : selectedEntry.output
}, [selectedEntry, showInput])
/**
* Handle row click - toggle if clicking same entry
*/
@@ -178,7 +293,7 @@ export function Terminal() {
if (isExpanded) {
setTerminalHeight(MIN_HEIGHT)
} else {
const maxHeight = window.innerHeight * 0.5
const maxHeight = window.innerHeight * 0.7
const targetHeight = Math.min(DEFAULT_EXPANDED_HEIGHT, maxHeight)
setTerminalHeight(targetHeight)
}
@@ -197,12 +312,27 @@ export function Terminal() {
const handleCopy = useCallback(() => {
if (!selectedEntry) return
const dataToCopy = selectedEntry.error || selectedEntry.output
const textToCopy = JSON.stringify(dataToCopy, null, 2)
const textToCopy = shouldShowCodeDisplay
? selectedEntry.input.code
: JSON.stringify(outputData, null, 2)
navigator.clipboard.writeText(textToCopy)
setShowCopySuccess(true)
}, [selectedEntry])
}, [selectedEntry, outputData, shouldShowCodeDisplay])
/**
* Handle clear console for current workflow
*/
const handleClearConsole = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
if (activeWorkflowId) {
clearWorkflowConsole(activeWorkflowId)
setSelectedEntry(null)
}
},
[activeWorkflowId, clearWorkflowConsole]
)
/**
* Mark hydration as complete on mount
@@ -211,6 +341,30 @@ export function Terminal() {
setHasHydrated(true)
}, [setHasHydrated])
/**
* Adjust showInput when selected entry changes
* Stay on input view if the new entry has input data
*/
useEffect(() => {
if (!selectedEntry) {
setShowInput(false)
return
}
// If we're viewing input but the new entry has no input, switch to output
if (showInput) {
const newHasInput =
selectedEntry.input &&
(typeof selectedEntry.input === 'object'
? Object.keys(selectedEntry.input).length > 0
: true)
if (!newHasInput) {
setShowInput(false)
}
}
}, [selectedEntry, showInput])
/**
* Reset copy success state after 2 seconds
*/
@@ -223,6 +377,33 @@ export function Terminal() {
}
}, [showCopySuccess])
/**
* Handle keyboard navigation through logs
*/
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!selectedEntry || filteredEntries.length === 0) return
// Only handle arrow keys
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return
// Prevent default scrolling behavior
e.preventDefault()
const currentIndex = filteredEntries.findIndex((entry) => entry.id === selectedEntry.id)
if (currentIndex === -1) return
if (e.key === 'ArrowUp' && currentIndex > 0) {
setSelectedEntry(filteredEntries[currentIndex - 1])
} else if (e.key === 'ArrowDown' && currentIndex < filteredEntries.length - 1) {
setSelectedEntry(filteredEntries[currentIndex + 1])
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [selectedEntry, filteredEntries])
/**
* Adjust output panel width when sidebar or panel width changes.
* Ensures output panel doesn't exceed maximum allowed width.
@@ -240,12 +421,11 @@ export function Terminal() {
// Calculate max width: total terminal width minus block column width
const terminalWidth = window.innerWidth - sidebarWidth - panelWidth
const maxWidth = terminalWidth - 200 // COLUMN_WIDTHS.BLOCK
const minWidth = 300
const maxWidth = terminalWidth - BLOCK_COLUMN_WIDTH_PX
// If current output panel width exceeds max, clamp it
if (outputPanelWidth > maxWidth && maxWidth >= minWidth) {
setOutputPanelWidth(Math.max(maxWidth, minWidth))
if (outputPanelWidth > maxWidth && maxWidth >= MIN_OUTPUT_PANEL_WIDTH_PX) {
setOutputPanelWidth(Math.max(maxWidth, MIN_OUTPUT_PANEL_WIDTH_PX))
}
}
@@ -305,9 +485,28 @@ export function Terminal() {
>
<ColumnHeader label='Block' width={COLUMN_WIDTHS.BLOCK} />
<ColumnHeader label='Status' width={COLUMN_WIDTHS.STATUS} />
<ColumnHeader label='Run ID' width={COLUMN_WIDTHS.RUN_ID} />
<ColumnHeader label='Duration' width={COLUMN_WIDTHS.DURATION} />
<ColumnHeader label='Timestamp' width={COLUMN_WIDTHS.TIMESTAMP} />
{!selectedEntry && (
<div className='ml-auto flex items-center'>
<div className='ml-auto flex items-center gap-[8px]'>
{filteredEntries.length > 0 && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={handleClearConsole}
aria-label='Clear console'
className='!p-1.5 -m-1.5'
>
<Trash2 className='h-3 w-3' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>Clear console</span>
</Tooltip.Content>
</Tooltip.Root>
)}
<ToggleButton
isExpanded={isExpanded}
onClick={(e) => {
@@ -322,7 +521,7 @@ export function Terminal() {
{/* Rows */}
<div className='flex-1 overflow-y-auto overflow-x-hidden'>
{filteredEntries.length === 0 ? (
<div className='flex h-full items-center justify-center text-[#8D8D8D] text-[12px]'>
<div className='flex h-full items-center justify-center text-[#8D8D8D] text-[13px]'>
No logs yet
</div>
) : (
@@ -330,6 +529,7 @@ export function Terminal() {
const statusInfo = getStatusInfo(entry.success, entry.error)
const isSelected = selectedEntry?.id === entry.id
const BlockIcon = getBlockIcon(entry.blockType)
const runIdColor = getRunIdColor(entry.executionId, executionIdOrderMap)
return (
<div
@@ -340,6 +540,7 @@ export function Terminal() {
)}
onClick={() => handleRowClick(entry)}
>
{/* Block */}
<div
className={clsx(
COLUMN_WIDTHS.BLOCK,
@@ -352,6 +553,8 @@ export function Terminal() {
)}
<span className={clsx('truncate', ROW_TEXT_CLASS)}>{entry.blockName}</span>
</div>
{/* Status */}
<div className={clsx(COLUMN_WIDTHS.STATUS, COLUMN_BASE_CLASS)}>
{statusInfo ? (
<div
@@ -369,7 +572,7 @@ export function Terminal() {
}}
/>
<span
className='font-medium text-[12px]'
className='font-medium text-[11.5px]'
style={{ color: statusInfo.isError ? '#EF4444' : '#B7B7B7' }}
>
{statusInfo.label}
@@ -379,6 +582,29 @@ export function Terminal() {
<span className={ROW_TEXT_CLASS}>-</span>
)}
</div>
{/* Run ID */}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span
className={clsx(
COLUMN_WIDTHS.RUN_ID,
COLUMN_BASE_CLASS,
'truncate font-medium font-mono text-[12px]'
)}
style={{ color: runIdColor?.text || '#D2D2D2' }}
>
{formatRunId(entry.executionId)}
</span>
</Tooltip.Trigger>
{entry.executionId && (
<Tooltip.Content>
<span className='font-mono text-[11px]'>{entry.executionId}</span>
</Tooltip.Content>
)}
</Tooltip.Root>
{/* Duration */}
<span
className={clsx(
COLUMN_WIDTHS.DURATION,
@@ -389,6 +615,18 @@ export function Terminal() {
>
{formatDuration(entry.durationMs)}
</span>
{/* Timestamp */}
<span
className={clsx(
COLUMN_WIDTHS.TIMESTAMP,
COLUMN_BASE_CLASS,
'truncate',
ROW_TEXT_CLASS
)}
>
{formatTimestamp(entry.timestamp)}
</span>
</div>
)
})
@@ -413,11 +651,54 @@ export function Terminal() {
{/* Header */}
<div
className='group flex h-[30px] flex-shrink-0 cursor-pointer items-center bg-[#1E1E1E] px-[16px]'
className='group flex h-[30px] flex-shrink-0 cursor-pointer items-center justify-between bg-[#1E1E1E] px-[16px]'
onClick={handleHeaderClick}
>
<span className={HEADER_TEXT_CLASS}>Output</span>
<div className='ml-auto flex items-center gap-[8px]'>
<div className='flex items-center'>
<Button
variant='ghost'
className={clsx(
'px-[8px] py-[6px] text-[12px]',
!showInput && hasInputData && '!text-[#E6E6E6] dark:!text-[#E6E6E6]'
)}
onClick={(e) => {
e.stopPropagation()
if (!isExpanded) {
setIsToggling(true)
const maxHeight = window.innerHeight * 0.7
const targetHeight = Math.min(DEFAULT_EXPANDED_HEIGHT, maxHeight)
setTerminalHeight(targetHeight)
}
if (showInput) setShowInput(false)
}}
aria-label='Show output'
>
Output
</Button>
{hasInputData && (
<Button
variant='ghost'
className={clsx(
'px-[8px] py-[6px] text-[12px]',
showInput && '!text-[#E6E6E6] dark:!text-[#E6E6E6] '
)}
onClick={(e) => {
e.stopPropagation()
if (!isExpanded) {
setIsToggling(true)
const maxHeight = window.innerHeight * 0.7
const targetHeight = Math.min(DEFAULT_EXPANDED_HEIGHT, maxHeight)
setTerminalHeight(targetHeight)
}
setShowInput(true)
}}
aria-label='Show input'
>
Input
</Button>
)}
</div>
<div className='flex items-center gap-[8px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
@@ -432,7 +713,7 @@ export function Terminal() {
{showCopySuccess ? (
<Check className='h-3.5 w-3.5' />
) : (
<Clipboard className='h-3.5 w-3.5' />
<Clipboard className='h-[12px] w-[12px]' />
)}
</Button>
</Tooltip.Trigger>
@@ -500,6 +781,23 @@ export function Terminal() {
</PopoverItem>
</PopoverContent>
</Popover>
{filteredEntries.length > 0 && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={handleClearConsole}
aria-label='Clear console'
className='!p-1.5 -m-1.5'
>
<Trash2 className='h-3 w-3' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>Clear console</span>
</Tooltip.Content>
</Tooltip.Root>
)}
<ToggleButton
isExpanded={isExpanded}
onClick={(e) => {
@@ -514,13 +812,24 @@ export function Terminal() {
<div
className={clsx(
'flex-1 overflow-x-auto overflow-y-auto',
displayMode === 'prettier' && 'px-[8px] pb-[8px]',
displayMode === 'raw' && '-mt-[4px]'
displayMode === 'prettier' && 'px-[8px] pb-[8px]'
)}
>
{displayMode === 'raw' ? (
{shouldShowCodeDisplay ? (
<Code.Viewer
code={JSON.stringify(selectedEntry.error || selectedEntry.output, null, 2)}
code={selectedEntry.input.code}
showGutter
language={
(selectedEntry.input.language as 'javascript' | 'json') || 'javascript'
}
className='m-0 min-h-full rounded-none border-0 bg-[#1E1E1E]'
paddingLeft={8}
gutterStyle={{ backgroundColor: 'transparent' }}
wrapText={wrapText}
/>
) : displayMode === 'raw' ? (
<Code.Viewer
code={JSON.stringify(outputData, null, 2)}
showGutter
language='json'
className='m-0 min-h-full rounded-none border-0 bg-[#1E1E1E]'
@@ -529,10 +838,7 @@ export function Terminal() {
wrapText={wrapText}
/>
) : (
<PrettierOutput
output={selectedEntry.error || selectedEntry.output}
wrapText={wrapText}
/>
<PrettierOutput output={outputData} wrapText={wrapText} />
)}
</div>
</div>

View File

@@ -1,5 +1,5 @@
import { memo, useCallback } from 'react'
import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, LogOut } from 'lucide-react'
import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, LogOut, Play } from 'lucide-react'
import { Button, Duplicate, Tooltip, Trash2 } from '@/components/emcn'
import { cn } from '@/lib/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
@@ -16,6 +16,10 @@ interface ActionBarProps {
blockType: string
/** Whether the action bar is disabled */
disabled?: boolean
/** Whether an execution is currently in progress */
isExecuting?: boolean
/** Handler to run the workflow starting from this block */
onRunFromBlock?: (blockId: string) => Promise<any> | void
}
/**
@@ -25,7 +29,13 @@ interface ActionBarProps {
* @component
*/
export const ActionBar = memo(
function ActionBar({ blockId, blockType, disabled = false }: ActionBarProps) {
function ActionBar({
blockId,
blockType,
disabled = false,
isExecuting = false,
onRunFromBlock,
}: ActionBarProps) {
const {
collaborativeRemoveBlock,
collaborativeToggleBlockEnabled,
@@ -69,6 +79,8 @@ export const ActionBar = memo(
return defaultMessage
}
const canRunFromBlock = !disabled && !isExecuting && Boolean(onRunFromBlock)
return (
<div
className={cn(
@@ -78,6 +90,26 @@ export const ActionBar = memo(
'gap-[6px] rounded-[10px] bg-[#242424] p-[6px]'
)}
>
{onRunFromBlock && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={() => {
if (canRunFromBlock) {
onRunFromBlock?.(blockId)
}
}}
className='h-[30px] w-[30px] rounded-[8px] bg-[#363636] p-0 text-[#868686] hover:bg-[#33B4FF] hover:text-[#1B1B1B] dark:text-[#868686] dark:hover:bg-[#33B4FF] dark:hover:text-[#1B1B1B]'
disabled={!canRunFromBlock}
>
<Play className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='right'>{getTooltipMessage('Run From Here')}</Tooltip.Content>
</Tooltip.Root>
)}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
@@ -204,7 +236,9 @@ export const ActionBar = memo(
return (
prevProps.blockId === nextProps.blockId &&
prevProps.blockType === nextProps.blockType &&
prevProps.disabled === nextProps.disabled
prevProps.disabled === nextProps.disabled &&
prevProps.isExecuting === nextProps.isExecuting &&
prevProps.onRunFromBlock === nextProps.onRunFromBlock
)
}
)

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
/**
* Return type for the useChildDeployment hook
@@ -7,9 +7,13 @@ export interface UseChildDeploymentReturn {
/** The active version number of the child workflow */
activeVersion: number | null
/** Whether the child workflow has an active deployment */
isDeployed: boolean
isDeployed: boolean | null
/** Whether the child workflow needs redeployment due to changes */
needsRedeploy: boolean
/** Whether the deployment information is currently being fetched */
isLoading: boolean
/** Function to manually refetch deployment status */
refetch: () => void
}
/**
@@ -20,67 +24,99 @@ export interface UseChildDeploymentReturn {
*/
export function useChildDeployment(childWorkflowId: string | undefined): UseChildDeploymentReturn {
const [activeVersion, setActiveVersion] = useState<number | null>(null)
const [isDeployed, setIsDeployed] = useState<boolean>(false)
const [isDeployed, setIsDeployed] = useState<boolean | null>(null)
const [needsRedeploy, setNeedsRedeploy] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [refetchTrigger, setRefetchTrigger] = useState(0)
useEffect(() => {
const fetchActiveVersion = useCallback(async (wfId: string) => {
let cancelled = false
const fetchActiveVersion = async (wfId: string) => {
try {
setIsLoading(true)
const res = await fetch(`/api/workflows/${wfId}/deployments`, {
try {
setIsLoading(true)
// Fetch both deployment versions and workflow metadata in parallel
const [deploymentsRes, workflowRes] = await Promise.all([
fetch(`/api/workflows/${wfId}/deployments`, {
cache: 'no-store',
headers: { 'Cache-Control': 'no-cache' },
})
}),
fetch(`/api/workflows/${wfId}`, {
cache: 'no-store',
headers: { 'Cache-Control': 'no-cache' },
}),
])
if (!res.ok) {
if (!cancelled) {
setActiveVersion(null)
setIsDeployed(false)
}
return
}
const json = await res.json()
const versions = Array.isArray(json?.data?.versions)
? json.data.versions
: Array.isArray(json?.versions)
? json.versions
: []
const active = versions.find((v: any) => v.isActive)
if (!cancelled) {
const v = active ? Number(active.version) : null
setActiveVersion(v)
setIsDeployed(v != null)
}
} catch {
if (!deploymentsRes.ok || !workflowRes.ok) {
if (!cancelled) {
setActiveVersion(null)
setIsDeployed(false)
setIsDeployed(null)
setNeedsRedeploy(false)
}
} finally {
if (!cancelled) setIsLoading(false)
return
}
}
if (childWorkflowId) {
void fetchActiveVersion(childWorkflowId)
} else {
setActiveVersion(null)
setIsDeployed(false)
const deploymentsJson = await deploymentsRes.json()
const workflowJson = await workflowRes.json()
const versions = Array.isArray(deploymentsJson?.data?.versions)
? deploymentsJson.data.versions
: Array.isArray(deploymentsJson?.versions)
? deploymentsJson.versions
: []
const active = versions.find((v: any) => v.isActive)
const workflowUpdatedAt = workflowJson?.data?.updatedAt || workflowJson?.updatedAt
if (!cancelled) {
const v = active ? Number(active.version) : null
const deployed = v != null
setActiveVersion(v)
setIsDeployed(deployed)
// Check if workflow has been updated since deployment
if (deployed && active?.createdAt && workflowUpdatedAt) {
const deploymentTime = new Date(active.createdAt).getTime()
const updateTime = new Date(workflowUpdatedAt).getTime()
setNeedsRedeploy(updateTime > deploymentTime)
} else {
setNeedsRedeploy(false)
}
}
} catch {
if (!cancelled) {
setActiveVersion(null)
setIsDeployed(null)
setNeedsRedeploy(false)
}
} finally {
if (!cancelled) setIsLoading(false)
}
return () => {
cancelled = true
}
}, [childWorkflowId])
}, [])
useEffect(() => {
if (childWorkflowId) {
void fetchActiveVersion(childWorkflowId)
} else {
setActiveVersion(null)
setIsDeployed(null)
setNeedsRedeploy(false)
}
}, [childWorkflowId, refetchTrigger, fetchActiveVersion])
const refetch = useCallback(() => {
setRefetchTrigger((prev) => prev + 1)
}, [])
return {
activeVersion,
isDeployed,
needsRedeploy,
isLoading,
refetch,
}
}

View File

@@ -11,9 +11,13 @@ export interface UseChildWorkflowReturn {
/** The active version of the child workflow */
childActiveVersion: number | null
/** Whether the child workflow is deployed */
childIsDeployed: boolean
childIsDeployed: boolean | null
/** Whether the child workflow needs redeployment due to changes */
childNeedsRedeploy: boolean
/** Whether the child version information is loading */
isLoadingChildVersion: boolean
/** Function to manually refetch deployment status */
refetchDeployment: () => void
}
/**
@@ -52,13 +56,17 @@ export function useChildWorkflow(
const {
activeVersion: childActiveVersion,
isDeployed: childIsDeployed,
needsRedeploy: childNeedsRedeploy,
isLoading: isLoadingChildVersion,
refetch: refetchDeployment,
} = useChildDeployment(isWorkflowSelector ? childWorkflowId : undefined)
return {
childWorkflowId,
childActiveVersion,
childIsDeployed,
childNeedsRedeploy,
isLoadingChildVersion,
refetchDeployment,
}
}

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useParams } from 'next/navigation'
import { Handle, type NodeProps, Position, useUpdateNodeInternals } from 'reactflow'
import { Badge } from '@/components/emcn/components/badge/badge'
@@ -24,6 +24,7 @@ import {
} from './hooks'
import type { WorkflowBlockProps } from './types'
import { debounce, getProviderName, shouldSkipBlockRender } from './utils'
import { useWorkflowExecution } from '../../hooks/use-workflow-execution'
const logger = createLogger('WorkflowBlock')
@@ -164,7 +165,9 @@ const getDisplayValue = (value: unknown): string => {
*/
const SubBlockRow = ({ title, value }: { title: string; value?: string }) => (
<div className='flex items-center gap-[8px]'>
<span className='flex-shrink-0 text-[#AEAEAE] text-[14px]'>{title}</span>
<span className='min-w-0 truncate text-[#AEAEAE] text-[14px]' title={title}>
{title}
</span>
{value !== undefined && (
<span className='flex-1 truncate text-right text-[#FFFFFF] text-[14px]' title={value}>
{value}
@@ -193,6 +196,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
currentWorkflow,
data
)
const { handleRunFromBlock, isExecuting } = useWorkflowExecution()
const { horizontalHandles, blockHeight, blockWidth, displayAdvancedMode, displayTriggerMode } =
useBlockProperties(
@@ -212,11 +216,51 @@ export const WorkflowBlock = memo(function WorkflowBlock({
disableSchedule,
} = useScheduleInfo(id, type, currentWorkflowId)
const { childWorkflowId, childIsDeployed } = useChildWorkflow(
id,
type,
data.isPreview ?? false,
data.subBlockValues
const { childWorkflowId, childIsDeployed, childNeedsRedeploy, refetchDeployment } =
useChildWorkflow(id, type, data.isPreview ?? false, data.subBlockValues)
const [isDeploying, setIsDeploying] = useState(false)
const setDeploymentStatus = useWorkflowRegistry((state) => state.setDeploymentStatus)
const deployWorkflow = useCallback(
async (workflowId: string) => {
if (isDeploying) return
try {
setIsDeploying(true)
const response = await fetch(`/api/workflows/${workflowId}/deploy`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
deployChatEnabled: false,
}),
})
if (response.ok) {
const responseData = await response.json()
const isDeployedStatus = responseData.isDeployed ?? false
const deployedAtTime = responseData.deployedAt
? new Date(responseData.deployedAt)
: undefined
setDeploymentStatus(
workflowId,
isDeployedStatus,
deployedAtTime,
responseData.apiKey || ''
)
refetchDeployment()
} else {
logger.error('Failed to deploy workflow')
}
} catch (error) {
logger.error('Error deploying workflow:', error)
} finally {
setIsDeploying(false)
}
},
[isDeploying, setDeploymentStatus, refetchDeployment]
)
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
@@ -558,7 +602,13 @@ export const WorkflowBlock = memo(function WorkflowBlock({
</div>
)}
<ActionBar blockId={id} blockType={type} disabled={!userPermissions.canEdit} />
<ActionBar
blockId={id}
blockType={type}
disabled={!userPermissions.canEdit}
isExecuting={isExecuting}
onRunFromBlock={handleRunFromBlock}
/>
{shouldShowDefaultHandles && (
<Connections blockId={id} horizontalHandles={horizontalHandles} />
@@ -604,68 +654,100 @@ export const WorkflowBlock = memo(function WorkflowBlock({
</div>
<div className='flex flex-shrink-0 items-center gap-2'>
{isWorkflowSelector && childWorkflowId && (
<Badge
variant='outline'
style={{
borderColor: childIsDeployed ? '#22C55E' : '#EF4444',
color: childIsDeployed ? '#22C55E' : '#EF4444',
}}
>
{childIsDeployed ? 'deployed' : 'undeployed'}
</Badge>
)}
{!isEnabled && <Badge>Disabled</Badge>}
{shouldShowScheduleBadge && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Badge
variant='outline'
className='cursor-pointer'
style={{
borderColor: scheduleInfo?.isDisabled ? '#FF6600' : '#22C55E',
color: scheduleInfo?.isDisabled ? '#FF6600' : '#22C55E',
}}
onClick={(e) => {
e.stopPropagation()
if (scheduleInfo?.id) {
if (scheduleInfo.isDisabled) {
reactivateSchedule(scheduleInfo.id)
} else {
disableSchedule(scheduleInfo.id)
}
}
}}
>
<div className='relative flex items-center justify-center'>
<div
className='absolute h-3 w-3 rounded-full'
<>
{typeof childIsDeployed === 'boolean' ? (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Badge
variant='outline'
className={!childIsDeployed || childNeedsRedeploy ? 'cursor-pointer' : ''}
style={{
backgroundColor: scheduleInfo?.isDisabled
? 'rgba(255, 102, 0, 0.2)'
: 'rgba(34, 197, 94, 0.2)',
borderColor: !childIsDeployed
? '#EF4444'
: childNeedsRedeploy
? '#FF6600'
: '#22C55E',
color: !childIsDeployed
? '#EF4444'
: childNeedsRedeploy
? '#FF6600'
: '#22C55E',
}}
/>
<div
className='relative h-2 w-2 rounded-full'
style={{
backgroundColor: scheduleInfo?.isDisabled ? '#FF6600' : '#22C55E',
onClick={(e) => {
e.stopPropagation()
if (
(!childIsDeployed || childNeedsRedeploy) &&
childWorkflowId &&
!isDeploying
) {
deployWorkflow(childWorkflowId)
}
}}
/>
</div>
{scheduleInfo?.isDisabled ? 'Disabled' : 'Scheduled'}
>
{isDeploying
? 'Deploying...'
: !childIsDeployed
? 'undeployed'
: childNeedsRedeploy
? 'redeploy'
: 'deployed'}
</Badge>
</Tooltip.Trigger>
{(!childIsDeployed || childNeedsRedeploy) && (
<Tooltip.Content>
<span className='text-sm'>
{!childIsDeployed ? 'Click to deploy' : 'Click to redeploy'}
</span>
</Tooltip.Content>
)}
</Tooltip.Root>
) : (
<Badge variant='outline' style={{ visibility: 'hidden' }}>
deployed
</Badge>
</Tooltip.Trigger>
<Tooltip.Content side='top' className='max-w-[300px] p-4'>
{scheduleInfo?.isDisabled ? (
<p className='text-sm'>
This schedule is currently disabled. Click the badge to reactivate it.
</p>
) : (
<p className='text-sm'>Click the badge to disable this schedule.</p>
)}
</Tooltip.Content>
</Tooltip.Root>
)}
</>
)}
{!isEnabled && <Badge>disabled</Badge>}
{type === 'schedule' && (
<>
{shouldShowScheduleBadge ? (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Badge
variant='outline'
className={scheduleInfo?.isDisabled ? 'cursor-pointer' : ''}
style={{
borderColor: scheduleInfo?.isDisabled ? '#FF6600' : '#22C55E',
color: scheduleInfo?.isDisabled ? '#FF6600' : '#22C55E',
}}
onClick={(e) => {
e.stopPropagation()
if (scheduleInfo?.id) {
if (scheduleInfo.isDisabled) {
reactivateSchedule(scheduleInfo.id)
} else {
disableSchedule(scheduleInfo.id)
}
}
}}
>
{scheduleInfo?.isDisabled ? 'disabled' : 'scheduled'}
</Badge>
</Tooltip.Trigger>
{scheduleInfo?.isDisabled && (
<Tooltip.Content>
<span className='text-sm'>Click to reactivate</span>
</Tooltip.Content>
)}
</Tooltip.Root>
) : (
<Badge variant='outline' style={{ visibility: 'hidden' }}>
scheduled
</Badge>
)}
</>
)}
{showWebhookIndicator && (

View File

@@ -1,4 +1,5 @@
export { useAutoLayout } from './use-auto-layout'
export { type CurrentWorkflow, useCurrentWorkflow } from './use-current-workflow'
export { useNodeUtilities } from './use-node-utilities'
export { useScrollManagement } from './use-scroll-management'
export { useWorkflowExecution } from './use-workflow-execution'

View File

@@ -3,18 +3,21 @@ import { v4 as uuidv4 } from 'uuid'
import { createLogger } from '@/lib/logs/console/logger'
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
import { processStreamingBlockLogs } from '@/lib/tokenization'
import {
extractTriggerMockPayload,
selectBestTrigger,
triggerNeedsMockPayload,
} from '@/lib/workflows/trigger-utils'
import { resolveStartCandidates, StartBlockPath, TriggerUtils } from '@/lib/workflows/triggers'
import type { BlockLog, ExecutionResult, StreamingExecution } from '@/executor/types'
import { useExecutionStream } from '@/hooks/use-execution-stream'
import { Serializer, WorkflowValidationError } from '@/serializer'
import { WorkflowValidationError } from '@/serializer'
import { useExecutionStore } from '@/stores/execution/store'
import { useVariablesStore } from '@/stores/panel/variables/store'
import { useEnvironmentStore } from '@/stores/settings/environment/store'
import { useTerminalConsoleStore } from '@/stores/terminal'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
import { filterEdgesFromTriggerBlocks } from '../utils/workflow-execution-utils'
import { useCurrentWorkflow } from './use-current-workflow'
const logger = createLogger('useWorkflowExecution')
@@ -439,9 +442,7 @@ export function useWorkflowExecution() {
}
// Get selected outputs from chat store
const chatStore = await import('@/stores/panel/chat/store').then(
(mod) => mod.useChatStore
)
const chatStore = await import('@/stores/chat/store').then((mod) => mod.useChatStore)
const selectedOutputs = chatStore
.getState()
.getSelectedWorkflowOutput(activeWorkflowId)
@@ -642,7 +643,11 @@ export function useWorkflowExecution() {
onStream?: (se: StreamingExecution) => Promise<void>,
executionId?: string,
onBlockComplete?: (blockId: string, output: any) => Promise<void>,
overrideTriggerType?: 'chat' | 'manual' | 'api'
overrideTriggerType?: 'chat' | 'manual' | 'api',
overrides?: {
startBlockId?: string
executionMode?: 'run_from_block'
}
): Promise<ExecutionResult | StreamingExecution> => {
// Use currentWorkflow but check if we're in diff mode
const { blocks: workflowBlocks, edges: workflowEdges } = currentWorkflow
@@ -700,78 +705,11 @@ export function useWorkflowExecution() {
{} as typeof mergedStates
)
const currentBlockStates = Object.entries(filteredStates).reduce(
(acc, [id, block]) => {
acc[id] = Object.entries(block.subBlocks).reduce(
(subAcc, [key, subBlock]) => {
subAcc[key] = subBlock.value
return subAcc
},
{} as Record<string, any>
)
return acc
},
{} as Record<string, Record<string, any>>
)
// Get workspaceId from workflow metadata
const workspaceId = activeWorkflowId ? workflows[activeWorkflowId]?.workspaceId : undefined
// Get environment variables with workspace precedence
const personalEnvVars = getAllVariables()
const personalEnvValues = Object.entries(personalEnvVars).reduce(
(acc, [key, variable]) => {
acc[key] = variable.value
return acc
},
{} as Record<string, string>
)
// Load workspace environment variables if workspaceId exists
let workspaceEnvValues: Record<string, string> = {}
if (workspaceId) {
try {
const workspaceData = await loadWorkspaceEnvironment(workspaceId)
workspaceEnvValues = workspaceData.workspace || {}
} catch (error) {
logger.warn('Failed to load workspace environment variables:', error)
}
}
// Merge with workspace taking precedence over personal
const envVarValues = { ...personalEnvValues, ...workspaceEnvValues }
// Get workflow variables
const workflowVars = activeWorkflowId ? getVariablesByWorkflowId(activeWorkflowId) : []
const workflowVariables = workflowVars.reduce(
(acc, variable) => {
acc[variable.id] = variable
return acc
},
{} as Record<string, any>
)
// Filter out edges between trigger blocks - triggers are independent entry points
const filteredEdges = filterEdgesFromTriggerBlocks(filteredStates, workflowEdges)
// Derive subflows from the current filtered graph to avoid stale state
const runtimeLoops = generateLoopBlocks(filteredStates)
const runtimeParallels = generateParallelBlocks(filteredStates)
// Create serialized workflow with validation enabled
const workflow = new Serializer().serializeWorkflow(
filteredStates,
filteredEdges,
runtimeLoops,
runtimeParallels,
true
)
// If this is a chat execution, get the selected outputs
let selectedOutputs: string[] | undefined
if (isExecutingFromChat && activeWorkflowId) {
// Get selected outputs from chat store
const chatStore = await import('@/stores/panel/chat/store').then((mod) => mod.useChatStore)
const chatStore = await import('@/stores/chat/store').then((mod) => mod.useChatStore)
selectedOutputs = chatStore.getState().getSelectedWorkflowOutput(activeWorkflowId)
}
@@ -791,10 +729,19 @@ export function useWorkflowExecution() {
}
// Determine start block and workflow input based on execution type
let startBlockId: string | undefined
const overrideStartBlockId = overrides?.startBlockId?.trim()
let startBlockId: string | undefined = overrideStartBlockId
let finalWorkflowInput = workflowInput
if (isExecutingFromChat) {
if (overrideStartBlockId) {
if (!filteredStates[overrideStartBlockId]) {
setIsExecuting(false)
throw new Error('Selected block is not part of this workflow')
}
}
if (!startBlockId && isExecutingFromChat) {
// For chat execution, find the appropriate chat trigger
const startBlock = TriggerUtils.findStartBlock(filteredStates, 'chat')
@@ -803,20 +750,22 @@ export function useWorkflowExecution() {
}
startBlockId = startBlock.blockId
} else {
} else if (!startBlockId) {
// Manual execution: detect and group triggers by paths
const candidates = resolveStartCandidates(filteredStates, {
execution: 'manual',
})
logger.info('Manual run start candidates:', {
count: candidates.length,
paths: candidates.map((candidate) => ({
path: candidate.path,
type: candidate.block.type,
name: candidate.block.name,
})),
})
if (candidates.length === 0) {
const error = new Error('Workflow requires at least one trigger block to execute')
logger.error('No trigger blocks found for manual run', {
allBlockTypes: Object.values(filteredStates).map((b) => b.type),
})
setIsExecuting(false)
throw error
}
// Check for multiple API triggers (still not allowed)
const apiCandidates = candidates.filter(
(candidate) => candidate.path === StartBlockPath.SPLIT_API
)
@@ -827,18 +776,16 @@ export function useWorkflowExecution() {
throw error
}
const selectedCandidate = apiCandidates[0] ?? candidates[0]
if (!selectedCandidate) {
const error = new Error('Manual run requires a Manual, Input Form, or API Trigger block')
logger.error('No manual/input or API triggers found for manual run')
setIsExecuting(false)
throw error
}
// Select the best trigger
// Priority: Start Block > Schedules > External Triggers > Legacy
const selectedTriggers = selectBestTrigger(candidates, workflowEdges)
// Execute the first/highest priority trigger
const selectedCandidate = selectedTriggers[0]
startBlockId = selectedCandidate.blockId
const selectedTrigger = selectedCandidate.block
// Validate outgoing connections for non-legacy triggers
if (selectedCandidate.path !== StartBlockPath.LEGACY_STARTER) {
const outgoingConnections = workflowEdges.filter((edge) => edge.source === startBlockId)
if (outgoingConnections.length === 0) {
@@ -850,30 +797,21 @@ export function useWorkflowExecution() {
}
}
if (
// Prepare input based on trigger type
if (triggerNeedsMockPayload(selectedCandidate)) {
const mockPayload = extractTriggerMockPayload(selectedCandidate)
finalWorkflowInput = mockPayload
} else if (
selectedCandidate.path === StartBlockPath.SPLIT_API ||
selectedCandidate.path === StartBlockPath.SPLIT_INPUT ||
selectedCandidate.path === StartBlockPath.UNIFIED
) {
const inputFormatValue = selectedTrigger.subBlocks?.inputFormat?.value
const testInput = extractTestValuesFromInputFormat(inputFormatValue)
if (Object.keys(testInput).length > 0) {
finalWorkflowInput = testInput
logger.info('Using trigger test values for manual run:', {
startBlockId,
testFields: Object.keys(testInput),
path: selectedCandidate.path,
})
}
}
logger.info('Trigger found for manual run:', {
startBlockId,
triggerType: selectedTrigger.type,
triggerName: selectedTrigger.name,
startPath: selectedCandidate.path,
})
}
// If we don't have a valid startBlockId at this point, throw an error
@@ -904,13 +842,16 @@ export function useWorkflowExecution() {
const activeBlocksSet = new Set<string>()
const streamedContent = new Map<string, string>()
// Execute the workflow
try {
await executionStream.execute({
workflowId: activeWorkflowId,
input: finalWorkflowInput,
startBlockId,
selectedOutputs,
triggerType: overrideTriggerType || 'manual',
useDraftState: true,
executionMode: overrides?.executionMode,
callbacks: {
onExecutionStarted: (data) => {
logger.info('Server execution started:', data)
@@ -1281,6 +1222,63 @@ export function useWorkflowExecution() {
handleDebugExecutionError,
])
/**
* Handles cancelling the current debugging session
*/
const handleRunFromBlock = useCallback(
async (blockId: string) => {
if (!activeWorkflowId || !blockId?.trim()) {
logger.warn('Run from block requested without active workflow or block id')
return
}
setExecutionResult(null)
setIsExecuting(true)
setIsDebugging(false)
try {
const runResult = await executeWorkflow(
undefined,
undefined,
undefined,
undefined,
'manual',
{
startBlockId: blockId,
executionMode: 'run_from_block',
}
)
if (runResult && 'metadata' in runResult && runResult.metadata?.isDebugSession) {
setDebugContext(runResult.metadata.context || null)
if (runResult.metadata.pendingBlocks) {
setPendingBlocks(runResult.metadata.pendingBlocks)
}
} else if (runResult && 'success' in runResult) {
setExecutionResult(runResult)
setIsExecuting(false)
setIsDebugging(false)
setActiveBlocks(new Set())
}
return runResult
} catch (error) {
return handleExecutionError(error, { executionId: undefined })
}
},
[
activeWorkflowId,
executeWorkflow,
handleExecutionError,
setExecutionResult,
setIsExecuting,
setIsDebugging,
setDebugContext,
setPendingBlocks,
setActiveBlocks,
]
)
/**
* Handles cancelling the current debugging session
*/
@@ -1322,6 +1320,7 @@ export function useWorkflowExecution() {
pendingBlocks,
executionResult,
handleRunWorkflow,
handleRunFromBlock,
handleStepDebug,
handleResumeDebug,
handleCancelDebug,

View File

@@ -1,16 +1,6 @@
/**
* Workflow execution utilities for client-side execution triggers
* This is now a thin wrapper around the server-side executor
*/
import type { Edge } from 'reactflow'
import { createLogger } from '@/lib/logs/console/logger'
import { TriggerUtils } from '@/lib/workflows/triggers'
import type { ExecutionResult, StreamingExecution } from '@/executor/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('WorkflowExecutionUtils')
export interface WorkflowExecutionOptions {
workflowInput?: any
onStream?: (se: StreamingExecution) => Promise<void>
@@ -54,28 +44,3 @@ export async function executeWorkflowWithFullLogging(
const result = await response.json()
return result as ExecutionResult
}
/**
* Filter out all incoming edges to trigger blocks - triggers are independent entry points
* This ensures execution and UI only show edges that are actually connected in execution
* @param blocks - Record of blocks keyed by block ID
* @param edges - Array of edges to filter
* @returns Filtered array of edges
*/
export function filterEdgesFromTriggerBlocks(blocks: Record<string, any>, edges: Edge[]): Edge[] {
return edges.filter((edge) => {
const sourceBlock = blocks[edge.source]
const targetBlock = blocks[edge.target]
if (!sourceBlock || !targetBlock) {
return true
}
const targetIsTrigger = TriggerUtils.isTriggerBlock({
type: targetBlock.type,
triggerMode: targetBlock.triggerMode,
})
return !targetIsTrigger
})
}

View File

@@ -15,6 +15,7 @@ import { createLogger } from '@/lib/logs/console/logger'
import { TriggerUtils } from '@/lib/workflows/triggers'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { DiffControls } from '@/app/workspace/[workspaceId]/w/[workflowId]/components'
import { Chat } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat'
import { UserAvatarStack } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/user-avatar-stack/user-avatar-stack'
import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index'
import { Panel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/panel-new'
@@ -34,7 +35,6 @@ import {
useCurrentWorkflow,
useNodeUtilities,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { filterEdgesFromTriggerBlocks } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils'
import { getBlock } from '@/blocks'
import { useSocket } from '@/contexts/socket-context'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
@@ -226,9 +226,7 @@ const WorkflowContent = React.memo(() => {
// Combine existing edges with reconstructed deleted edges
edgesToFilter = [...edges, ...reconstructedEdges]
}
// Filter out edges between trigger blocks for consistent UI and execution behavior
return filterEdgesFromTriggerBlocks(blocks, edgesToFilter)
return edgesToFilter
}, [edges, isShowingDiff, isDiffReady, diffAnalysis, blocks])
// User permissions - get current user's specific permissions from context
@@ -601,6 +599,12 @@ const WorkflowContent = React.memo(() => {
if (isAutoConnectEnabled) {
const closestBlock = findClosestOutput(centerPosition)
if (closestBlock) {
// Container nodes are never triggers, but check if source is a trigger
const sourceBlockConfig = getBlock(closestBlock.type)
const isSourceTrigger =
sourceBlockConfig?.category === 'triggers' || sourceBlockConfig?.triggers?.enabled
// Container nodes can connect from triggers (they're not triggers themselves)
// Get appropriate source handle
const sourceHandle = determineSourceHandle(closestBlock)
@@ -660,18 +664,28 @@ const WorkflowContent = React.memo(() => {
const closestBlock = findClosestOutput(centerPosition)
logger.info('Closest block found:', closestBlock)
if (closestBlock) {
// Get appropriate source handle
const sourceHandle = determineSourceHandle(closestBlock)
// Don't create edges into trigger blocks
const targetBlockConfig = blockConfig
const isTargetTrigger =
targetBlockConfig?.category === 'triggers' || targetBlockConfig?.triggers?.enabled
autoConnectEdge = {
id: crypto.randomUUID(),
source: closestBlock.id,
target: id,
sourceHandle,
targetHandle: 'target',
type: 'workflowEdge',
if (!isTargetTrigger) {
const sourceHandle = determineSourceHandle(closestBlock)
autoConnectEdge = {
id: crypto.randomUUID(),
source: closestBlock.id,
target: id,
sourceHandle,
targetHandle: 'target',
type: 'workflowEdge',
}
logger.info('Auto-connect edge created:', autoConnectEdge)
} else {
logger.info('Skipping auto-connect into trigger block', {
target: type,
})
}
logger.info('Auto-connect edge created:', autoConnectEdge)
}
}
@@ -831,6 +845,7 @@ const WorkflowContent = React.memo(() => {
if (isAutoConnectEnabled) {
const closestBlock = findClosestOutput(position)
if (closestBlock) {
// Container nodes can connect from any block (they're never triggers)
const sourceHandle = determineSourceHandle(closestBlock)
autoConnectEdge = {
@@ -911,17 +926,24 @@ const WorkflowContent = React.memo(() => {
.sort((a, b) => a.distance - b.distance)[0]?.block
if (closestBlock) {
const sourceHandle = determineSourceHandle({
id: closestBlock.id,
type: closestBlock.type,
})
autoConnectEdge = {
id: crypto.randomUUID(),
source: closestBlock.id,
target: id,
sourceHandle,
targetHandle: 'target',
type: 'workflowEdge',
// Don't create edges into trigger blocks
const targetBlockConfig = getBlock(data.type)
const isTargetTrigger =
targetBlockConfig?.category === 'triggers' || targetBlockConfig?.triggers?.enabled
if (!isTargetTrigger) {
const sourceHandle = determineSourceHandle({
id: closestBlock.id,
type: closestBlock.type,
})
autoConnectEdge = {
id: crypto.randomUUID(),
source: closestBlock.id,
target: id,
sourceHandle,
targetHandle: 'target',
type: 'workflowEdge',
}
}
}
} else {
@@ -982,15 +1004,22 @@ const WorkflowContent = React.memo(() => {
if (isAutoConnectEnabled && data.type !== 'starter') {
const closestBlock = findClosestOutput(position)
if (closestBlock) {
const sourceHandle = determineSourceHandle(closestBlock)
// Don't create edges into trigger blocks
const targetBlockConfig = getBlock(data.type)
const isTargetTrigger =
targetBlockConfig?.category === 'triggers' || targetBlockConfig?.triggers?.enabled
autoConnectEdge = {
id: crypto.randomUUID(),
source: closestBlock.id,
target: id,
sourceHandle,
targetHandle: 'target',
type: 'workflowEdge',
if (!isTargetTrigger) {
const sourceHandle = determineSourceHandle(closestBlock)
autoConnectEdge = {
id: crypto.randomUUID(),
source: closestBlock.id,
target: id,
sourceHandle,
targetHandle: 'target',
type: 'workflowEdge',
}
}
}
}
@@ -2022,6 +2051,9 @@ const WorkflowContent = React.memo(() => {
<CollaboratorCursorLayer />
{/* Floating chat modal */}
<Chat />
{/* Show DiffControls if diff is available (regardless of current view mode) */}
<DiffControls />

View File

@@ -0,0 +1,82 @@
'use client'
import { Pencil } from 'lucide-react'
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
import { Trash } from '@/components/emcn/icons'
interface ContextMenuProps {
/**
* Whether the context menu is open
*/
isOpen: boolean
/**
* Position of the context menu
*/
position: { x: number; y: number }
/**
* Ref for the menu element
*/
menuRef: React.RefObject<HTMLDivElement | null>
/**
* Callback when menu should close
*/
onClose: () => void
/**
* Callback when rename is clicked
*/
onRename: () => void
/**
* Callback when delete is clicked
*/
onDelete: () => void
}
/**
* Reusable context menu component for workflow and folder items.
* Displays rename and delete options in a popover at the right-click position.
*
* @param props - Component props
* @returns Context menu popover
*/
export function ContextMenu({
isOpen,
position,
menuRef,
onClose,
onRename,
onDelete,
}: ContextMenuProps) {
return (
<Popover open={isOpen} onOpenChange={onClose}>
<PopoverAnchor
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
<PopoverItem
onClick={() => {
onRename()
onClose()
}}
>
<Pencil className='h-3 w-3' />
<span>Rename</span>
</PopoverItem>
<PopoverItem
onClick={() => {
onDelete()
onClose()
}}
>
<Trash className='h-3 w-3' />
<span>Delete</span>
</PopoverItem>
</PopoverContent>
</Popover>
)
}

Some files were not shown because too many files have changed in this diff Show More