mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-11 07:58:06 -05:00
Compare commits
15 Commits
feat/execu
...
feat/run-f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d46b44e51 | ||
|
|
ff99d75055 | ||
|
|
0ed0a26b3b | ||
|
|
0d4d953169 | ||
|
|
81a12e721e | ||
|
|
b03f9702d2 | ||
|
|
997c4639ed | ||
|
|
1e8b4769aa | ||
|
|
28b416078c | ||
|
|
d0720b85bc | ||
|
|
75ce8882c8 | ||
|
|
142d3aadb8 | ||
|
|
7c6e6d1603 | ||
|
|
e186ea630a | ||
|
|
b3490e9127 |
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -38,3 +38,15 @@ import { Card, Cards } from 'fumadocs-ui/components/card'
|
||||
3. ブロックをワークフローの残りの部分に接続します。
|
||||
|
||||
> デプロイメントはすべてのトリガーを動作させます。ワークフローを更新し、再デプロイすると、すべてのトリガーエントリーポイントが新しいスナップショットを取得します。詳細は[実行 → デプロイメントスナップショット](/execution)をご覧ください。
|
||||
|
||||
## 手動実行の優先順位
|
||||
|
||||
エディターで**実行**をクリックすると、Simは以下の優先順位に基づいて自動的に実行するトリガーを選択します:
|
||||
|
||||
1. **スタートブロック**(最高優先度)
|
||||
2. **スケジュールトリガー**
|
||||
3. **外部トリガー**(ウェブフック、Slack、Gmail、Airtableなどの連携)
|
||||
|
||||
ワークフローに複数のトリガーがある場合、最も優先度の高いトリガーが実行されます。例えば、スタートブロックとウェブフックトリガーの両方がある場合、実行をクリックするとスタートブロックが実行されます。
|
||||
|
||||
**モックペイロードを持つ外部トリガー**:外部トリガー(ウェブフックと連携)が手動で実行される場合、Simはトリガーの予想されるデータ構造に基づいて自動的にモックペイロードを生成します。これにより、テスト中に下流のブロックが変数を正しく解決できるようになります。
|
||||
|
||||
@@ -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 会根据触发器的预期数据结构自动生成模拟负载。这确保了在测试期间,下游块可以正确解析变量。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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'
|
||||
/>
|
||||
</>
|
||||
)
|
||||
@@ -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',
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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]'>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
--panel-width: 244px;
|
||||
--toolbar-triggers-height: 300px;
|
||||
--editor-connections-height: 200px;
|
||||
--terminal-height: 30px;
|
||||
--terminal-height: 100px;
|
||||
}
|
||||
|
||||
.sidebar-container {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>>({})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
@@ -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>
|
||||
@@ -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()}
|
||||
>
|
||||
@@ -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}
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { useChangeDetection } from './use-change-detection'
|
||||
export { useDeployedState } from './use-deployed-state'
|
||||
export { useDeployment } from './use-deployment'
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { Deploy } from './deploy'
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { createDragPreview, type DragItemInfo } from './drag-preview'
|
||||
@@ -1,2 +1,2 @@
|
||||
export { useToolbarItemInteractions } from './use-toolbar-item-interactions'
|
||||
export { useToolbarResize } from './use-toolbar-resize'
|
||||
export { calculateTriggerHeights, useToolbarResize } from './use-toolbar-resize'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]'
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user