Compare commits

...

32 Commits

Author SHA1 Message Date
Vikhyath Mondreti
8f0ef58056 v0.5.7: combobox selectors, usage indicator, workflow loading race condition, other improvements 2025-11-17 21:25:51 -08:00
Vikhyath Mondreti
33ca1483aa Merge branch 'main' into staging 2025-11-17 21:21:30 -08:00
Vikhyath Mondreti
620ce97056 improvement(selectors): consolidate all integration selectors to use the combobox (#2020)
* improvement(selectors): consolidate all integration selectors to use the combobox

* improved credential selector and file-upload styling to use emcn combobox

* update mcp subblocks to use emcn components, delete unused mcp server modal

* fix filterOptions change

* fix project selector

* attempted jira fix

* fix gdrive inf calls

* rewrite credential selector

* fix docs

* fix onedrive folder

* fix

* fix

* fix excel cred fetch

* fix excel part 2

---------

Co-authored-by: waleed <walif6@gmail.com>
2025-11-17 21:06:52 -08:00
Vikhyath Mondreti
25ac91779b fix(workflow-block): clearing child workflow input format field must lazy cascade parent workflow state deletion (#2038) 2025-11-17 19:00:50 -08:00
Waleed
d51a756c1b improvement(docs): remove copy page from mobile view on docs (#2037)
* improvement(docs): remove copy page from mobile view on docs

* bring title and pagenav lower on mobile

* added cursor pointer to clickable components in docs
2025-11-17 18:10:52 -08:00
Waleed
3d1feab507 improvement(undo-redo): expand undo-redo store to store 100 ops instead of 15 (#2036)
* improvement(undo-redo): expand undo-redo store to store 100 ops instead of 15

* prevent undo-redo from interfering with subblock browser text undo
2025-11-17 18:00:12 -08:00
Vikhyath Mondreti
98908dbfb9 fix(triggers): dedup + not surfacing deployment status log (#2033)
* fix(triggers): dedup + not surfacing deployment status log

* fix ms teams

* change to microsoftteams

* Revert "change to microsoftteams"

This reverts commit 217f808641.

* fix

* fix

* fix provider name

* fix oauth for msteams
2025-11-17 17:48:22 -08:00
Waleed
00d9b45a22 fix(workflows): fixed workflow loading in without start block, added templates RQ hook, cleaned up unused templates code (#2035) 2025-11-17 17:31:01 -08:00
Waleed
b5b2855b40 fix(overage): fix pill calculation in the usage indicator to be consistent across views (#2034) 2025-11-17 16:22:24 -08:00
Waleed
a81f3847df fix(usage-data): refetch on usage limit update in settings (#2032) 2025-11-17 15:07:52 -08:00
Waleed
3058e35edf v0.5.6: executor fixes, UI improvements, run paths (#2028)
* test(pr): hackathon (#1999)

* test(pr): github trigger (#2000)

* fix(usage-indicator): conditional rendering, upgrade, and ui/ux (#2001)

* fix: usage-limit indicator and render conditonally on is billing enabled

* fix: upgrade render

* fix(notes): fix notes, tighten spacing, update deprecated zustand function, update use mention data to ignore block positon (#2002)

* fix(pdfs): use unpdf instead of pdf-parse (#2004)

* fix(modals): fix z-index for various modals and output selector and variables (#2005)

* fix(condition): treat condition input the same as the code subblock (#2006)

* feat(models): added gpt-5.1 (#2007)

* improvement: runpath edges, blocks, active (#2008)

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

* fix(triggers): check triggermode and consolidate block type (#2011)

* fix(triggers): disabled trigger shouldn't be added to dag (#2012)

* Fix disabled blocks

* Comments

* Fix api/chat trigger not found message

* fix(tags): only show start block upstream if is ancestor (#2013)

* fix(variables): Fix resolution on double < (#2016)

* Fix variable <>

* Ling

* Clean

* feat(billing): add notif for first failed payment, added upgrade email from free, updated providers that supported granular tool control to support them, fixed envvar popover, fixed redirect to wrong workspace after oauth connect (#2015)

* feat(billing): add notif for first failed payment, added upgrade email from free, updated providers that supported granular tool control to support them, fixed envvar popover, fixed redirect to wrong workspace after oauth connect

* fix build

* ack PR comments

* feat(performance): added reactquery hooks for workflow operations, for logs, fixed logs reloading, fix subscription UI (#2017)

* feat(performance): added reactquery hooks for workflow operations, for logs, fixed logs reloading, fix subscription UI

* use useInfiniteQuery for logs fetching

* fix(copilot): run workflow supports input format and fix run id (#2018)

* fix(router): fix error edge in router block + fix source handle problem (#2019)

* Fix router block error port handling

* Remove comment

* Fix edge execution

* improvement: code subblock, action bar, connections (#2024)

* improvement: action bar, connections

* fix: code block draggable resize

* fix(response): fix response block http format (#2027)

* Fix response block

* Lint

* fix(notes): fix notes block spacing, additional logs for billing transfer route (#2029)

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
2025-11-17 13:46:03 -08:00
Waleed
6f3dee867c fix(notes): fix notes block spacing, additional logs for billing transfer route (#2029) 2025-11-17 13:37:17 -08:00
Siddharth Ganesan
bfa7c919d8 fix(response): fix response block http format (#2027)
* Fix response block

* Lint
2025-11-17 11:50:33 -08:00
Emir Karabeg
e37b01b92c improvement: code subblock, action bar, connections (#2024)
* improvement: action bar, connections

* fix: code block draggable resize
2025-11-17 11:04:41 -08:00
Siddharth Ganesan
7e3e38a6f2 fix(router): fix error edge in router block + fix source handle problem (#2019)
* Fix router block error port handling

* Remove comment

* Fix edge execution
2025-11-15 18:32:14 -08:00
Siddharth Ganesan
1c85fe9a51 fix(copilot): run workflow supports input format and fix run id (#2018) 2025-11-15 18:11:09 -08:00
Waleed
5f446ad756 feat(performance): added reactquery hooks for workflow operations, for logs, fixed logs reloading, fix subscription UI (#2017)
* feat(performance): added reactquery hooks for workflow operations, for logs, fixed logs reloading, fix subscription UI

* use useInfiniteQuery for logs fetching
2025-11-15 16:41:35 -08:00
Waleed
d99d5fe39c feat(billing): add notif for first failed payment, added upgrade email from free, updated providers that supported granular tool control to support them, fixed envvar popover, fixed redirect to wrong workspace after oauth connect (#2015)
* feat(billing): add notif for first failed payment, added upgrade email from free, updated providers that supported granular tool control to support them, fixed envvar popover, fixed redirect to wrong workspace after oauth connect

* fix build

* ack PR comments
2025-11-15 16:09:58 -08:00
Siddharth Ganesan
949f9287cf fix(variables): Fix resolution on double < (#2016)
* Fix variable <>

* Ling

* Clean
2025-11-15 15:09:01 -08:00
Siddharth Ganesan
fca92a7499 fix(tags): only show start block upstream if is ancestor (#2013) 2025-11-15 12:27:34 -08:00
Siddharth Ganesan
c25ea5c677 fix(triggers): disabled trigger shouldn't be added to dag (#2012)
* Fix disabled blocks

* Comments

* Fix api/chat trigger not found message
2025-11-15 12:19:37 -08:00
Siddharth Ganesan
dccd9e9ce5 fix(triggers): check triggermode and consolidate block type (#2011) 2025-11-15 12:00:12 -08:00
Waleed
b5d9964c48 feat(i18n): update translations (#2009) 2025-11-14 23:33:13 -08:00
Emir Karabeg
4bd0f31f36 improvement: runpath edges, blocks, active (#2008) 2025-11-14 23:26:41 -08:00
Waleed
f8070f9029 feat(models): added gpt-5.1 (#2007) 2025-11-14 23:23:47 -08:00
Waleed
bc8947caa6 fix(condition): treat condition input the same as the code subblock (#2006) 2025-11-14 23:23:39 -08:00
Waleed
f1111ec16f fix(modals): fix z-index for various modals and output selector and variables (#2005) 2025-11-14 23:13:31 -08:00
Waleed
d0767507b2 fix(pdfs): use unpdf instead of pdf-parse (#2004) 2025-11-14 22:39:28 -08:00
Waleed
8bd75debc1 fix(notes): fix notes, tighten spacing, update deprecated zustand function, update use mention data to ignore block positon (#2002) 2025-11-14 22:12:01 -08:00
Emir Karabeg
ad2a375358 fix(usage-indicator): conditional rendering, upgrade, and ui/ux (#2001)
* fix: usage-limit indicator and render conditonally on is billing enabled

* fix: upgrade render
2025-11-14 21:26:40 -08:00
Vikhyath Mondreti
de91dc97a9 test(pr): github trigger (#2000) 2025-11-14 18:08:07 -08:00
Vikhyath Mondreti
31ed712378 test(pr): hackathon (#1999) 2025-11-14 18:02:00 -08:00
188 changed files with 6595 additions and 13495 deletions

View File

@@ -198,15 +198,17 @@ export default async function Page(props: { params: Promise<{ slug?: string[]; l
component: <CustomFooter />,
}}
>
<div className='relative'>
<div className='relative mt-6 sm:mt-0'>
<div className='absolute top-1 right-0 flex items-center gap-2'>
<CopyPageButton
content={`# ${page.data.title}
<div className='hidden sm:flex'>
<CopyPageButton
content={`# ${page.data.title}
${page.data.description || ''}
${page.data.content || ''}`}
/>
/>
</div>
<PageNavigationArrows previous={neighbours?.previous} next={neighbours?.next} />
</div>
<DocsTitle>{page.data.title}</DocsTitle>

View File

@@ -69,7 +69,7 @@ export function SidebarFolder({
</Link>
<button
onClick={() => setOpen(!open)}
className='rounded p-1 transition-colors hover:bg-gray-100/60 dark:hover:bg-gray-800/40'
className='cursor-pointer rounded p-1 transition-colors hover:bg-gray-100/60 dark:hover:bg-gray-800/40'
aria-label={open ? 'Collapse' : 'Expand'}
>
<ChevronRight
@@ -84,7 +84,7 @@ export function SidebarFolder({
<button
onClick={() => setOpen(!open)}
className={cn(
'flex w-full items-center justify-between rounded-md px-2.5 py-1.5 text-left font-medium text-[13px] leading-tight transition-colors',
'flex w-full cursor-pointer items-center justify-between rounded-md px-2.5 py-1.5 text-left font-medium text-[13px] leading-tight transition-colors',
'hover:bg-gray-100/60 dark:hover:bg-gray-800/40',
'text-gray-800 dark:text-gray-200'
)}

View File

@@ -30,7 +30,7 @@ export function CodeBlock(props: React.ComponentProps<typeof FumadocsCodeBlock>)
if (pre) handleCopy(pre.textContent || '')
}}
className={cn(
'rounded-md p-2 transition-all',
'cursor-pointer rounded-md p-2 transition-all',
'border border-border bg-background/80 hover:bg-muted',
'backdrop-blur-sm'
)}

View File

@@ -23,7 +23,7 @@ export function CopyPageButton({ content }: CopyPageButtonProps) {
return (
<button
onClick={handleCopy}
className='flex items-center gap-1.5 rounded-lg border border-border/40 bg-background px-2.5 py-2 text-muted-foreground/60 text-sm leading-none transition-all hover:border-border hover:bg-accent/50 hover:text-muted-foreground'
className='flex cursor-pointer items-center gap-1.5 rounded-lg border border-border/40 bg-background px-2.5 py-2 text-muted-foreground/60 text-sm leading-none transition-all hover:border-border hover:bg-accent/50 hover:text-muted-foreground'
aria-label={copied ? 'Copied to clipboard' : 'Copy page content'}
>
{copied ? (

View File

@@ -82,7 +82,7 @@ export function LanguageDropdown() {
aria-haspopup='listbox'
aria-expanded={isOpen}
aria-controls='language-menu'
className='flex items-center gap-1.5 rounded-xl px-3 py-2 font-normal text-[0.9375rem] text-foreground/60 leading-[1.4] transition-colors hover:bg-foreground/8 hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring'
className='flex cursor-pointer items-center gap-1.5 rounded-xl px-3 py-2 font-normal text-[0.9375rem] text-foreground/60 leading-[1.4] transition-colors hover:bg-foreground/8 hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring'
style={{
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
@@ -110,7 +110,7 @@ export function LanguageDropdown() {
}}
role='option'
aria-selected={currentLang === code}
className={`flex w-full items-center gap-3 px-3 py-3 text-base transition-colors first:rounded-t-xl last:rounded-b-xl hover:bg-muted/80 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring md:gap-2 md:px-2.5 md:py-2 md:text-sm ${
className={`flex w-full cursor-pointer items-center gap-3 px-3 py-3 text-base transition-colors first:rounded-t-xl last:rounded-b-xl hover:bg-muted/80 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring md:gap-2 md:px-2.5 md:py-2 md:text-sm ${
currentLang === code ? 'bg-muted/60 font-medium text-primary' : 'text-foreground'
}`}
>

View File

@@ -15,7 +15,7 @@ export function SearchTrigger() {
return (
<button
type='button'
className='flex h-10 w-[460px] items-center gap-2 rounded-xl border border-border/50 px-3 py-2 text-sm backdrop-blur-xl transition-colors hover:border-border'
className='flex h-10 w-[460px] cursor-pointer items-center gap-2 rounded-xl border border-border/50 px-3 py-2 text-sm backdrop-blur-xl transition-colors hover:border-border'
style={{
backgroundColor: 'hsla(0, 0%, 5%, 0.85)',
backdropFilter: 'blur(33px) saturate(180%)',

View File

@@ -14,7 +14,7 @@ export function ThemeToggle() {
if (!mounted) {
return (
<button className='flex items-center justify-center rounded-md p-1 text-muted-foreground'>
<button className='flex cursor-pointer items-center justify-center rounded-md p-1 text-muted-foreground'>
<Moon className='h-4 w-4' />
</button>
)
@@ -23,7 +23,7 @@ export function ThemeToggle() {
return (
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className='flex items-center justify-center rounded-md p-1 text-muted-foreground transition-colors hover:text-foreground'
className='flex cursor-pointer items-center justify-center rounded-md p-1 text-muted-foreground transition-colors hover:text-foreground'
aria-label='Toggle theme'
>
{theme === 'dark' ? <Moon className='h-4 w-4' /> : <Sun className='h-4 w-4' />}

View File

@@ -42,10 +42,10 @@ Der Benutzer-Prompt stellt die primären Eingabedaten für die Inferenzverarbeit
Der Agent-Block unterstützt mehrere LLM-Anbieter über eine einheitliche Inferenzschnittstelle. Verfügbare Modelle umfassen:
- **OpenAI**: GPT-5, GPT-4o, o1, o3, o4-mini, gpt-4.1
- **Anthropic**: Claude 3.7 Sonnet
- **OpenAI**: GPT-5.1, GPT-5, GPT-4o, o1, o3, o4-mini, gpt-4.1
- **Anthropic**: Claude 4.5 Sonnet, Claude Opus 4.1
- **Google**: Gemini 2.5 Pro, Gemini 2.0 Flash
- **Andere Anbieter**: Groq, Cerebras, xAI, DeepSeek
- **Andere Anbieter**: Groq, Cerebras, xAI, Azure OpenAI, OpenRouter
- **Lokale Modelle**: Ollama-kompatible Modelle
### Temperatur

View File

@@ -42,10 +42,10 @@ The user prompt represents the primary input data for inference processing. This
The Agent block supports multiple LLM providers through a unified inference interface. Available models include:
- **OpenAI**: GPT-5, GPT-4o, o1, o3, o4-mini, gpt-4.1
- **Anthropic**: Claude 3.7 Sonnet
- **OpenAI**: GPT-5.1, GPT-5, GPT-4o, o1, o3, o4-mini, gpt-4.1
- **Anthropic**: Claude 4.5 Sonnet, Claude Opus 4.1
- **Google**: Gemini 2.5 Pro, Gemini 2.0 Flash
- **Other Providers**: Groq, Cerebras, xAI, DeepSeek
- **Other Providers**: Groq, Cerebras, xAI, Azure OpenAI, OpenRouter
- **Local Models**: Ollama-compatible models
### Temperature

View File

@@ -42,11 +42,11 @@ El prompt del usuario representa los datos de entrada principales para el proces
El bloque Agente admite múltiples proveedores de LLM a través de una interfaz de inferencia unificada. Los modelos disponibles incluyen:
- **OpenAI**: GPT-5, GPT-4o, o1, o3, o4-mini, gpt-4.1
- **Anthropic**: Claude 3.7 Sonnet
- **OpenAI**: GPT-5.1, GPT-5, GPT-4o, o1, o3, o4-mini, gpt-4.1
- **Anthropic**: Claude 4.5 Sonnet, Claude Opus 4.1
- **Google**: Gemini 2.5 Pro, Gemini 2.0 Flash
- **Otros proveedores**: Groq, Cerebras, xAI, DeepSeek
- **Modelos locales**: Modelos compatibles con Ollama
- **Otros proveedores**: Groq, Cerebras, xAI, Azure OpenAI, OpenRouter
- **Modelos locales**: modelos compatibles con Ollama
### Temperatura

View File

@@ -42,10 +42,10 @@ Le prompt utilisateur représente les données d'entrée principales pour le tra
Le bloc Agent prend en charge plusieurs fournisseurs de LLM via une interface d'inférence unifiée. Les modèles disponibles comprennent :
- **OpenAI** : GPT-5, GPT-4o, o1, o3, o4-mini, gpt-4.1
- **Anthropic** : Claude 3.7 Sonnet
- **OpenAI** : GPT-5.1, GPT-5, GPT-4o, o1, o3, o4-mini, gpt-4.1
- **Anthropic** : Claude 4.5 Sonnet, Claude Opus 4.1
- **Google** : Gemini 2.5 Pro, Gemini 2.0 Flash
- **Autres fournisseurs** : Groq, Cerebras, xAI, DeepSeek
- **Autres fournisseurs** : Groq, Cerebras, xAI, Azure OpenAI, OpenRouter
- **Modèles locaux** : modèles compatibles avec Ollama
### Température

View File

@@ -42,10 +42,10 @@ When responding to questions about investments, include risk disclaimers.
エージェントブロックは統一された推論インターフェースを通じて複数のLLMプロバイダーをサポートしています。利用可能なモデルには以下が含まれます
- **OpenAI**: GPT-5、GPT-4o、o1、o3、o4-mini、gpt-4.1
- **Anthropic**: Claude 3.7 Sonnet
- **OpenAI**: GPT-5.1、GPT-5、GPT-4o、o1、o3、o4-mini、gpt-4.1
- **Anthropic**: Claude 4.5 Sonnet、Claude Opus 4.1
- **Google**: Gemini 2.5 Pro、Gemini 2.0 Flash
- **その他のプロバイダー**: Groq、Cerebras、xAI、DeepSeek
- **その他のプロバイダー**: Groq、Cerebras、xAI、Azure OpenAI、OpenRouter
- **ローカルモデル**: Ollama互換モデル
### 温度

View File

@@ -42,10 +42,10 @@ When responding to questions about investments, include risk disclaimers.
代理模块通过统一的推理接口支持多个 LLM 提供商。可用模型包括:
- **OpenAI**GPT-5、GPT-4o、o1、o3、o4-mini、gpt-4.1
- **Anthropic**Claude 3.7 Sonnet
- **OpenAI**GPT-5.1、GPT-5、GPT-4o、o1、o3、o4-mini、gpt-4.1
- **Anthropic**Claude 4.5 Sonnet、Claude Opus 4.1
- **Google**Gemini 2.5 Pro、Gemini 2.0 Flash
- **其他提供商**Groq、Cerebras、xAI、DeepSeek
- **其他提供商**Groq、Cerebras、xAI、Azure OpenAI、OpenRouter
- **本地模型**:兼容 Ollama 的模型
### 温度

View File

@@ -5117,7 +5117,7 @@ checksums:
content/9: e688b523909d6d6e9966c17892a18c96
content/10: e50bd5107ca3410126cf0252b3c47eca
content/11: d03d17960348dea95c6df8f46114bd0a
content/12: 3850cfbd618a9d1c836fc7086da0f9b4
content/12: 80da7e96414b75bb5b910c437bf7894a
content/13: 6a7479225be3a7c7a42ba557ece50d03
content/14: c64f9cd5168b3e592fe3341cbe1a41fe
content/15: 87d6b6280da1c98b1bc291483459c8cf

View File

@@ -20,7 +20,7 @@ interface NavProps {
}
export default function Nav({ hideAuthButtons = false, variant = 'landing' }: NavProps = {}) {
const [githubStars, setGithubStars] = useState('18k')
const [githubStars, setGithubStars] = useState('18.5k')
const [isHovered, setIsHovered] = useState(false)
const [isLoginHovered, setIsLoginHovered] = useState(false)
const router = useRouter()

View File

@@ -1,12 +1,9 @@
import { db } from '@sim/db'
import { account } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { createLogger } from '@/lib/logs/console/logger'
import { validateMicrosoftGraphId } from '@/lib/security/input-validation'
import { generateRequestId } from '@/lib/utils'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { getCredential, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
@@ -15,15 +12,10 @@ const logger = createLogger('MicrosoftFileAPI')
export async function GET(request: NextRequest) {
const requestId = generateRequestId()
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const credentialId = searchParams.get('credentialId')
const fileId = searchParams.get('fileId')
const workflowId = searchParams.get('workflowId') || undefined
if (!credentialId || !fileId) {
return NextResponse.json({ error: 'Credential ID and File ID are required' }, { status: 400 })
@@ -35,19 +27,27 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: fileIdValidation.error }, { status: 400 })
}
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
const authz = await authorizeCredentialUse(request, {
credentialId,
workflowId,
requireWorkflowIdForInternal: false,
})
if (!credentials.length) {
if (!authz.ok || !authz.credentialOwnerUserId) {
const status = authz.error === 'Credential not found' ? 404 : 403
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status })
}
const credential = await getCredential(requestId, credentialId, authz.credentialOwnerUserId)
if (!credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const credential = credentials[0]
if (credential.userId !== session.user.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
const accessToken = await refreshAccessTokenIfNeeded(
credentialId,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })

View File

@@ -1,11 +1,8 @@
import { db } from '@sim/db'
import { account } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { createLogger } from '@/lib/logs/console/logger'
import { generateRequestId } from '@/lib/utils'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { getCredential, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
@@ -18,46 +15,39 @@ export async function GET(request: NextRequest) {
const requestId = generateRequestId()
try {
// Get the session
const session = await getSession()
// Check if the user is authenticated
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthenticated request rejected`)
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}
// Get the credential ID from the query params
const { searchParams } = new URL(request.url)
const credentialId = searchParams.get('credentialId')
const query = searchParams.get('query') || ''
const workflowId = searchParams.get('workflowId') || undefined
if (!credentialId) {
logger.warn(`[${requestId}] Missing credential ID`)
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
}
// Get the credential from the database
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
const authz = await authorizeCredentialUse(request, {
credentialId,
workflowId,
requireWorkflowIdForInternal: false,
})
if (!credentials.length) {
logger.warn(`[${requestId}] Credential not found`, { credentialId })
if (!authz.ok || !authz.credentialOwnerUserId) {
const status = authz.error === 'Credential not found' ? 404 : 403
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status })
}
const credential = await getCredential(requestId, credentialId, authz.credentialOwnerUserId)
if (!credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const credential = credentials[0]
// Check if the credential belongs to the user
if (credential.userId !== session.user.id) {
logger.warn(`[${requestId}] Unauthorized credential access attempt`, {
credentialUserId: credential.userId,
requestUserId: session.user.id,
})
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
// Refresh access token if needed using the utility function
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
const accessToken = await refreshAccessTokenIfNeeded(
credentialId,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })

View File

@@ -1,8 +1,8 @@
import { render } from '@react-email/components'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import CareersConfirmationEmail from '@/components/emails/careers-confirmation-email'
import CareersSubmissionEmail from '@/components/emails/careers-submission-email'
import CareersConfirmationEmail from '@/components/emails/careers/careers-confirmation-email'
import CareersSubmissionEmail from '@/components/emails/careers/careers-submission-email'
import { sendEmail } from '@/lib/email/mailer'
import { createLogger } from '@/lib/logs/console/logger'
import { generateRequestId } from '@/lib/utils'

View File

@@ -1,6 +1,6 @@
import { db } from '@sim/db'
import { webhook as webhookTable, workflow as workflowTable } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import { and, eq, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { verifyCronAuth } from '@/lib/auth/internal'
import { createLogger } from '@/lib/logs/console/logger'
@@ -35,7 +35,15 @@ export async function GET(request: NextRequest) {
})
.from(webhookTable)
.innerJoin(workflowTable, eq(webhookTable.workflowId, workflowTable.id))
.where(and(eq(webhookTable.isActive, true), eq(webhookTable.provider, 'microsoftteams')))
.where(
and(
eq(webhookTable.isActive, true),
or(
eq(webhookTable.provider, 'microsoft-teams'),
eq(webhookTable.provider, 'microsoftteams')
)
)
)
logger.info(
`Found ${webhooksWithWorkflows.length} active Teams webhooks, checking for expiring subscriptions`

View File

@@ -137,7 +137,7 @@ export async function POST(request: NextRequest) {
const isCredentialBased = credentialBasedProviders.includes(provider)
// Treat Microsoft Teams chat subscription as credential-based for path generation purposes
const isMicrosoftTeamsChatSubscription =
provider === 'microsoftteams' &&
provider === 'microsoft-teams' &&
typeof providerConfig === 'object' &&
providerConfig?.triggerId === 'microsoftteams_chat_subscription'
@@ -297,7 +297,7 @@ export async function POST(request: NextRequest) {
}
}
if (provider === 'microsoftteams') {
if (provider === 'microsoft-teams') {
const { createTeamsSubscription } = await import('@/lib/webhooks/webhook-helpers')
logger.info(`[${requestId}] Creating Teams subscription before saving to database`)
try {

View File

@@ -441,7 +441,7 @@ export async function GET(request: NextRequest) {
})
}
case 'microsoftteams': {
case 'microsoft-teams': {
const hmacSecret = providerConfig.hmacSecret
if (!hmacSecret) {

View File

@@ -1,7 +1,5 @@
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { createLogger } from '@/lib/logs/console/logger'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { generateRequestId } from '@/lib/utils'
import {
checkRateLimits,
@@ -139,34 +137,10 @@ export async function POST(
if (foundWebhook.blockId) {
const blockExists = await blockExistsInDeployment(foundWorkflow.id, foundWebhook.blockId)
if (!blockExists) {
logger.warn(
logger.info(
`[${requestId}] Trigger block ${foundWebhook.blockId} not found in deployment for workflow ${foundWorkflow.id}`
)
const executionId = uuidv4()
const loggingSession = new LoggingSession(foundWorkflow.id, executionId, 'webhook', requestId)
const actorUserId = foundWorkflow.workspaceId
? (await import('@/lib/workspaces/utils')).getWorkspaceBilledAccountUserId(
foundWorkflow.workspaceId
) || foundWorkflow.userId
: foundWorkflow.userId
await loggingSession.safeStart({
userId: actorUserId,
workspaceId: foundWorkflow.workspaceId || '',
variables: {},
})
await loggingSession.safeCompleteWithError({
error: {
message: `Trigger block not deployed. The webhook trigger (block ${foundWebhook.blockId}) is not present in the deployed workflow. Please redeploy the workflow.`,
stackTrace: undefined,
},
traceSpans: [],
})
return new NextResponse('Trigger block not deployed', { status: 404 })
return new NextResponse('Trigger block not found in deployment', { status: 404 })
}
}

View File

@@ -15,6 +15,7 @@ import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
import { type ExecutionEvent, encodeSSEEvent } from '@/lib/workflows/executor/execution-events'
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
import { createStreamingResponse } from '@/lib/workflows/streaming'
import { createHttpResponseFromBlock, workflowHasResponseBlock } from '@/lib/workflows/utils'
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
import { type ExecutionMetadata, ExecutionSnapshot } from '@/executor/execution/snapshot'
import type { StreamingExecution } from '@/executor/types'
@@ -495,6 +496,11 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
loggingSession,
})
const hasResponseBlock = workflowHasResponseBlock(result)
if (hasResponseBlock) {
return createHttpResponseFromBlock(result)
}
const filteredResult = {
success: result.success,
output: result.output,

View File

@@ -118,18 +118,18 @@ export async function POST(req: NextRequest) {
logger.info(`[${requestId}] Creating workflow ${workflowId} for user ${session.user.id}`)
// Track workflow creation
try {
const { trackPlatformEvent } = await import('@/lib/telemetry/tracer')
trackPlatformEvent('platform.workflow.created', {
'workflow.id': workflowId,
'workflow.name': name,
'workflow.has_workspace': !!workspaceId,
'workflow.has_folder': !!folderId,
import('@/lib/telemetry/tracer')
.then(({ trackPlatformEvent }) => {
trackPlatformEvent('platform.workflow.created', {
'workflow.id': workflowId,
'workflow.name': name,
'workflow.has_workspace': !!workspaceId,
'workflow.has_folder': !!folderId,
})
})
.catch(() => {
// Silently fail
})
} catch (_e) {
// Silently fail
}
await db.insert(workflow).values({
id: workflowId,

View File

@@ -74,30 +74,6 @@
animation: dash-animation 1.5s linear infinite !important;
}
/**
* Active block ring animation - cycles through gray tones using box-shadow
*/
@keyframes ring-pulse-colors {
0%,
100% {
box-shadow: 0 0 0 4px var(--surface-14);
}
33% {
box-shadow: 0 0 0 4px var(--surface-12);
}
66% {
box-shadow: 0 0 0 4px var(--surface-15);
}
}
.dark .animate-ring-pulse {
animation: ring-pulse-colors 2s ease-in-out infinite !important;
}
.light .animate-ring-pulse {
animation: ring-pulse-colors 2s ease-in-out infinite !important;
}
/**
* Dark color tokens - single source of truth for all colors (dark-only)
*/
@@ -135,6 +111,7 @@
--border-strong: #d1d1d1;
--divider: #e5e5e5;
--border-muted: #eeeeee;
--border-success: #d5d5d5;
/* Brand & state */
--brand-400: #8e4cfb;
@@ -250,6 +227,7 @@
--border-strong: #303030;
--divider: #393939;
--border-muted: #424242;
--border-success: #575757;
/* Brand & state */
--brand-400: #8e4cfb;

View File

@@ -34,9 +34,9 @@ import { createLogger } from '@/lib/logs/console/logger'
import { getBaseUrl } from '@/lib/urls/utils'
import { cn } from '@/lib/utils'
import type { CredentialRequirement } from '@/lib/workflows/credential-extractor'
import type { Template } from '@/app/templates/templates'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
import { getBlock } from '@/blocks/registry'
import { useStarTemplate, useTemplate } from '@/hooks/queries/templates'
const logger = createLogger('TemplateDetails')
@@ -52,16 +52,14 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
const workspaceId = isWorkspaceContext ? (params?.workspaceId as string) : null
const { data: session } = useSession()
const [template, setTemplate] = useState<Template | null>(null)
const { data: template, isLoading: loading } = useTemplate(templateId)
const starTemplate = useStarTemplate()
const [currentUserOrgs, setCurrentUserOrgs] = useState<string[]>([])
const [currentUserOrgRoles, setCurrentUserOrgRoles] = useState<
Array<{ organizationId: string; role: string }>
>([])
const [isSuperUser, setIsSuperUser] = useState(false)
const [loading, setLoading] = useState(true)
const [isStarred, setIsStarred] = useState(false)
const [starCount, setStarCount] = useState(0)
const [isStarring, setIsStarring] = useState(false)
const [isUsing, setIsUsing] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const [isApproving, setIsApproving] = useState(false)
@@ -76,29 +74,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
const currentUserId = session?.user?.id || null
// Fetch template data on client side
useEffect(() => {
if (!templateId) {
setLoading(false)
return
}
const fetchTemplate = async () => {
try {
const response = await fetch(`/api/templates/${templateId}`)
if (response.ok) {
const data = await response.json()
setTemplate(data.data)
setIsStarred(data.data.isStarred || false)
setStarCount(data.data.stars || 0)
}
} catch (error) {
logger.error('Error fetching template:', error)
} finally {
setLoading(false)
}
}
const fetchUserOrganizations = async () => {
if (!currentUserId) return
@@ -134,12 +110,10 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
}
}
fetchTemplate()
fetchSuperUserStatus()
fetchUserOrganizations()
}, [templateId, currentUserId])
}, [currentUserId])
// Fetch workspaces when user is logged in
useEffect(() => {
if (!currentUserId) return
@@ -149,7 +123,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
const response = await fetch('/api/workspaces')
if (response.ok) {
const data = await response.json()
// Filter workspaces where user has write/admin permissions
const availableWorkspaces = data.workspaces
.filter((ws: any) => ws.permissions === 'write' || ws.permissions === 'admin')
.map((ws: any) => ({
@@ -169,7 +142,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
fetchWorkspaces()
}, [currentUserId])
// Clean up URL when returning from login
useEffect(() => {
if (template && searchParams?.get('use') === 'true' && currentUserId) {
if (isWorkspaceContext && workspaceId) {
@@ -181,26 +153,20 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
}
}, [searchParams, currentUserId, template, isWorkspaceContext, workspaceId, router])
// Check if user can edit template
const canEditTemplate = (() => {
if (!currentUserId || !template?.creator) return false
// For user creator profiles: must be the user themselves
if (template.creator.referenceType === 'user') {
return template.creator.referenceId === currentUserId
}
// For organization creator profiles:
if (template.creator.referenceType === 'organization' && template.creator.referenceId) {
const isOrgMember = currentUserOrgs.includes(template.creator.referenceId)
// If template has a connected workflow, any org member with workspace access can edit
if (template.workflowId) {
return isOrgMember
}
// If template is orphaned, only admin/owner can edit
// We need to check the user's role in the organization
const orgMembership = currentUserOrgRoles.find(
(org) => org.organizationId === template.creator?.referenceId
)
@@ -212,7 +178,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
return false
})()
// Check workspace access for connected workflow
useEffect(() => {
const checkWorkspaceAccess = async () => {
if (!template?.workflowId || !currentUserId || !canEditTemplate) {
@@ -227,7 +192,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
} else if (checkResponse.ok) {
setHasWorkspaceAccess(true)
} else {
// Workflow doesn't exist
setHasWorkspaceAccess(null)
}
} catch (error) {
@@ -319,32 +283,20 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
* @param event - The wheel event fired when the user scrolls over the preview area.
*/
const handleCanvasWheelCapture = (event: React.WheelEvent<HTMLDivElement>) => {
// Allow pinch/zoom gestures (e.g., ctrl/cmd + wheel) to continue to the canvas.
if (event.ctrlKey || event.metaKey) {
return
}
// Prevent React Flow from handling the wheel; let the page scroll naturally.
event.stopPropagation()
}
const handleStarToggle = async () => {
if (isStarring || !currentUserId) return
if (!currentUserId || !template) return
setIsStarring(true)
try {
const method = isStarred ? 'DELETE' : 'POST'
const response = await fetch(`/api/templates/${template.id}/star`, { method })
if (response.ok) {
setIsStarred(!isStarred)
setStarCount((prev) => (isStarred ? prev - 1 : prev + 1))
}
} catch (error) {
logger.error('Error toggling star:', error)
} finally {
setIsStarring(false)
}
starTemplate.mutate({
templateId: template.id,
action: template.isStarred ? 'remove' : 'add',
})
}
const handleUseTemplate = () => {
@@ -357,7 +309,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
return
}
// In workspace context, use current workspace directly
if (isWorkspaceContext && workspaceId) {
handleWorkspaceSelectForUse(workspaceId)
}
@@ -366,7 +317,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
const handleEditTemplate = async () => {
if (!currentUserId || !template) return
// In workspace context with existing workflow, navigate directly
if (isWorkspaceContext && workspaceId && template.workflowId) {
setIsEditing(true)
try {
@@ -381,10 +331,8 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
} finally {
setIsEditing(false)
}
// If workflow doesn't exist, fall through to workspace selector
}
// Check if workflow exists and user has access (global context)
if (template.workflowId && !isWorkspaceContext) {
setIsEditing(true)
try {
@@ -410,7 +358,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
}
}
// Workflow doesn't exist - show workspace selector or use current workspace
if (isWorkspaceContext && workspaceId) {
handleWorkspaceSelectForEdit(workspaceId)
} else {
@@ -435,7 +382,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
const { workflowId } = await response.json()
// Navigate to the new workflow with full page load
window.location.href = `/workspace/${workspaceId}/w/${workflowId}`
} catch (error) {
logger.error('Error using template:', error)
@@ -450,7 +396,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
setIsUsing(true)
setShowWorkspaceSelectorForEdit(false)
try {
// Import template as a new workflow and connect it to the template
const response = await fetch(`/api/templates/${template.id}/use`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -463,7 +408,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
const { workflowId } = await response.json()
// Navigate to the new workflow with full page load
window.location.href = `/workspace/${workspaceId}/w/${workflowId}`
} catch (error) {
logger.error('Error importing template for editing:', error)
@@ -482,9 +426,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
})
if (response.ok) {
// Update template status optimistically
setTemplate({ ...template, status: 'approved' })
// Redirect back to templates page after approval
if (isWorkspaceContext && workspaceId) {
router.push(`/workspace/${workspaceId}/templates`)
} else {
@@ -508,9 +449,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
})
if (response.ok) {
// Update template status optimistically
setTemplate({ ...template, status: 'rejected' })
// Redirect back to templates page after rejection
if (isWorkspaceContext && workspaceId) {
router.push(`/workspace/${workspaceId}/templates`)
} else {
@@ -752,11 +690,11 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
onClick={handleStarToggle}
className={cn(
'h-[14px] w-[14px] cursor-pointer transition-colors',
isStarred ? 'fill-yellow-500 text-yellow-500' : 'text-[#888888]',
isStarring && 'opacity-50'
template.isStarred ? 'fill-yellow-500 text-yellow-500' : 'text-[#888888]',
starTemplate.isPending && 'opacity-50'
)}
/>
<span className='font-medium text-[#888888] text-[14px]'>{starCount}</span>
<span className='font-medium text-[#888888] text-[14px]'>{template.stars || 0}</span>
{/* Users icon and count */}
<ChartNoAxesColumn className='h-[16px] w-[16px] text-[#888888]' />

View File

@@ -5,6 +5,7 @@ import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
import { getBlock } from '@/blocks/registry'
import { useStarTemplate } from '@/hooks/queries/templates'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('TemplateCard')
@@ -12,37 +13,20 @@ const logger = createLogger('TemplateCard')
interface TemplateCardProps {
id: string
title: string
description: string
author: string
authorImageUrl?: string | null
usageCount: string
stars?: number
icon?: React.ReactNode | string
iconColor?: string
blocks?: string[]
onClick?: () => void
className?: string
// Workflow state for rendering preview
state?: WorkflowState
isStarred?: boolean
// Optional callback when template is successfully used (for closing modals, etc.)
onTemplateUsed?: () => void
// Callback when star state changes (for parent state updates)
onStarChange?: (templateId: string, isStarred: boolean, newStarCount: number) => void
// User authentication status
isAuthenticated?: boolean
}
/**
* Skeleton component for loading states
*/
export function TemplateCardSkeleton({ className }: { className?: string }) {
return (
<div className={cn('h-[268px] w-full rounded-[8px] bg-[#202020] p-[8px]', className)}>
{/* Workflow preview skeleton */}
<div className='h-[180px] w-full animate-pulse rounded-[6px] bg-gray-700' />
{/* Title and blocks row skeleton */}
<div className='mt-[14px] flex items-center justify-between'>
<div className='h-4 w-32 animate-pulse rounded bg-gray-700' />
<div className='flex items-center gap-[-4px]'>
@@ -55,7 +39,6 @@ export function TemplateCardSkeleton({ className }: { className?: string }) {
</div>
</div>
{/* Creator and stats row skeleton */}
<div className='mt-[14px] flex items-center justify-between'>
<div className='flex items-center gap-[8px]'>
<div className='h-[14px] w-[14px] animate-pulse rounded-full bg-gray-700' />
@@ -72,31 +55,23 @@ export function TemplateCardSkeleton({ className }: { className?: string }) {
)
}
// Utility function to extract block types from workflow state
const extractBlockTypesFromState = (state?: {
blocks?: Record<string, { type: string; name?: string }>
}): string[] => {
if (!state?.blocks) return []
// Get unique block types from the state, excluding starter blocks
// Sort the keys to ensure consistent ordering between server and client
const blockTypes = Object.keys(state.blocks)
.sort() // Sort keys to ensure consistent order
.sort()
.map((key) => state.blocks![key].type)
.filter((type) => type !== 'starter')
return [...new Set(blockTypes)]
}
// Utility function to get the full block config for colored icon display
const getBlockConfig = (blockType: string) => {
const block = getBlock(blockType)
return block
}
/**
* Normalize an arbitrary workflow-like object into a valid WorkflowState for preview rendering.
* Ensures required fields exist: blocks with required properties, edges array, loops and parallels maps.
*/
function normalizeWorkflowState(input?: any): WorkflowState | null {
if (!input || !input.blocks) return null
@@ -142,34 +117,22 @@ function normalizeWorkflowState(input?: any): WorkflowState | null {
function TemplateCardInner({
id,
title,
description,
author,
authorImageUrl,
usageCount,
stars = 0,
icon,
iconColor = 'bg-blue-500',
blocks = [],
onClick,
className,
state,
isStarred = false,
onTemplateUsed,
onStarChange,
isAuthenticated = true,
}: TemplateCardProps) {
const router = useRouter()
const params = useParams()
// Local state for optimistic updates
const [localIsStarred, setLocalIsStarred] = useState(isStarred)
const [localStarCount, setLocalStarCount] = useState(stars)
const [isStarLoading, setIsStarLoading] = useState(false)
const { mutate: toggleStar, isPending: isStarLoading } = useStarTemplate()
// Memoize normalized workflow state to avoid recalculation on every render
const normalizedState = useMemo(() => normalizeWorkflowState(state), [state])
// Use IntersectionObserver to defer rendering the heavy WorkflowPreview until in viewport
const previewRef = useRef<HTMLDivElement | null>(null)
const [isInView, setIsInView] = useState(false)
@@ -188,9 +151,6 @@ function TemplateCardInner({
return () => observer.disconnect()
}, [])
// Extract block types from state if provided, otherwise use the blocks prop
// Filter out starter blocks in both cases and sort for consistent rendering
// Memoized to prevent recalculation on every render
const blockTypes = useMemo(
() =>
state
@@ -199,65 +159,16 @@ function TemplateCardInner({
[state, blocks]
)
// Handle star toggle with optimistic updates
const handleStarClick = async (e: React.MouseEvent) => {
const handleStarClick = (e: React.MouseEvent) => {
e.stopPropagation()
// Prevent multiple clicks while loading
if (isStarLoading) return
setIsStarLoading(true)
// Optimistic update - update UI immediately
const newIsStarred = !localIsStarred
const newStarCount = newIsStarred ? localStarCount + 1 : localStarCount - 1
setLocalIsStarred(newIsStarred)
setLocalStarCount(newStarCount)
// Notify parent component immediately for optimistic update
if (onStarChange) {
onStarChange(id, newIsStarred, newStarCount)
}
try {
const method = localIsStarred ? 'DELETE' : 'POST'
const response = await fetch(`/api/templates/${id}/star`, { method })
if (!response.ok) {
// Rollback on error
setLocalIsStarred(localIsStarred)
setLocalStarCount(localStarCount)
// Rollback parent state too
if (onStarChange) {
onStarChange(id, localIsStarred, localStarCount)
}
logger.error('Failed to toggle star:', response.statusText)
}
} catch (error) {
// Rollback on error
setLocalIsStarred(localIsStarred)
setLocalStarCount(localStarCount)
// Rollback parent state too
if (onStarChange) {
onStarChange(id, localIsStarred, localStarCount)
}
logger.error('Error toggling star:', error)
} finally {
setIsStarLoading(false)
}
toggleStar({
templateId: id,
action: isStarred ? 'remove' : 'add',
})
}
/**
* Get the appropriate template detail page URL based on context.
* If we're in a workspace context, navigate to the workspace template page.
* Otherwise, navigate to the global template page.
* Memoized to avoid recalculation on every render.
*/
const templateUrl = useMemo(() => {
const workspaceId = params?.workspaceId as string | undefined
if (workspaceId) {
@@ -266,23 +177,8 @@ function TemplateCardInner({
return `/templates/${id}`
}, [params?.workspaceId, id])
/**
* Handle use button click - navigate to template detail page
*/
const handleUseClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
router.push(templateUrl)
},
[router, templateUrl]
)
/**
* Handle card click - navigate to template detail page
*/
const handleCardClick = useCallback(
(e: React.MouseEvent) => {
// Don't navigate if clicking on action buttons
const target = e.target as HTMLElement
if (target.closest('button') || target.closest('[data-action]')) {
return
@@ -298,7 +194,6 @@ function TemplateCardInner({
onClick={handleCardClick}
className={cn('w-full cursor-pointer rounded-[8px] bg-[#202020] p-[8px]', className)}
>
{/* Workflow Preview */}
<div
ref={previewRef}
className='pointer-events-none h-[180px] w-full overflow-hidden rounded-[6px]'
@@ -318,16 +213,12 @@ function TemplateCardInner({
)}
</div>
{/* Title and Blocks Row */}
<div className='mt-[10px] flex items-center justify-between'>
{/* Template Name */}
<h3 className='truncate pr-[8px] pl-[2px] font-medium text-[16px] text-white'>{title}</h3>
{/* Block Icons */}
<div className='flex flex-shrink-0'>
{blockTypes.length > 4 ? (
<>
{/* Show first 3 blocks when there are more than 4 */}
{blockTypes.slice(0, 3).map((blockType, index) => {
const blockConfig = getBlockConfig(blockType)
if (!blockConfig) return null
@@ -345,7 +236,6 @@ function TemplateCardInner({
</div>
)
})}
{/* Show +n for remaining blocks */}
<div
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-[4px] bg-[#4A4A4A]'
style={{ marginLeft: '-4px' }}
@@ -354,7 +244,6 @@ function TemplateCardInner({
</div>
</>
) : (
/* Show all blocks when 4 or fewer */
blockTypes.map((blockType, index) => {
const blockConfig = getBlockConfig(blockType)
if (!blockConfig) return null
@@ -376,9 +265,7 @@ function TemplateCardInner({
</div>
</div>
{/* Creator and Stats Row */}
<div className='mt-[10px] flex items-center justify-between'>
{/* Creator Info */}
<div className='flex items-center gap-[8px]'>
{authorImageUrl ? (
<div className='h-[26px] w-[26px] flex-shrink-0 overflow-hidden rounded-full'>
@@ -392,7 +279,6 @@ function TemplateCardInner({
<span className='truncate font-medium text-[#888888] text-[12px]'>{author}</span>
</div>
{/* Stats */}
<div className='flex flex-shrink-0 items-center gap-[6px] font-medium text-[#888888] text-[12px]'>
<User className='h-[12px] w-[12px]' />
<span>{usageCount}</span>
@@ -400,11 +286,11 @@ function TemplateCardInner({
onClick={handleStarClick}
className={cn(
'h-[12px] w-[12px] cursor-pointer transition-colors',
localIsStarred ? 'fill-yellow-500 text-yellow-500' : 'text-[#888888]',
isStarred ? 'fill-yellow-500 text-yellow-500' : 'text-[#888888]',
isStarLoading && 'opacity-50'
)}
/>
<span>{localStarCount}</span>
<span>{stars}</span>
</div>
</div>
</div>

View File

@@ -8,6 +8,7 @@ import { Input } from '@/components/ui/input'
import { createLogger } from '@/lib/logs/console/logger'
import type { CredentialRequirement } from '@/lib/workflows/credential-extractor'
import { TemplateCard, TemplateCardSkeleton } from '@/app/templates/components/template-card'
import { useDebounce } from '@/hooks/use-debounce'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import type { CreatorProfileDetails } from '@/types/creator-profile'
@@ -55,11 +56,11 @@ export default function Templates({
}: TemplatesProps) {
const router = useRouter()
const [searchQuery, setSearchQuery] = useState('')
const debouncedSearchQuery = useDebounce(searchQuery, 300)
const [activeTab, setActiveTab] = useState('gallery')
const [templates, setTemplates] = useState<Template[]>(initialTemplates)
const [loading, setLoading] = useState(false)
// Redirect authenticated users to workspace templates
useEffect(() => {
if (currentUserId) {
const redirectToWorkspace = async () => {
@@ -80,32 +81,19 @@ export default function Templates({
}
}, [currentUserId, router])
/**
* Update star status for a template
*/
const handleStarChange = (templateId: string, isStarred: boolean, newStarCount: number) => {
setTemplates((prevTemplates) =>
prevTemplates.map((template) =>
template.id === templateId ? { ...template, isStarred, stars: newStarCount } : template
)
)
}
/**
* Filter templates based on active tab and search query
* Memoized to prevent unnecessary recalculations on render
*/
const filteredTemplates = useMemo(() => {
const query = searchQuery.toLowerCase()
const query = debouncedSearchQuery.toLowerCase()
return templates.filter((template) => {
// Filter by tab - only gallery and pending for public page
const tabMatch =
activeTab === 'gallery' ? template.status === 'approved' : template.status === 'pending'
if (!tabMatch) return false
// Filter by search query
if (!query) return true
const searchableText = [template.name, template.details?.tagline, template.creator?.name]
@@ -115,14 +103,14 @@ export default function Templates({
return searchableText.includes(query)
})
}, [templates, activeTab, searchQuery])
}, [templates, activeTab, debouncedSearchQuery])
/**
* Get empty state message based on current filters
* Memoized to prevent unnecessary recalculations on render
*/
const emptyState = useMemo(() => {
if (searchQuery) {
if (debouncedSearchQuery) {
return {
title: 'No templates found',
description: 'Try a different search term',
@@ -141,7 +129,7 @@ export default function Templates({
}
return messages[activeTab as keyof typeof messages] || messages.gallery
}, [searchQuery, activeTab])
}, [debouncedSearchQuery, activeTab])
return (
<div className='flex h-[100vh] flex-col'>
@@ -209,15 +197,12 @@ export default function Templates({
key={template.id}
id={template.id}
title={template.name}
description={template.details?.tagline || ''}
author={template.creator?.name || 'Unknown'}
authorImageUrl={template.creator?.profileImageUrl || null}
usageCount={template.views.toString()}
stars={template.stars}
state={template.state}
isStarred={template.isStarred}
onStarChange={handleStarChange}
isAuthenticated={!!currentUserId}
/>
))
)}

View File

@@ -23,6 +23,7 @@ import '@/components/emcn/components/code/code.css'
interface LogSidebarProps {
log: WorkflowLog | null
isOpen: boolean
isLoadingDetails?: boolean
onClose: () => void
onNavigateNext?: () => void
onNavigatePrev?: () => void
@@ -192,6 +193,7 @@ const BlockContentDisplay = ({
export function Sidebar({
log,
isOpen,
isLoadingDetails = false,
onClose,
onNavigateNext,
onNavigatePrev,
@@ -219,15 +221,6 @@ export function Sidebar({
}
}, [log?.id])
const isLoadingDetails = useMemo(() => {
if (!log) return false
// Only show while we expect details to arrive (has executionId)
if (!log.executionId) return false
const hasEnhanced = !!log.executionData?.enhanced
const hasAnyDetails = hasEnhanced || !!log.cost || Array.isArray(log.executionData?.traceSpans)
return !hasAnyDetails
}, [log])
const formattedContent = useMemo(() => {
if (!log) return null

View File

@@ -3,7 +3,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Loader2 } from 'lucide-react'
import { useParams, useRouter, useSearchParams } from 'next/navigation'
import { createLogger } from '@/lib/logs/console/logger'
import { soehne } from '@/app/fonts/soehne/soehne'
import Controls from '@/app/workspace/[workspaceId]/logs/components/dashboard/controls'
import KPIs from '@/app/workspace/[workspaceId]/logs/components/dashboard/kpis'
@@ -11,12 +10,15 @@ import WorkflowDetails from '@/app/workspace/[workspaceId]/logs/components/dashb
import WorkflowsList from '@/app/workspace/[workspaceId]/logs/components/dashboard/workflows-list'
import Timeline from '@/app/workspace/[workspaceId]/logs/components/filters/components/timeline'
import { mapToExecutionLog, mapToExecutionLogAlt } from '@/app/workspace/[workspaceId]/logs/utils'
import {
useExecutionsMetrics,
useGlobalDashboardLogs,
useWorkflowDashboardLogs,
} from '@/hooks/queries/logs'
import { formatCost } from '@/providers/utils'
import { useFilterStore } from '@/stores/logs/filters/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('Dashboard')
type TimeFilter = '30m' | '1h' | '6h' | '12h' | '24h' | '3d' | '7d' | '14d' | '30d'
interface WorkflowExecution {
@@ -59,15 +61,6 @@ interface ExecutionLog {
workflowColor?: string
}
interface WorkflowDetailsDataLocal {
errorRates: { timestamp: string; value: number }[]
durations: { timestamp: string; value: number }[]
executionCounts: { timestamp: string; value: number }[]
logs: ExecutionLog[]
allLogs: ExecutionLog[]
__meta?: { offset: number; hasMore: boolean }
}
export default function Dashboard() {
const params = useParams()
const workspaceId = params.workspaceId as string
@@ -99,23 +92,7 @@ export default function Dashboard() {
}
}
const [endTime, setEndTime] = useState<Date>(new Date())
const [executions, setExecutions] = useState<WorkflowExecution[]>([])
const [loading, setLoading] = useState(true)
const [isRefetching, setIsRefetching] = useState(false)
const [error, setError] = useState<string | null>(null)
const [expandedWorkflowId, setExpandedWorkflowId] = useState<string | null>(null)
const [workflowDetails, setWorkflowDetails] = useState<Record<string, WorkflowDetailsDataLocal>>(
{}
)
const [globalDetails, setGlobalDetails] = useState<WorkflowDetailsDataLocal | null>(null)
const [globalLogsMeta, setGlobalLogsMeta] = useState<{ offset: number; hasMore: boolean }>({
offset: 0,
hasMore: true,
})
const [globalLoadingMore, setGlobalLoadingMore] = useState(false)
const [aggregateSegments, setAggregateSegments] = useState<
{ timestamp: string; totalExecutions: number; successfulExecutions: number }[]
>([])
const [selectedSegments, setSelectedSegments] = useState<Record<string, number[]>>({})
const [lastAnchorIndices, setLastAnchorIndices] = useState<Record<string, number>>({})
const [searchQuery, setSearchQuery] = useState('')
@@ -135,6 +112,134 @@ export default function Dashboard() {
const timeFilter = getTimeFilterFromRange(sidebarTimeRange)
const getStartTime = useCallback(() => {
const start = new Date(endTime)
switch (timeFilter) {
case '30m':
start.setMinutes(endTime.getMinutes() - 30)
break
case '1h':
start.setHours(endTime.getHours() - 1)
break
case '6h':
start.setHours(endTime.getHours() - 6)
break
case '12h':
start.setHours(endTime.getHours() - 12)
break
case '24h':
start.setHours(endTime.getHours() - 24)
break
case '3d':
start.setDate(endTime.getDate() - 3)
break
case '7d':
start.setDate(endTime.getDate() - 7)
break
case '14d':
start.setDate(endTime.getDate() - 14)
break
case '30d':
start.setDate(endTime.getDate() - 30)
break
default:
start.setHours(endTime.getHours() - 24)
}
return start
}, [endTime, timeFilter])
const metricsFilters = useMemo(
() => ({
workspaceId,
segments: segmentCount || DEFAULT_SEGMENTS,
startTime: getStartTime().toISOString(),
endTime: endTime.toISOString(),
workflowIds: workflowIds.length > 0 ? workflowIds : undefined,
folderIds: folderIds.length > 0 ? folderIds : undefined,
triggers: triggers.length > 0 ? triggers : undefined,
}),
[workspaceId, segmentCount, getStartTime, endTime, workflowIds, folderIds, triggers]
)
const logsFilters = useMemo(
() => ({
workspaceId,
startDate: getStartTime().toISOString(),
endDate: endTime.toISOString(),
workflowIds: workflowIds.length > 0 ? workflowIds : undefined,
folderIds: folderIds.length > 0 ? folderIds : undefined,
triggers: triggers.length > 0 ? triggers : undefined,
limit: 50,
}),
[workspaceId, getStartTime, endTime, workflowIds, folderIds, triggers]
)
const metricsQuery = useExecutionsMetrics(metricsFilters, {
enabled: Boolean(workspaceId),
})
const globalLogsQuery = useGlobalDashboardLogs(logsFilters, {
enabled: Boolean(workspaceId),
})
const workflowLogsQuery = useWorkflowDashboardLogs(expandedWorkflowId ?? undefined, logsFilters, {
enabled: Boolean(workspaceId) && Boolean(expandedWorkflowId),
})
const executions = metricsQuery.data?.workflows ?? []
const aggregateSegments = metricsQuery.data?.aggregateSegments ?? []
const loading = metricsQuery.isLoading
const isRefetching = metricsQuery.isFetching && !metricsQuery.isLoading
const error = metricsQuery.error?.message ?? null
const globalLogs = useMemo(() => {
if (!globalLogsQuery.data?.pages) return []
return globalLogsQuery.data.pages.flatMap((page) => page.logs).map(mapToExecutionLog)
}, [globalLogsQuery.data?.pages])
const workflowLogs = useMemo(() => {
if (!workflowLogsQuery.data?.pages) return []
return workflowLogsQuery.data.pages.flatMap((page) => page.logs).map(mapToExecutionLogAlt)
}, [workflowLogsQuery.data?.pages])
const globalDetails = useMemo(() => {
if (!aggregateSegments.length) return null
const errorRates = aggregateSegments.map((s) => ({
timestamp: s.timestamp,
value: s.totalExecutions > 0 ? (1 - s.successfulExecutions / s.totalExecutions) * 100 : 0,
}))
const executionCounts = aggregateSegments.map((s) => ({
timestamp: s.timestamp,
value: s.totalExecutions,
}))
return {
errorRates,
durations: [],
executionCounts,
logs: globalLogs,
allLogs: globalLogs,
}
}, [aggregateSegments, globalLogs])
const workflowDetails = useMemo(() => {
if (!expandedWorkflowId || !workflowLogs.length) return {}
return {
[expandedWorkflowId]: {
errorRates: [],
durations: [],
executionCounts: [],
logs: workflowLogs,
allLogs: workflowLogs,
},
}
}, [expandedWorkflowId, workflowLogs])
useEffect(() => {
const urlView = searchParams.get('view')
if (urlView === 'dashboard' || urlView === 'logs') {
@@ -190,362 +295,24 @@ export default function Dashboard() {
}
}, [executions])
const getStartTime = useCallback(() => {
const start = new Date(endTime)
switch (timeFilter) {
case '30m':
start.setMinutes(endTime.getMinutes() - 30)
break
case '1h':
start.setHours(endTime.getHours() - 1)
break
case '6h':
start.setHours(endTime.getHours() - 6)
break
case '12h':
start.setHours(endTime.getHours() - 12)
break
case '24h':
start.setHours(endTime.getHours() - 24)
break
case '3d':
start.setDate(endTime.getDate() - 3)
break
case '7d':
start.setDate(endTime.getDate() - 7)
break
case '14d':
start.setDate(endTime.getDate() - 14)
break
case '30d':
start.setDate(endTime.getDate() - 30)
break
default:
start.setHours(endTime.getHours() - 24)
}
return start
}, [endTime, timeFilter])
const fetchExecutions = useCallback(
async (isInitialLoad = false) => {
try {
if (isInitialLoad) {
setLoading(true)
} else {
setIsRefetching(true)
}
setError(null)
const startTime = getStartTime()
const params = new URLSearchParams({
segments: String(segmentCount || DEFAULT_SEGMENTS),
startTime: startTime.toISOString(),
endTime: endTime.toISOString(),
})
if (workflowIds.length > 0) {
params.set('workflowIds', workflowIds.join(','))
}
if (folderIds.length > 0) {
params.set('folderIds', folderIds.join(','))
}
if (triggers.length > 0) {
params.set('triggers', triggers.join(','))
}
const response = await fetch(
`/api/workspaces/${workspaceId}/metrics/executions?${params.toString()}`
)
if (!response.ok) {
throw new Error('Failed to fetch execution history')
}
const data = await response.json()
const mapped: WorkflowExecution[] = (data.workflows || []).map((wf: any) => {
const segments = (wf.segments || []).map((s: any) => {
const total = s.totalExecutions || 0
const success = s.successfulExecutions || 0
const hasExecutions = total > 0
const successRate = hasExecutions ? (success / total) * 100 : 100
return {
timestamp: s.timestamp,
hasExecutions,
totalExecutions: total,
successfulExecutions: success,
successRate,
avgDurationMs: typeof s.avgDurationMs === 'number' ? s.avgDurationMs : 0,
p50Ms: typeof s.p50Ms === 'number' ? s.p50Ms : 0,
p90Ms: typeof s.p90Ms === 'number' ? s.p90Ms : 0,
p99Ms: typeof s.p99Ms === 'number' ? s.p99Ms : 0,
}
})
const totals = segments.reduce(
(acc: { total: number; success: number }, seg: (typeof segments)[number]) => {
acc.total += seg.totalExecutions
acc.success += seg.successfulExecutions
return acc
},
{ total: 0, success: 0 }
)
const overallSuccessRate = totals.total > 0 ? (totals.success / totals.total) * 100 : 100
return {
workflowId: wf.workflowId,
workflowName: wf.workflowName,
segments,
overallSuccessRate,
} as WorkflowExecution
})
const sortedWorkflows = mapped.sort((a, b) => {
const errA = a.overallSuccessRate < 100 ? 1 - a.overallSuccessRate / 100 : 0
const errB = b.overallSuccessRate < 100 ? 1 - b.overallSuccessRate / 100 : 0
return errB - errA
})
setExecutions(sortedWorkflows)
const segmentsCount: number = Number(params.get('segments') || DEFAULT_SEGMENTS)
const agg: { timestamp: string; totalExecutions: number; successfulExecutions: number }[] =
Array.from({ length: segmentsCount }, (_, i) => {
const base = startTime.getTime()
const ts = new Date(base + Math.floor((i * (endTime.getTime() - base)) / segmentsCount))
return {
timestamp: ts.toISOString(),
totalExecutions: 0,
successfulExecutions: 0,
}
})
for (const wf of data.workflows as any[]) {
wf.segments.forEach((s: any, i: number) => {
const index = Math.min(i, segmentsCount - 1)
agg[index].totalExecutions += s.totalExecutions || 0
agg[index].successfulExecutions += s.successfulExecutions || 0
})
}
setAggregateSegments(agg)
const errorRates = agg.map((s) => ({
timestamp: s.timestamp,
value: s.totalExecutions > 0 ? (1 - s.successfulExecutions / s.totalExecutions) * 100 : 0,
}))
const executionCounts = agg.map((s) => ({
timestamp: s.timestamp,
value: s.totalExecutions,
}))
const logsParams = new URLSearchParams({
limit: '50',
offset: '0',
workspaceId,
startDate: startTime.toISOString(),
endDate: endTime.toISOString(),
order: 'desc',
details: 'full',
})
if (workflowIds.length > 0) logsParams.set('workflowIds', workflowIds.join(','))
if (folderIds.length > 0) logsParams.set('folderIds', folderIds.join(','))
if (triggers.length > 0) logsParams.set('triggers', triggers.join(','))
const logsResponse = await fetch(`/api/logs?${logsParams.toString()}`)
let mappedLogs: ExecutionLog[] = []
if (logsResponse.ok) {
const logsData = await logsResponse.json()
mappedLogs = (logsData.data || []).map(mapToExecutionLog)
}
setGlobalDetails({
errorRates,
durations: [],
executionCounts,
logs: mappedLogs,
allLogs: mappedLogs,
})
setGlobalLogsMeta({ offset: mappedLogs.length, hasMore: mappedLogs.length === 50 })
} catch (err) {
logger.error('Error fetching executions:', err)
setError(err instanceof Error ? err.message : 'An error occurred')
} finally {
setLoading(false)
setIsRefetching(false)
}
},
[workspaceId, timeFilter, endTime, getStartTime, workflowIds, folderIds, triggers, segmentCount]
)
const fetchWorkflowDetails = useCallback(
async (workflowId: string, silent = false) => {
try {
const startTime = getStartTime()
const params = new URLSearchParams({
startTime: startTime.toISOString(),
endTime: endTime.toISOString(),
})
if (triggers.length > 0) {
params.set('triggers', triggers.join(','))
}
const response = await fetch(
`/api/logs?${new URLSearchParams({
limit: '50',
offset: '0',
workspaceId,
startDate: startTime.toISOString(),
endDate: endTime.toISOString(),
order: 'desc',
details: 'full',
workflowIds: workflowId,
...(triggers.length > 0 ? { triggers: triggers.join(',') } : {}),
}).toString()}`
)
if (!response.ok) {
throw new Error('Failed to fetch workflow details')
}
const data = await response.json()
const mappedLogs: ExecutionLog[] = (data.data || []).map(mapToExecutionLogAlt)
setWorkflowDetails((prev) => ({
...prev,
[workflowId]: {
errorRates: [],
durations: [],
executionCounts: [],
logs: mappedLogs,
allLogs: mappedLogs,
__meta: { offset: mappedLogs.length, hasMore: (data.data || []).length === 50 },
},
}))
} catch (err) {
logger.error('Error fetching workflow details:', err)
}
},
[workspaceId, endTime, getStartTime, triggers]
)
// Infinite scroll for details logs
const loadMoreLogs = useCallback(
async (workflowId: string) => {
const details = (workflowDetails as any)[workflowId]
if (!details) return
if (details.__loading) return
if (!details.__meta?.hasMore) return
try {
// mark loading to prevent duplicate fetches
setWorkflowDetails((prev) => ({
...prev,
[workflowId]: { ...(prev as any)[workflowId], __loading: true },
}))
const startTime = getStartTime()
const offset = details.__meta.offset || 0
const qp = new URLSearchParams({
limit: '50',
offset: String(offset),
workspaceId,
startDate: startTime.toISOString(),
endDate: endTime.toISOString(),
order: 'desc',
details: 'full',
workflowIds: workflowId,
})
if (triggers.length > 0) qp.set('triggers', triggers.join(','))
const res = await fetch(`/api/logs?${qp.toString()}`)
if (!res.ok) return
const data = await res.json()
const more: ExecutionLog[] = (data.data || []).map(mapToExecutionLogAlt)
setWorkflowDetails((prev) => {
const cur = prev[workflowId]
const seen = new Set<string>()
const dedup = [...(cur?.allLogs || []), ...more].filter((x) => {
const id = x.id
if (seen.has(id)) return false
seen.add(id)
return true
})
return {
...prev,
[workflowId]: {
...cur,
logs: dedup,
allLogs: dedup,
__meta: {
offset: (cur?.__meta?.offset || 0) + more.length,
hasMore: more.length === 50,
},
__loading: false,
},
}
})
} catch {
setWorkflowDetails((prev) => ({
...prev,
[workflowId]: { ...(prev as any)[workflowId], __loading: false },
}))
(workflowId: string) => {
if (
workflowId === expandedWorkflowId &&
workflowLogsQuery.hasNextPage &&
!workflowLogsQuery.isFetchingNextPage
) {
workflowLogsQuery.fetchNextPage()
}
},
[workspaceId, endTime, getStartTime, triggers, workflowDetails]
[expandedWorkflowId, workflowLogsQuery]
)
const loadMoreGlobalLogs = useCallback(async () => {
if (!globalDetails || !globalLogsMeta.hasMore) return
if (globalLoadingMore) return
try {
setGlobalLoadingMore(true)
const startTime = getStartTime()
const qp = new URLSearchParams({
limit: '50',
offset: String(globalLogsMeta.offset || 0),
workspaceId,
startDate: startTime.toISOString(),
endDate: endTime.toISOString(),
order: 'desc',
details: 'full',
})
if (workflowIds.length > 0) qp.set('workflowIds', workflowIds.join(','))
if (folderIds.length > 0) qp.set('folderIds', folderIds.join(','))
if (triggers.length > 0) qp.set('triggers', triggers.join(','))
const res = await fetch(`/api/logs?${qp.toString()}`)
if (!res.ok) return
const data = await res.json()
const more: ExecutionLog[] = (data.data || []).map(mapToExecutionLog)
setGlobalDetails((prev) => {
if (!prev) return prev
const seen = new Set<string>()
const dedup = [...prev.allLogs, ...more].filter((x) => {
const id = x.id
if (seen.has(id)) return false
seen.add(id)
return true
})
return { ...prev, logs: dedup, allLogs: dedup }
})
setGlobalLogsMeta((m) => ({
offset: (m.offset || 0) + more.length,
hasMore: more.length === 50,
}))
} catch {
// ignore
} finally {
setGlobalLoadingMore(false)
const loadMoreGlobalLogs = useCallback(() => {
if (globalLogsQuery.hasNextPage && !globalLogsQuery.isFetchingNextPage) {
globalLogsQuery.fetchNextPage()
}
}, [
globalDetails,
globalLogsMeta,
globalLoadingMore,
workspaceId,
endTime,
getStartTime,
workflowIds,
folderIds,
triggers,
])
}, [globalLogsQuery])
const toggleWorkflow = useCallback(
(workflowId: string) => {
@@ -553,12 +320,9 @@ export default function Dashboard() {
setExpandedWorkflowId(null)
} else {
setExpandedWorkflowId(workflowId)
if (!workflowDetails[workflowId]) {
fetchWorkflowDetails(workflowId)
}
}
},
[expandedWorkflowId, workflowDetails, fetchWorkflowDetails]
[expandedWorkflowId]
)
const handleSegmentClick = useCallback(
@@ -568,13 +332,7 @@ export default function Dashboard() {
_timestamp: string,
mode: 'single' | 'toggle' | 'range'
) => {
// Fetch workflow details if not already loaded
if (!workflowDetails[workflowId]) {
fetchWorkflowDetails(workflowId)
}
if (mode === 'toggle') {
// Toggle mode: Add/remove segment from selection, allowing cross-workflow selection
setSelectedSegments((prev) => {
const currentSegments = prev[workflowId] || []
const exists = currentSegments.includes(segmentIndex)
@@ -584,7 +342,6 @@ export default function Dashboard() {
if (nextSegments.length === 0) {
const { [workflowId]: _, ...rest } = prev
// If this was the only workflow with selections, clear expanded
if (Object.keys(rest).length === 0) {
setExpandedWorkflowId(null)
}
@@ -593,7 +350,6 @@ export default function Dashboard() {
const newState = { ...prev, [workflowId]: nextSegments }
// Set to multi-workflow mode if multiple workflows have selections
const selectedWorkflowIds = Object.keys(newState)
if (selectedWorkflowIds.length > 1) {
setExpandedWorkflowId('__multi__')
@@ -606,27 +362,23 @@ export default function Dashboard() {
setLastAnchorIndices((prev) => ({ ...prev, [workflowId]: segmentIndex }))
} else if (mode === 'single') {
// Single mode: Select this segment, or deselect if already selected
setSelectedSegments((prev) => {
const currentSegments = prev[workflowId] || []
const isOnlySelectedSegment =
currentSegments.length === 1 && currentSegments[0] === segmentIndex
const isOnlyWorkflowSelected = Object.keys(prev).length === 1 && prev[workflowId]
// If this is the only selected segment in the only selected workflow, deselect it
if (isOnlySelectedSegment && isOnlyWorkflowSelected) {
setExpandedWorkflowId(null)
setLastAnchorIndices({})
return {}
}
// Otherwise, select only this segment
setExpandedWorkflowId(workflowId)
setLastAnchorIndices({ [workflowId]: segmentIndex })
return { [workflowId]: [segmentIndex] }
})
} else if (mode === 'range') {
// Range mode: Expand selection within the current workflow
if (expandedWorkflowId === workflowId) {
setSelectedSegments((prev) => {
const currentSegments = prev[workflowId] || []
@@ -638,31 +390,15 @@ export default function Dashboard() {
return { ...prev, [workflowId]: Array.from(union).sort((a, b) => a - b) }
})
} else {
// If clicking range on a different workflow, treat as single click
setExpandedWorkflowId(workflowId)
setSelectedSegments({ [workflowId]: [segmentIndex] })
setLastAnchorIndices({ [workflowId]: segmentIndex })
}
}
},
[expandedWorkflowId, workflowDetails, fetchWorkflowDetails, lastAnchorIndices]
[expandedWorkflowId, workflowDetails, lastAnchorIndices]
)
const isInitialMount = useRef(true)
useEffect(() => {
const isInitial = isInitialMount.current
if (isInitial) {
isInitialMount.current = false
}
fetchExecutions(isInitial)
}, [workspaceId, timeFilter, endTime, workflowIds, folderIds, triggers, segmentCount])
useEffect(() => {
if (expandedWorkflowId) {
fetchWorkflowDetails(expandedWorkflowId)
}
}, [expandedWorkflowId, timeFilter, endTime, workflowIds, folderIds, fetchWorkflowDetails])
useEffect(() => {
setSelectedSegments({})
setLastAnchorIndices({})
@@ -692,68 +428,15 @@ export default function Dashboard() {
}
}, [])
const getShiftLabel = () => {
switch (sidebarTimeRange) {
case 'Past 30 minutes':
return '30 minutes'
case 'Past hour':
return 'hour'
case 'Past 12 hours':
return '12 hours'
case 'Past 24 hours':
return '24 hours'
default:
return 'period'
}
}
const getDateRange = () => {
const start = getStartTime()
return `${start.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })} - ${endTime.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', year: 'numeric' })}`
}
const shiftTimeWindow = (direction: 'back' | 'forward') => {
let shift: number
switch (timeFilter) {
case '30m':
shift = 30 * 60 * 1000
break
case '1h':
shift = 60 * 60 * 1000
break
case '6h':
shift = 6 * 60 * 60 * 1000
break
case '12h':
shift = 12 * 60 * 60 * 1000
break
case '24h':
shift = 24 * 60 * 60 * 1000
break
case '3d':
shift = 3 * 24 * 60 * 60 * 1000
break
case '7d':
shift = 7 * 24 * 60 * 60 * 1000
break
case '14d':
shift = 14 * 24 * 60 * 60 * 1000
break
case '30d':
shift = 30 * 24 * 60 * 60 * 1000
break
default:
shift = 24 * 60 * 60 * 1000
}
setEndTime((prev) => new Date(prev.getTime() + (direction === 'forward' ? shift : -shift)))
}
const resetToNow = () => {
setEndTime(new Date())
}
const isLive = endTime.getTime() > Date.now() - 60000 // Within last minute
const [live, setLive] = useState(false)
useEffect(() => {
@@ -768,8 +451,6 @@ export default function Dashboard() {
}
}, [live])
// Infinite scroll is now handled inside WorkflowDetails
return (
<div className={`flex h-full min-w-0 flex-col pl-64 ${soehne.className}`}>
<div className='flex min-w-0 flex-1 overflow-hidden'>
@@ -873,25 +554,21 @@ export default function Dashboard() {
{/* Details section in its own scroll area */}
<div className='min-h-0 flex-1 overflow-auto'>
{(() => {
// Handle multi-workflow selection view
if (expandedWorkflowId === '__multi__') {
const selectedWorkflowIds = Object.keys(selectedSegments)
const totalMs = endTime.getTime() - getStartTime().getTime()
const segMs = totalMs / Math.max(1, segmentCount)
// Collect all unique segment indices across all workflows
const allSegmentIndices = new Set<number>()
for (const indices of Object.values(selectedSegments)) {
indices.forEach((idx) => allSegmentIndices.add(idx))
}
const sortedIndices = Array.from(allSegmentIndices).sort((a, b) => a - b)
// Aggregate logs from all selected workflows/segments
const allLogs: any[] = []
let totalExecutions = 0
let totalSuccess = 0
// Build aggregated chart series
const aggregatedSegments: Array<{
timestamp: string
totalExecutions: number
@@ -900,9 +577,7 @@ export default function Dashboard() {
durationCount: number
}> = []
// Initialize aggregated segments for each unique index
for (const idx of sortedIndices) {
// Get the timestamp from the first workflow that has this index
let timestamp = ''
for (const wfId of selectedWorkflowIds) {
const wf = executions.find((w) => w.workflowId === wfId)
@@ -921,7 +596,6 @@ export default function Dashboard() {
})
}
// Aggregate data from all workflows
for (const wfId of selectedWorkflowIds) {
const wf = executions.find((w) => w.workflowId === wfId)
const details = workflowDetails[wfId]
@@ -929,7 +603,6 @@ export default function Dashboard() {
if (!wf || !details || indices.length === 0) continue
// Calculate time windows for this workflow's selected segments
const windows = indices
.map((idx) => wf.segments[idx])
.filter(Boolean)
@@ -944,7 +617,6 @@ export default function Dashboard() {
const inAnyWindow = (t: number) =>
windows.some((w) => t >= w.start && t < w.end)
// Filter logs for this workflow's selected segments
const workflowLogs = details.allLogs
.filter((log) => inAnyWindow(new Date(log.startedAt).getTime()))
.map((log) => ({
@@ -956,7 +628,6 @@ export default function Dashboard() {
allLogs.push(...workflowLogs)
// Aggregate segment metrics
indices.forEach((idx) => {
const segment = wf.segments[idx]
if (!segment) return
@@ -974,7 +645,6 @@ export default function Dashboard() {
})
}
// Build chart series
const errorRates = aggregatedSegments.map((seg) => ({
timestamp: seg.timestamp,
value:
@@ -993,7 +663,6 @@ export default function Dashboard() {
value: seg.durationCount > 0 ? seg.avgDurationMs / seg.durationCount : 0,
}))
// Sort logs by time (most recent first)
allLogs.sort(
(a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()
)
@@ -1002,13 +671,11 @@ export default function Dashboard() {
const totalRate =
totalExecutions > 0 ? (totalSuccess / totalExecutions) * 100 : 100
// Calculate overall time range across all selected workflows
let multiWorkflowTimeRange: { start: Date; end: Date } | null = null
if (sortedIndices.length > 0) {
const firstIdx = sortedIndices[0]
const lastIdx = sortedIndices[sortedIndices.length - 1]
// Find earliest start time
let earliestStart: Date | null = null
for (const wfId of selectedWorkflowIds) {
const wf = executions.find((w) => w.workflowId === wfId)
@@ -1021,7 +688,6 @@ export default function Dashboard() {
}
}
// Find latest end time
let latestEnd: Date | null = null
for (const wfId of selectedWorkflowIds) {
const wf = executions.find((w) => w.workflowId === wfId)
@@ -1042,7 +708,6 @@ export default function Dashboard() {
}
}
// Get workflow names
const workflowNames = selectedWorkflowIds
.map((id) => executions.find((w) => w.workflowId === id)?.workflowName)
.filter(Boolean) as string[]
@@ -1179,33 +844,25 @@ export default function Dashboard() {
...log,
workflowName: (log as any).workflowName || wf.workflowName,
}))
// Build series from selected segments indices
const idxSet = new Set(workflowSelectedIndices)
const selectedSegs = wf.segments.filter((_, i) => idxSet.has(i))
;(details as any).__filtered = buildSeriesFromSegments(selectedSegs as any)
} else if (details) {
// Clear filtered data when no segments are selected
;(details as any).__filtered = undefined
}
// Compute series data based on selected segments or all segments
const segmentsToUse =
workflowSelectedIndices.length > 0
? wf.segments.filter((_, i) => workflowSelectedIndices.includes(i))
: wf.segments
const series = buildSeriesFromSegments(segmentsToUse as any)
const detailsWithFilteredLogs = details
? {
...details,
logs: logsToDisplay,
...(() => {
const series =
(details as any).__filtered ||
buildSeriesFromSegments(wf.segments as any)
return {
errorRates: series.errorRates,
durations: series.durations,
executionCounts: series.executionCounts,
durationP50: series.durationP50,
durationP90: series.durationP90,
durationP99: series.durationP99,
}
})(),
errorRates: series.errorRates,
durations: series.durations,
executionCounts: series.executionCounts,
durationP50: series.durationP50,
durationP90: series.durationP90,
durationP99: series.durationP99,
}
: undefined
@@ -1261,8 +918,8 @@ export default function Dashboard() {
}}
formatCost={formatCost}
onLoadMore={() => loadMoreLogs(expandedWorkflowId)}
hasMore={(workflowDetails as any)[expandedWorkflowId]?.__meta?.hasMore}
isLoadingMore={(workflowDetails as any)[expandedWorkflowId]?.__loading}
hasMore={workflowLogsQuery.hasNextPage ?? false}
isLoadingMore={workflowLogsQuery.isFetchingNextPage}
/>
)
}
@@ -1297,8 +954,8 @@ export default function Dashboard() {
}}
formatCost={formatCost}
onLoadMore={loadMoreGlobalLogs}
hasMore={globalLogsMeta.hasMore}
isLoadingMore={globalLoadingMore}
hasMore={globalLogsQuery.hasNextPage ?? false}
isLoadingMore={globalLogsQuery.isFetchingNextPage}
/>
)
})()}

View File

@@ -1,10 +1,9 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { AlertCircle, ArrowUpRight, Info, Loader2 } from 'lucide-react'
import Link from 'next/link'
import { useParams } from 'next/navigation'
import { createLogger } from '@/lib/logs/console/logger'
import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser'
import { cn } from '@/lib/utils'
import Controls from '@/app/workspace/[workspaceId]/logs/components/dashboard/controls'
@@ -13,12 +12,12 @@ import { Sidebar } from '@/app/workspace/[workspaceId]/logs/components/sidebar/s
import Dashboard from '@/app/workspace/[workspaceId]/logs/dashboard'
import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils'
import { useFolders } from '@/hooks/queries/folders'
import { useLogDetail, useLogsList } from '@/hooks/queries/logs'
import { useDebounce } from '@/hooks/use-debounce'
import { useFolderStore } from '@/stores/folders/store'
import { useFilterStore } from '@/stores/logs/filters/store'
import type { LogsResponse, WorkflowLog } from '@/stores/logs/filters/types'
import type { WorkflowLog } from '@/stores/logs/filters/types'
const logger = createLogger('Logs')
const LOGS_PER_PAGE = 50
/**
@@ -63,19 +62,7 @@ export default function Logs() {
const workspaceId = params.workspaceId as string
const {
logs,
loading,
error,
setLogs,
setLoading,
setError,
setWorkspaceId,
page,
setPage,
hasMore,
setHasMore,
isFetchingMore,
setIsFetchingMore,
initializeFromURL,
timeRange,
level,
@@ -95,10 +82,6 @@ export default function Logs() {
const [selectedLog, setSelectedLog] = useState<WorkflowLog | null>(null)
const [selectedLogIndex, setSelectedLogIndex] = useState<number>(-1)
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
const [isDetailsLoading, setIsDetailsLoading] = useState(false)
const detailsCacheRef = useRef<Map<string, any>>(new Map())
const detailsAbortRef = useRef<AbortController | null>(null)
const currentDetailsIdRef = useRef<string | null>(null)
const selectedRowRef = useRef<HTMLTableRowElement | null>(null)
const loaderRef = useRef<HTMLDivElement>(null)
const scrollContainerRef = useRef<HTMLDivElement>(null)
@@ -107,16 +90,37 @@ export default function Logs() {
const [searchQuery, setSearchQuery] = useState(storeSearchQuery)
const debouncedSearchQuery = useDebounce(searchQuery, 300)
const [availableWorkflows, setAvailableWorkflows] = useState<string[]>([])
const [availableFolders, setAvailableFolders] = useState<string[]>([])
const [, setAvailableWorkflows] = useState<string[]>([])
const [, setAvailableFolders] = useState<string[]>([])
// Live and refresh state
const [isLive, setIsLive] = useState(false)
const [isRefreshing, setIsRefreshing] = useState(false)
const liveIntervalRef = useRef<NodeJS.Timeout | null>(null)
const isSearchOpenRef = useRef<boolean>(false)
// Sync local search query with store search query
const logFilters = useMemo(
() => ({
timeRange,
level,
workflowIds,
folderIds,
triggers,
searchQuery: debouncedSearchQuery,
limit: LOGS_PER_PAGE,
}),
[timeRange, level, workflowIds, folderIds, triggers, debouncedSearchQuery]
)
const logsQuery = useLogsList(workspaceId, logFilters, {
enabled: Boolean(workspaceId) && isInitialized.current,
refetchInterval: isLive ? 5000 : false,
})
const logDetailQuery = useLogDetail(selectedLog?.id)
const logs = useMemo(() => {
if (!logsQuery.data?.pages) return []
return logsQuery.data.pages.flatMap((page) => page.logs)
}, [logsQuery.data?.pages])
useEffect(() => {
setSearchQuery(storeSearchQuery)
}, [storeSearchQuery])
@@ -182,62 +186,6 @@ export default function Logs() {
const index = logs.findIndex((l) => l.id === log.id)
setSelectedLogIndex(index)
setIsSidebarOpen(true)
setIsDetailsLoading(true)
const currentId = log.id
const prevId = index > 0 ? logs[index - 1]?.id : undefined
const nextId = index < logs.length - 1 ? logs[index + 1]?.id : undefined
if (detailsAbortRef.current) {
try {
detailsAbortRef.current.abort()
} catch {
/* no-op */
}
}
const controller = new AbortController()
detailsAbortRef.current = controller
currentDetailsIdRef.current = currentId
const idsToFetch: Array<{ id: string; merge: boolean }> = []
const cachedCurrent = currentId ? detailsCacheRef.current.get(currentId) : undefined
if (currentId && !cachedCurrent) idsToFetch.push({ id: currentId, merge: true })
if (prevId && !detailsCacheRef.current.has(prevId))
idsToFetch.push({ id: prevId, merge: false })
if (nextId && !detailsCacheRef.current.has(nextId))
idsToFetch.push({ id: nextId, merge: false })
if (cachedCurrent) {
setSelectedLog((prev) =>
prev && prev.id === currentId
? ({ ...(prev as any), ...(cachedCurrent as any) } as any)
: prev
)
setIsDetailsLoading(false)
}
if (idsToFetch.length === 0) return
Promise.all(
idsToFetch.map(async ({ id, merge }) => {
try {
const res = await fetch(`/api/logs/${id}`, { signal: controller.signal })
if (!res.ok) return
const body = await res.json()
const detailed = body?.data
if (detailed) {
detailsCacheRef.current.set(id, detailed)
if (merge && id === currentId) {
setSelectedLog((prev) =>
prev && prev.id === id ? ({ ...(prev as any), ...(detailed as any) } as any) : prev
)
if (currentDetailsIdRef.current === id) setIsDetailsLoading(false)
}
}
} catch (e: any) {
if (e?.name === 'AbortError') return
}
})
).catch(() => {})
}
const handleNavigateNext = useCallback(() => {
@@ -246,54 +194,6 @@ export default function Logs() {
setSelectedLogIndex(nextIndex)
const nextLog = logs[nextIndex]
setSelectedLog(nextLog)
if (detailsAbortRef.current) {
try {
detailsAbortRef.current.abort()
} catch {
/* no-op */
}
}
const controller = new AbortController()
detailsAbortRef.current = controller
const cached = detailsCacheRef.current.get(nextLog.id)
if (cached) {
setSelectedLog((prev) =>
prev && prev.id === nextLog.id ? ({ ...(prev as any), ...(cached as any) } as any) : prev
)
} else {
const prevId = nextIndex > 0 ? logs[nextIndex - 1]?.id : undefined
const afterId = nextIndex < logs.length - 1 ? logs[nextIndex + 1]?.id : undefined
const idsToFetch: Array<{ id: string; merge: boolean }> = []
if (nextLog.id && !detailsCacheRef.current.has(nextLog.id))
idsToFetch.push({ id: nextLog.id, merge: true })
if (prevId && !detailsCacheRef.current.has(prevId))
idsToFetch.push({ id: prevId, merge: false })
if (afterId && !detailsCacheRef.current.has(afterId))
idsToFetch.push({ id: afterId, merge: false })
Promise.all(
idsToFetch.map(async ({ id, merge }) => {
try {
const res = await fetch(`/api/logs/${id}`, { signal: controller.signal })
if (!res.ok) return
const body = await res.json()
const detailed = body?.data
if (detailed) {
detailsCacheRef.current.set(id, detailed)
if (merge && id === nextLog.id) {
setSelectedLog((prev) =>
prev && prev.id === id
? ({ ...(prev as any), ...(detailed as any) } as any)
: prev
)
}
}
} catch (e: any) {
if (e?.name === 'AbortError') return
}
})
).catch(() => {})
}
}
}, [selectedLogIndex, logs])
@@ -303,54 +203,6 @@ export default function Logs() {
setSelectedLogIndex(prevIndex)
const prevLog = logs[prevIndex]
setSelectedLog(prevLog)
if (detailsAbortRef.current) {
try {
detailsAbortRef.current.abort()
} catch {
/* no-op */
}
}
const controller = new AbortController()
detailsAbortRef.current = controller
const cached = detailsCacheRef.current.get(prevLog.id)
if (cached) {
setSelectedLog((prev) =>
prev && prev.id === prevLog.id ? ({ ...(prev as any), ...(cached as any) } as any) : prev
)
} else {
const beforeId = prevIndex > 0 ? logs[prevIndex - 1]?.id : undefined
const afterId = prevIndex < logs.length - 1 ? logs[prevIndex + 1]?.id : undefined
const idsToFetch: Array<{ id: string; merge: boolean }> = []
if (prevLog.id && !detailsCacheRef.current.has(prevLog.id))
idsToFetch.push({ id: prevLog.id, merge: true })
if (beforeId && !detailsCacheRef.current.has(beforeId))
idsToFetch.push({ id: beforeId, merge: false })
if (afterId && !detailsCacheRef.current.has(afterId))
idsToFetch.push({ id: afterId, merge: false })
Promise.all(
idsToFetch.map(async ({ id, merge }) => {
try {
const res = await fetch(`/api/logs/${id}`, { signal: controller.signal })
if (!res.ok) return
const body = await res.json()
const detailed = body?.data
if (detailed) {
detailsCacheRef.current.set(id, detailed)
if (merge && id === prevLog.id) {
setSelectedLog((prev) =>
prev && prev.id === id
? ({ ...(prev as any), ...(detailed as any) } as any)
: prev
)
}
}
} catch (e: any) {
if (e?.name === 'AbortError') return
}
})
).catch(() => {})
}
}
}, [selectedLogIndex, logs])
@@ -369,106 +221,13 @@ export default function Logs() {
}
}, [selectedLogIndex])
const fetchLogs = useCallback(async (pageNum: number, append = false) => {
try {
// Don't fetch if workspaceId is not set
const { workspaceId: storeWorkspaceId } = useFilterStore.getState()
if (!storeWorkspaceId) {
return
}
if (pageNum === 1) {
setLoading(true)
} else {
setIsFetchingMore(true)
}
const { buildQueryParams: getCurrentQueryParams } = useFilterStore.getState()
const queryParams = getCurrentQueryParams(pageNum, LOGS_PER_PAGE)
const { searchQuery: currentSearchQuery } = useFilterStore.getState()
const parsedQuery = parseQuery(currentSearchQuery)
const enhancedParams = queryToApiParams(parsedQuery)
const allParams = new URLSearchParams(queryParams)
Object.entries(enhancedParams).forEach(([key, value]) => {
if (key === 'triggers' && allParams.has('triggers')) {
const existingTriggers = allParams.get('triggers')?.split(',') || []
const searchTriggers = value.split(',')
const combined = [...new Set([...existingTriggers, ...searchTriggers])]
allParams.set('triggers', combined.join(','))
} else {
allParams.set(key, value)
}
})
allParams.set('details', 'basic')
const response = await fetch(`/api/logs?${allParams.toString()}`)
if (!response.ok) {
throw new Error(`Error fetching logs: ${response.statusText}`)
}
const data: LogsResponse = await response.json()
setHasMore(data.data.length === LOGS_PER_PAGE && data.page < data.totalPages)
setLogs(data.data, append)
setError(null)
} catch (err) {
logger.error('Failed to fetch logs:', { err })
setError(err instanceof Error ? err.message : 'An unknown error occurred')
} finally {
if (pageNum === 1) {
setLoading(false)
} else {
setIsFetchingMore(false)
}
}
}, [])
const handleRefresh = async () => {
if (isRefreshing) return
setIsRefreshing(true)
try {
await fetchLogs(1)
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'An unknown error occurred')
} finally {
setIsRefreshing(false)
await logsQuery.refetch()
if (selectedLog?.id) {
await logDetailQuery.refetch()
}
}
// Setup or clear the live refresh interval when isLive changes
useEffect(() => {
if (liveIntervalRef.current) {
clearInterval(liveIntervalRef.current)
liveIntervalRef.current = null
}
if (isLive) {
handleRefresh()
liveIntervalRef.current = setInterval(() => {
handleRefresh()
}, 5000)
}
return () => {
if (liveIntervalRef.current) {
clearInterval(liveIntervalRef.current)
liveIntervalRef.current = null
}
}
}, [isLive])
const toggleLive = () => {
setIsLive(!isLive)
}
const handleExport = async () => {
const params = new URLSearchParams()
params.set('workspaceId', workspaceId)
@@ -506,101 +265,14 @@ export default function Logs() {
return () => window.removeEventListener('popstate', handlePopState)
}, [initializeFromURL])
useEffect(() => {
if (!isInitialized.current) {
return
}
// Don't fetch if workspaceId is not set yet
if (!workspaceId) {
return
}
setPage(1)
setHasMore(true)
const fetchWithFilters = async () => {
try {
setLoading(true)
const params = new URLSearchParams()
params.set('details', 'basic')
params.set('limit', LOGS_PER_PAGE.toString())
params.set('offset', '0') // Always start from page 1
params.set('workspaceId', workspaceId)
const parsedQuery = parseQuery(debouncedSearchQuery)
const enhancedParams = queryToApiParams(parsedQuery)
if (level !== 'all') params.set('level', level)
if (triggers.length > 0) params.set('triggers', triggers.join(','))
if (workflowIds.length > 0) params.set('workflowIds', workflowIds.join(','))
if (folderIds.length > 0) params.set('folderIds', folderIds.join(','))
Object.entries(enhancedParams).forEach(([key, value]) => {
if (key === 'triggers' && params.has('triggers')) {
const storeTriggers = params.get('triggers')?.split(',') || []
const searchTriggers = value.split(',')
const combined = [...new Set([...storeTriggers, ...searchTriggers])]
params.set('triggers', combined.join(','))
} else {
params.set(key, value)
}
})
if (timeRange !== 'All time') {
const now = new Date()
let startDate: Date
switch (timeRange) {
case 'Past 30 minutes':
startDate = new Date(now.getTime() - 30 * 60 * 1000)
break
case 'Past hour':
startDate = new Date(now.getTime() - 60 * 60 * 1000)
break
case 'Past 24 hours':
startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000)
break
default:
startDate = new Date(0)
}
params.set('startDate', startDate.toISOString())
}
const response = await fetch(`/api/logs?${params.toString()}`)
if (!response.ok) {
throw new Error(`Error fetching logs: ${response.statusText}`)
}
const data: LogsResponse = await response.json()
setHasMore(data.data.length === LOGS_PER_PAGE && data.page < data.totalPages)
setLogs(data.data, false)
setError(null)
} catch (err) {
logger.error('Failed to fetch logs:', { err })
setError(err instanceof Error ? err.message : 'An unknown error occurred')
} finally {
setLoading(false)
}
}
fetchWithFilters()
}, [workspaceId, timeRange, level, workflowIds, folderIds, debouncedSearchQuery, triggers])
const loadMoreLogs = useCallback(() => {
if (!isFetchingMore && hasMore) {
const nextPage = page + 1
setPage(nextPage)
setIsFetchingMore(true)
setTimeout(() => {
fetchLogs(nextPage, true)
}, 50)
if (!logsQuery.isFetching && logsQuery.hasNextPage) {
logsQuery.fetchNextPage()
}
}, [fetchLogs, isFetchingMore, hasMore, page])
}, [logsQuery])
useEffect(() => {
if (loading || !hasMore) return
if (logsQuery.isLoading || !logsQuery.hasNextPage) return
const scrollContainer = scrollContainerRef.current
if (!scrollContainer) return
@@ -612,7 +284,7 @@ export default function Logs() {
const scrollPercentage = (scrollTop / (scrollHeight - clientHeight)) * 100
if (scrollPercentage > 60 && !isFetchingMore && hasMore) {
if (scrollPercentage > 60 && !logsQuery.isFetchingNextPage && logsQuery.hasNextPage) {
loadMoreLogs()
}
}
@@ -622,13 +294,14 @@ export default function Logs() {
return () => {
scrollContainer.removeEventListener('scroll', handleScroll)
}
}, [loading, hasMore, isFetchingMore, loadMoreLogs])
}, [logsQuery.isLoading, logsQuery.hasNextPage, logsQuery.isFetchingNextPage, loadMoreLogs])
useEffect(() => {
const currentLoaderRef = loaderRef.current
const scrollContainer = scrollContainerRef.current
if (!currentLoaderRef || !scrollContainer || loading || !hasMore) return
if (!currentLoaderRef || !scrollContainer || logsQuery.isLoading || !logsQuery.hasNextPage)
return
const observer = new IntersectionObserver(
(entries) => {
@@ -636,7 +309,7 @@ export default function Logs() {
if (!e?.isIntersecting) return
const { scrollTop, scrollHeight, clientHeight } = scrollContainer
const pct = (scrollTop / (scrollHeight - clientHeight)) * 100
if (pct > 70 && !isFetchingMore) {
if (pct > 70 && !logsQuery.isFetchingNextPage) {
loadMoreLogs()
}
},
@@ -652,7 +325,7 @@ export default function Logs() {
return () => {
observer.unobserve(currentLoaderRef)
}
}, [loading, hasMore, isFetchingMore, loadMoreLogs])
}, [logsQuery.isLoading, logsQuery.hasNextPage, logsQuery.isFetchingNextPage, loadMoreLogs])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -686,7 +359,6 @@ export default function Logs() {
return () => window.removeEventListener('keydown', handleKeyDown)
}, [logs, selectedLogIndex, isSidebarOpen, selectedLog, handleNavigateNext, handleNavigatePrev])
// If in dashboard mode, show the dashboard
if (viewMode === 'dashboard') {
return <Dashboard />
}
@@ -701,7 +373,7 @@ export default function Logs() {
<div className='flex min-w-0 flex-1 overflow-hidden'>
<div className='flex flex-1 flex-col p-[24px]'>
<Controls
isRefetching={isRefreshing}
isRefetching={logsQuery.isFetching}
resetToNow={handleRefresh}
live={isLive}
setLive={(fn) => setIsLive(fn)}
@@ -750,18 +422,20 @@ export default function Logs() {
{/* Table body - scrollable */}
<div className='flex-1 overflow-y-auto overflow-x-hidden' ref={scrollContainerRef}>
{loading && page === 1 ? (
{logsQuery.isLoading && !logsQuery.data ? (
<div className='flex h-full items-center justify-center'>
<div className='flex items-center gap-[8px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
<Loader2 className='h-[16px] w-[16px] animate-spin' />
<span className='text-[13px]'>Loading logs...</span>
</div>
</div>
) : error ? (
) : logsQuery.isError ? (
<div className='flex h-full items-center justify-center'>
<div className='flex items-center gap-[8px] text-[var(--text-error)] dark:text-[var(--text-error)]'>
<AlertCircle className='h-[16px] w-[16px]' />
<span className='text-[13px]'>Error: {error}</span>
<span className='text-[13px]'>
Error: {logsQuery.error?.message || 'Failed to load logs'}
</span>
</div>
</div>
) : logs.length === 0 ? (
@@ -778,7 +452,6 @@ export default function Logs() {
const isSelected = selectedLog?.id === log.id
const baseLevel = (log.level || 'info').toLowerCase()
const isError = baseLevel === 'error'
// If it's an error, don't treat it as pending even if hasPendingPause is true
const isPending = !isError && log.hasPendingPause === true
const statusLabel = isPending
? 'Pending'
@@ -906,13 +579,13 @@ export default function Logs() {
})}
{/* Infinite scroll loader */}
{hasMore && (
{logsQuery.hasNextPage && (
<div className='flex items-center justify-center py-[16px]'>
<div
ref={loaderRef}
className='flex items-center gap-[8px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'
>
{isFetchingMore ? (
{logsQuery.isFetchingNextPage ? (
<>
<Loader2 className='h-[16px] w-[16px] animate-spin' />
<span className='text-[13px]'>Loading more...</span>
@@ -932,8 +605,9 @@ export default function Logs() {
{/* Log Sidebar */}
<Sidebar
log={selectedLog}
log={logDetailQuery.data || selectedLog}
isOpen={isSidebarOpen}
isLoadingDetails={logDetailQuery.isLoading}
onClose={handleCloseSidebar}
onNavigateNext={handleNavigateNext}
onNavigatePrev={handleNavigatePrev}

View File

@@ -5,6 +5,7 @@ import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
import { getBlock } from '@/blocks/registry'
import { useStarTemplate } from '@/hooks/queries/templates'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('TemplateCard')
@@ -12,37 +13,21 @@ const logger = createLogger('TemplateCard')
interface TemplateCardProps {
id: string
title: string
description: string
author: string
authorImageUrl?: string | null
usageCount: string
stars?: number
icon?: React.ReactNode | string
iconColor?: string
blocks?: string[]
onClick?: () => void
className?: string
// Workflow state for rendering preview
state?: WorkflowState
isStarred?: boolean
// Optional callback when template is successfully used (for closing modals, etc.)
onTemplateUsed?: () => void
// Callback when star state changes (for parent state updates)
onStarChange?: (templateId: string, isStarred: boolean, newStarCount: number) => void
// User authentication status
isAuthenticated?: boolean
}
/**
* Skeleton component for loading states
*/
export function TemplateCardSkeleton({ className }: { className?: string }) {
return (
<div className={cn('h-[268px] w-full rounded-[8px] bg-[#202020] p-[8px]', className)}>
{/* Workflow preview skeleton */}
<div className='h-[180px] w-full animate-pulse rounded-[6px] bg-gray-700' />
{/* Title and blocks row skeleton */}
<div className='mt-[14px] flex items-center justify-between'>
<div className='h-4 w-32 animate-pulse rounded bg-gray-700' />
<div className='flex items-center gap-[-4px]'>
@@ -55,7 +40,6 @@ export function TemplateCardSkeleton({ className }: { className?: string }) {
</div>
</div>
{/* Creator and stats row skeleton */}
<div className='mt-[14px] flex items-center justify-between'>
<div className='flex items-center gap-[8px]'>
<div className='h-[14px] w-[14px] animate-pulse rounded-full bg-gray-700' />
@@ -72,31 +56,23 @@ export function TemplateCardSkeleton({ className }: { className?: string }) {
)
}
// Utility function to extract block types from workflow state
const extractBlockTypesFromState = (state?: {
blocks?: Record<string, { type: string; name?: string }>
}): string[] => {
if (!state?.blocks) return []
// Get unique block types from the state, excluding starter blocks
// Sort the keys to ensure consistent ordering between server and client
const blockTypes = Object.keys(state.blocks)
.sort() // Sort keys to ensure consistent order
.sort()
.map((key) => state.blocks![key].type)
.filter((type) => type !== 'starter')
return [...new Set(blockTypes)]
}
// Utility function to get the full block config for colored icon display
const getBlockConfig = (blockType: string) => {
const block = getBlock(blockType)
return block
}
/**
* Normalize an arbitrary workflow-like object into a valid WorkflowState for preview rendering.
* Ensures required fields exist: blocks with required properties, edges array, loops and parallels maps.
*/
function normalizeWorkflowState(input?: any): WorkflowState | null {
if (!input || !input.blocks) return null
@@ -142,34 +118,22 @@ function normalizeWorkflowState(input?: any): WorkflowState | null {
function TemplateCardInner({
id,
title,
description,
author,
authorImageUrl,
usageCount,
stars = 0,
icon,
iconColor = 'bg-blue-500',
blocks = [],
onClick,
className,
state,
isStarred = false,
onTemplateUsed,
onStarChange,
isAuthenticated = true,
}: TemplateCardProps) {
const router = useRouter()
const params = useParams()
// Local state for optimistic updates
const [localIsStarred, setLocalIsStarred] = useState(isStarred)
const [localStarCount, setLocalStarCount] = useState(stars)
const [isStarLoading, setIsStarLoading] = useState(false)
const { mutate: toggleStar, isPending: isStarLoading } = useStarTemplate()
// Memoize normalized workflow state to avoid recalculation on every render
const normalizedState = useMemo(() => normalizeWorkflowState(state), [state])
// Use IntersectionObserver to defer rendering the heavy WorkflowPreview until in viewport
const previewRef = useRef<HTMLDivElement | null>(null)
const [isInView, setIsInView] = useState(false)
@@ -188,9 +152,6 @@ function TemplateCardInner({
return () => observer.disconnect()
}, [])
// Extract block types from state if provided, otherwise use the blocks prop
// Filter out starter blocks in both cases and sort for consistent rendering
// Memoized to prevent recalculation on every render
const blockTypes = useMemo(
() =>
state
@@ -199,65 +160,16 @@ function TemplateCardInner({
[state, blocks]
)
// Handle star toggle with optimistic updates
const handleStarClick = async (e: React.MouseEvent) => {
const handleStarClick = (e: React.MouseEvent) => {
e.stopPropagation()
// Prevent multiple clicks while loading
if (isStarLoading) return
setIsStarLoading(true)
// Optimistic update - update UI immediately
const newIsStarred = !localIsStarred
const newStarCount = newIsStarred ? localStarCount + 1 : localStarCount - 1
setLocalIsStarred(newIsStarred)
setLocalStarCount(newStarCount)
// Notify parent component immediately for optimistic update
if (onStarChange) {
onStarChange(id, newIsStarred, newStarCount)
}
try {
const method = localIsStarred ? 'DELETE' : 'POST'
const response = await fetch(`/api/templates/${id}/star`, { method })
if (!response.ok) {
// Rollback on error
setLocalIsStarred(localIsStarred)
setLocalStarCount(localStarCount)
// Rollback parent state too
if (onStarChange) {
onStarChange(id, localIsStarred, localStarCount)
}
logger.error('Failed to toggle star:', response.statusText)
}
} catch (error) {
// Rollback on error
setLocalIsStarred(localIsStarred)
setLocalStarCount(localStarCount)
// Rollback parent state too
if (onStarChange) {
onStarChange(id, localIsStarred, localStarCount)
}
logger.error('Error toggling star:', error)
} finally {
setIsStarLoading(false)
}
toggleStar({
templateId: id,
action: isStarred ? 'remove' : 'add',
})
}
/**
* Get the appropriate template detail page URL based on context.
* If we're in a workspace context, navigate to the workspace template page.
* Otherwise, navigate to the global template page.
* Memoized to avoid recalculation on every render.
*/
const templateUrl = useMemo(() => {
const workspaceId = params?.workspaceId as string | undefined
if (workspaceId) {
@@ -266,23 +178,8 @@ function TemplateCardInner({
return `/templates/${id}`
}, [params?.workspaceId, id])
/**
* Handle use button click - navigate to template detail page
*/
const handleUseClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
router.push(templateUrl)
},
[router, templateUrl]
)
/**
* Handle card click - navigate to template detail page
*/
const handleCardClick = useCallback(
(e: React.MouseEvent) => {
// Don't navigate if clicking on action buttons
const target = e.target as HTMLElement
if (target.closest('button') || target.closest('[data-action]')) {
return
@@ -298,7 +195,6 @@ function TemplateCardInner({
onClick={handleCardClick}
className={cn('w-full cursor-pointer rounded-[8px] bg-[#202020] p-[8px]', className)}
>
{/* Workflow Preview */}
<div
ref={previewRef}
className='pointer-events-none h-[180px] w-full overflow-hidden rounded-[6px]'
@@ -318,16 +214,12 @@ function TemplateCardInner({
)}
</div>
{/* Title and Blocks Row */}
<div className='mt-[10px] flex items-center justify-between'>
{/* Template Name */}
<h3 className='truncate pr-[8px] pl-[2px] font-medium text-[16px] text-white'>{title}</h3>
{/* Block Icons */}
<div className='flex flex-shrink-0'>
{blockTypes.length > 4 ? (
<>
{/* Show first 3 blocks when there are more than 4 */}
{blockTypes.slice(0, 3).map((blockType, index) => {
const blockConfig = getBlockConfig(blockType)
if (!blockConfig) return null
@@ -345,7 +237,6 @@ function TemplateCardInner({
</div>
)
})}
{/* Show +n for remaining blocks */}
<div
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-[4px] bg-[#4A4A4A]'
style={{ marginLeft: '-4px' }}
@@ -354,7 +245,6 @@ function TemplateCardInner({
</div>
</>
) : (
/* Show all blocks when 4 or fewer */
blockTypes.map((blockType, index) => {
const blockConfig = getBlockConfig(blockType)
if (!blockConfig) return null
@@ -376,9 +266,7 @@ function TemplateCardInner({
</div>
</div>
{/* Creator and Stats Row */}
<div className='mt-[10px] flex items-center justify-between'>
{/* Creator Info */}
<div className='flex items-center gap-[8px]'>
{authorImageUrl ? (
<div className='h-[26px] w-[26px] flex-shrink-0 overflow-hidden rounded-full'>
@@ -392,7 +280,6 @@ function TemplateCardInner({
<span className='truncate font-medium text-[#888888] text-[12px]'>{author}</span>
</div>
{/* Stats */}
<div className='flex flex-shrink-0 items-center gap-[6px] font-medium text-[#888888] text-[12px]'>
<User className='h-[12px] w-[12px]' />
<span>{usageCount}</span>
@@ -400,11 +287,11 @@ function TemplateCardInner({
onClick={handleStarClick}
className={cn(
'h-[12px] w-[12px] cursor-pointer transition-colors',
localIsStarred ? 'fill-yellow-500 text-yellow-500' : 'text-[#888888]',
isStarred ? 'fill-yellow-500 text-yellow-500' : 'text-[#888888]',
isStarLoading && 'opacity-50'
)}
/>
<span>{localStarCount}</span>
<span>{stars}</span>
</div>
</div>
</div>

View File

@@ -9,6 +9,7 @@ import {
TemplateCard,
TemplateCardSkeleton,
} from '@/app/workspace/[workspaceId]/templates/components/template-card'
import { useDebounce } from '@/hooks/use-debounce'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import type { CreatorProfileDetails } from '@/types/creator-profile'
@@ -70,30 +71,19 @@ export default function Templates({
isSuperUser,
}: TemplatesProps) {
const [searchQuery, setSearchQuery] = useState('')
const debouncedSearchQuery = useDebounce(searchQuery, 300)
const [activeTab, setActiveTab] = useState('gallery')
const [templates, setTemplates] = useState<Template[]>(initialTemplates)
const [loading, setLoading] = useState(false)
/**
* Update star status for a template
*/
const handleStarChange = (templateId: string, isStarred: boolean, newStarCount: number) => {
setTemplates((prevTemplates) =>
prevTemplates.map((template) =>
template.id === templateId ? { ...template, isStarred, stars: newStarCount } : template
)
)
}
/**
* Filter templates based on active tab and search query
* Memoized to prevent unnecessary recalculations on render
*/
const filteredTemplates = useMemo(() => {
const query = searchQuery.toLowerCase()
const query = debouncedSearchQuery.toLowerCase()
return templates.filter((template) => {
// Filter by tab
const tabMatch =
activeTab === 'your'
? template.userId === currentUserId || template.isStarred
@@ -103,7 +93,6 @@ export default function Templates({
if (!tabMatch) return false
// Filter by search query
if (!query) return true
const searchableText = [
@@ -119,14 +108,14 @@ export default function Templates({
return searchableText.includes(query)
})
}, [templates, activeTab, searchQuery, currentUserId])
}, [templates, activeTab, debouncedSearchQuery, currentUserId])
/**
* Get empty state message based on current filters
* Memoized to prevent unnecessary recalculations on render
*/
const emptyState = useMemo(() => {
if (searchQuery) {
if (debouncedSearchQuery) {
return {
title: 'No templates found',
description: 'Try a different search term',
@@ -149,7 +138,7 @@ export default function Templates({
}
return messages[activeTab as keyof typeof messages] || messages.gallery
}, [searchQuery, activeTab])
}, [debouncedSearchQuery, activeTab])
return (
<div className='flex h-[100vh] flex-col pl-64'>
@@ -228,17 +217,12 @@ export default function Templates({
key={template.id}
id={template.id}
title={template.name}
description={template.description || template.details?.tagline || ''}
author={author}
authorImageUrl={authorImageUrl}
usageCount={template.views.toString()}
stars={template.stars}
icon={template.icon}
iconColor={template.color}
state={template.state}
isStarred={template.isStarred}
onStarChange={handleStarChange}
isAuthenticated={true}
/>
)
})

View File

@@ -600,6 +600,7 @@ export function Chat() {
onOutputSelect={handleOutputSelection}
disabled={!activeWorkflowId}
placeholder='Select outputs'
align='end'
/>
</div>

View File

@@ -7,7 +7,6 @@ import {
Popover,
PopoverContent,
PopoverItem,
PopoverScrollArea,
PopoverSection,
PopoverTrigger,
} from '@/components/emcn'
@@ -24,6 +23,7 @@ interface OutputSelectProps {
disabled?: boolean
placeholder?: string
valueMode?: 'id' | 'label'
align?: 'start' | 'end' | 'center'
}
export function OutputSelect({
@@ -33,10 +33,13 @@ export function OutputSelect({
disabled = false,
placeholder = 'Select outputs',
valueMode = 'id',
align = 'start',
}: OutputSelectProps) {
const [open, setOpen] = useState(false)
const [highlightedIndex, setHighlightedIndex] = useState(-1)
const triggerRef = useRef<HTMLDivElement>(null)
const popoverRef = useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
const blocks = useWorkflowStore((state) => state.blocks)
const { isShowingDiff, isDiffReady, diffWorkflow } = useWorkflowDiffStore()
const subBlockValues = useSubBlockStore((state) =>
@@ -230,6 +233,13 @@ export function OutputSelect({
return blockConfig?.bgColor || '#2F55FF'
}
/**
* Flattened outputs for keyboard navigation
*/
const flattenedOutputs = useMemo(() => {
return Object.values(groupedOutputs).flat()
}, [groupedOutputs])
/**
* Handles output selection - toggle selection
*/
@@ -246,6 +256,75 @@ export function OutputSelect({
onOutputSelect(newSelectedOutputs)
}
/**
* Keyboard navigation handler
*/
const handleKeyDown = (e: React.KeyboardEvent) => {
if (flattenedOutputs.length === 0) return
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setHighlightedIndex((prev) => {
const next = prev < flattenedOutputs.length - 1 ? prev + 1 : 0
return next
})
break
case 'ArrowUp':
e.preventDefault()
setHighlightedIndex((prev) => {
const next = prev > 0 ? prev - 1 : flattenedOutputs.length - 1
return next
})
break
case 'Enter':
e.preventDefault()
if (highlightedIndex >= 0 && highlightedIndex < flattenedOutputs.length) {
handleOutputSelection(flattenedOutputs[highlightedIndex].label)
}
break
case 'Escape':
e.preventDefault()
setOpen(false)
break
}
}
/**
* Reset highlighted index when popover opens/closes
*/
useEffect(() => {
if (open) {
// Find first selected item, or start at -1
const firstSelectedIndex = flattenedOutputs.findIndex((output) => isSelectedValue(output))
setHighlightedIndex(firstSelectedIndex >= 0 ? firstSelectedIndex : -1)
// Focus the content for keyboard navigation
setTimeout(() => {
contentRef.current?.focus()
}, 0)
} else {
setHighlightedIndex(-1)
}
}, [open, flattenedOutputs])
/**
* Scroll highlighted item into view
*/
useEffect(() => {
if (highlightedIndex >= 0 && contentRef.current) {
const highlightedElement = contentRef.current.querySelector(
`[data-option-index="${highlightedIndex}"]`
)
if (highlightedElement) {
highlightedElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
}
}, [highlightedIndex])
/**
* Closes popover when clicking outside
*/
@@ -288,44 +367,57 @@ export function OutputSelect({
<PopoverContent
ref={popoverRef}
side='bottom'
align='start'
align={align}
sideOffset={4}
maxHeight={140}
maxWidth={140}
minWidth={140}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
maxHeight={300}
maxWidth={300}
minWidth={200}
onKeyDown={handleKeyDown}
tabIndex={0}
style={{ outline: 'none' }}
>
<PopoverScrollArea className='space-y-[2px]'>
{Object.entries(groupedOutputs).map(([blockName, outputs]) => (
<div key={blockName}>
<PopoverSection>{blockName}</PopoverSection>
<div ref={contentRef} className='space-y-[2px]'>
{Object.entries(groupedOutputs).map(([blockName, outputs]) => {
// Calculate the starting index for this group
const startIndex = flattenedOutputs.findIndex((o) => o.blockName === blockName)
<div className='flex flex-col gap-[2px]'>
{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>
))}
return (
<div key={blockName}>
<PopoverSection>{blockName}</PopoverSection>
<div className='flex flex-col gap-[2px]'>
{outputs.map((output, localIndex) => {
const globalIndex = startIndex + localIndex
const isHighlighted = globalIndex === highlightedIndex
return (
<PopoverItem
key={output.id}
active={isSelectedValue(output) || isHighlighted}
data-option-index={globalIndex}
onClick={() => handleOutputSelection(output.label)}
onMouseEnter={() => setHighlightedIndex(globalIndex)}
>
<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>
</div>
</div>
))}
</PopoverScrollArea>
)
})}
</div>
</PopoverContent>
</Popover>
)

View File

@@ -27,6 +27,12 @@ import { TagInput } from '@/components/ui/tag-input'
import { useSession } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
import {
useCreateTemplate,
useDeleteTemplate,
useTemplateByWorkflow,
useUpdateTemplate,
} from '@/hooks/queries/templates'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('TemplateDeploy')
@@ -55,15 +61,16 @@ interface TemplateDeployProps {
export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDeployProps) {
const { data: session } = useSession()
const [isSubmitting, setIsSubmitting] = useState(false)
const [existingTemplate, setExistingTemplate] = useState<any>(null)
const [isLoadingTemplate, setIsLoadingTemplate] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [creatorOptions, setCreatorOptions] = useState<CreatorOption[]>([])
const [loadingCreators, setLoadingCreators] = useState(false)
const [showPreviewDialog, setShowPreviewDialog] = useState(false)
const { data: existingTemplate, isLoading: isLoadingTemplate } = useTemplateByWorkflow(workflowId)
const createMutation = useCreateTemplate()
const updateMutation = useUpdateTemplate()
const deleteMutation = useDeleteTemplate()
const form = useForm<TemplateFormData>({
resolver: zodResolver(templateSchema),
defaultValues: {
@@ -75,7 +82,6 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
},
})
// Fetch creator profiles
const fetchCreatorOptions = async () => {
if (!session?.user?.id) return
@@ -105,7 +111,6 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
fetchCreatorOptions()
}, [session?.user?.id])
// Auto-select creator profile when there's only one option and no selection yet
useEffect(() => {
const currentCreatorId = form.getValues('creatorId')
if (creatorOptions.length === 1 && !currentCreatorId) {
@@ -114,15 +119,12 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
}
}, [creatorOptions, form])
// Listen for creator profile saved event
useEffect(() => {
const handleCreatorProfileSaved = async () => {
logger.info('Creator profile saved, refreshing profiles...')
// Refetch creator profiles (autoselection will happen via the effect above)
await fetchCreatorOptions()
// Close settings modal and reopen deploy modal to template tab
window.dispatchEvent(new CustomEvent('close-settings'))
setTimeout(() => {
window.dispatchEvent(new CustomEvent('open-deploy-modal', { detail: { tab: 'template' } }))
@@ -136,41 +138,20 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
}
}, [])
// Check for existing template
useEffect(() => {
const checkExistingTemplate = async () => {
setIsLoadingTemplate(true)
try {
const response = await fetch(`/api/templates?workflowId=${workflowId}&limit=1`)
if (response.ok) {
const result = await response.json()
const template = result.data?.[0] || null
setExistingTemplate(template)
if (existingTemplate) {
const tagline = existingTemplate.details?.tagline || ''
const about = existingTemplate.details?.about || ''
if (template) {
// Map old template format to new format if needed
const tagline = (template.details as any)?.tagline || template.description || ''
const about = (template.details as any)?.about || ''
form.reset({
name: template.name,
tagline: tagline,
about: about,
creatorId: template.creatorId || undefined,
tags: template.tags || [],
})
}
}
} catch (error) {
logger.error('Error checking existing template:', error)
setExistingTemplate(null)
} finally {
setIsLoadingTemplate(false)
}
form.reset({
name: existingTemplate.name,
tagline: tagline,
about: about,
creatorId: existingTemplate.creatorId || undefined,
tags: existingTemplate.tags || [],
})
}
checkExistingTemplate()
}, [workflowId, session?.user?.id])
}, [existingTemplate, form])
const onSubmit = async (data: TemplateFormData) => {
if (!session?.user) {
@@ -178,85 +159,51 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
return
}
setIsSubmitting(true)
try {
// Build template data with new schema
const templateData: any = {
const templateData = {
name: data.name,
details: {
tagline: data.tagline || '',
about: data.about || '',
},
creatorId: data.creatorId || null,
creatorId: data.creatorId || undefined,
tags: data.tags || [],
}
let response
if (existingTemplate) {
// Update template metadata AND state from current workflow
response = await fetch(`/api/templates/${existingTemplate.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
await updateMutation.mutateAsync({
id: existingTemplate.id,
data: {
...templateData,
updateState: true, // Update state from current workflow
}),
updateState: true,
},
})
} else {
// Create new template with workflowId
response = await fetch('/api/templates', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...templateData, workflowId }),
})
await createMutation.mutateAsync({ ...templateData, workflowId })
}
if (!response.ok) {
const errorData = await response.json()
throw new Error(
errorData.error || `Failed to ${existingTemplate ? 'update' : 'create'} template`
)
}
const result = await response.json()
logger.info(`Template ${existingTemplate ? 'updated' : 'created'} successfully:`, result)
// Update existing template state
setExistingTemplate(result.data || result)
logger.info(`Template ${existingTemplate ? 'updated' : 'created'} successfully`)
onDeploymentComplete?.()
} catch (error) {
logger.error('Failed to save template:', error)
} finally {
setIsSubmitting(false)
}
}
const handleDelete = async () => {
if (!existingTemplate) return
setIsDeleting(true)
try {
const response = await fetch(`/api/templates/${existingTemplate.id}`, {
method: 'DELETE',
await deleteMutation.mutateAsync(existingTemplate.id)
setShowDeleteDialog(false)
form.reset({
name: '',
tagline: '',
about: '',
creatorId: undefined,
tags: [],
})
if (response.ok) {
setExistingTemplate(null)
setShowDeleteDialog(false)
form.reset({
name: '',
tagline: '',
about: '',
creatorId: undefined,
tags: [],
})
}
} catch (error) {
logger.error('Error deleting template:', error)
} finally {
setIsDeleting(false)
}
}
@@ -422,7 +369,7 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
onChange={field.onChange}
placeholder='Type and press Enter to add tags'
maxTags={10}
disabled={isSubmitting}
disabled={createMutation.isPending || updateMutation.isPending}
/>
</FormControl>
<p className='text-muted-foreground text-xs'>
@@ -447,9 +394,11 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
<Button
type='submit'
variant='primary'
disabled={isSubmitting || !form.formState.isValid}
disabled={
createMutation.isPending || updateMutation.isPending || !form.formState.isValid
}
>
{isSubmitting ? (
{createMutation.isPending || updateMutation.isPending ? (
<>
<Loader2 className='mr-[8px] h-[14px] w-[14px] animate-spin' />
{existingTemplate ? 'Updating...' : 'Publishing...'}
@@ -479,10 +428,10 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
</Button>
<Button
onClick={handleDelete}
disabled={isDeleting}
disabled={deleteMutation.isPending}
className='bg-red-600 text-white hover:bg-red-700'
>
{isDeleting ? 'Deleting...' : 'Delete'}
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
</Button>
</div>
</div>
@@ -511,7 +460,6 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
)
}
// Ensure the state has the right structure
const workflowState: WorkflowState = {
blocks: existingTemplate.state.blocks || {},
edges: existingTemplate.state.edges || [],

View File

@@ -1,5 +1,4 @@
export { DeployModal } from './deploy-modal/deploy-modal'
export { DeploymentControls } from './deployment-controls/deployment-controls'
export { ExportControls } from './export-controls/export-controls'
export { TemplateModal } from './template-modal/template-modal'
export { WebhookSettings } from './webhook-settings/webhook-settings'

View File

@@ -1,756 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import {
Award,
BarChart3,
Bell,
BookOpen,
Bot,
Brain,
Briefcase,
Calculator,
Cloud,
Code,
Cpu,
CreditCard,
Database,
DollarSign,
Edit,
Eye,
FileText,
Folder,
Globe,
HeadphonesIcon,
Layers,
Lightbulb,
LineChart,
Loader2,
Mail,
Megaphone,
MessageSquare,
NotebookPen,
Phone,
Play,
Search,
Server,
Settings,
ShoppingCart,
Star,
Target,
TrendingUp,
User,
Users,
Workflow,
Wrench,
X,
Zap,
} from 'lucide-react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { ColorPicker } from '@/components/ui/color-picker'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { useSession } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { buildWorkflowStateForTemplate } from '@/lib/workflows/state-builder'
const logger = createLogger('TemplateModal')
const templateSchema = z.object({
name: z.string().min(1, 'Name is required').max(100, 'Name must be less than 100 characters'),
description: z
.string()
.min(1, 'Description is required')
.max(500, 'Description must be less than 500 characters'),
author: z
.string()
.min(1, 'Author is required')
.max(100, 'Author must be less than 100 characters'),
authorType: z.enum(['user', 'organization']).default('user'),
organizationId: z.string().optional(),
icon: z.string().min(1, 'Icon is required'),
color: z.string().regex(/^#[0-9A-F]{6}$/i, 'Color must be a valid hex color (e.g., #3972F6)'),
})
type TemplateFormData = z.infer<typeof templateSchema>
interface Organization {
id: string
name: string
}
interface TemplateModalProps {
open: boolean
onOpenChange: (open: boolean) => void
workflowId: string
}
const icons = [
// Content & Documentation
{ value: 'FileText', label: 'File Text', component: FileText },
{ value: 'NotebookPen', label: 'Notebook', component: NotebookPen },
{ value: 'BookOpen', label: 'Book', component: BookOpen },
{ value: 'Edit', label: 'Edit', component: Edit },
// Analytics & Charts
{ value: 'BarChart3', label: 'Bar Chart', component: BarChart3 },
{ value: 'LineChart', label: 'Line Chart', component: LineChart },
{ value: 'TrendingUp', label: 'Trending Up', component: TrendingUp },
{ value: 'Target', label: 'Target', component: Target },
// Database & Storage
{ value: 'Database', label: 'Database', component: Database },
{ value: 'Server', label: 'Server', component: Server },
{ value: 'Cloud', label: 'Cloud', component: Cloud },
{ value: 'Folder', label: 'Folder', component: Folder },
// Marketing & Communication
{ value: 'Megaphone', label: 'Megaphone', component: Megaphone },
{ value: 'Mail', label: 'Mail', component: Mail },
{ value: 'MessageSquare', label: 'Message', component: MessageSquare },
{ value: 'Phone', label: 'Phone', component: Phone },
{ value: 'Bell', label: 'Bell', component: Bell },
// Sales & Finance
{ value: 'DollarSign', label: 'Dollar Sign', component: DollarSign },
{ value: 'CreditCard', label: 'Credit Card', component: CreditCard },
{ value: 'Calculator', label: 'Calculator', component: Calculator },
{ value: 'ShoppingCart', label: 'Shopping Cart', component: ShoppingCart },
{ value: 'Briefcase', label: 'Briefcase', component: Briefcase },
// Support & Service
{ value: 'HeadphonesIcon', label: 'Headphones', component: HeadphonesIcon },
{ value: 'User', label: 'User', component: User },
{ value: 'Users', label: 'Users', component: Users },
{ value: 'Settings', label: 'Settings', component: Settings },
{ value: 'Wrench', label: 'Wrench', component: Wrench },
// AI & Technology
{ value: 'Bot', label: 'Bot', component: Bot },
{ value: 'Brain', label: 'Brain', component: Brain },
{ value: 'Cpu', label: 'CPU', component: Cpu },
{ value: 'Code', label: 'Code', component: Code },
{ value: 'Zap', label: 'Zap', component: Zap },
// Workflow & Process
{ value: 'Workflow', label: 'Workflow', component: Workflow },
{ value: 'Search', label: 'Search', component: Search },
{ value: 'Play', label: 'Play', component: Play },
{ value: 'Layers', label: 'Layers', component: Layers },
// General
{ value: 'Lightbulb', label: 'Lightbulb', component: Lightbulb },
{ value: 'Star', label: 'Star', component: Star },
{ value: 'Globe', label: 'Globe', component: Globe },
{ value: 'Award', label: 'Award', component: Award },
]
export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalProps) {
const { data: session } = useSession()
const [isSubmitting, setIsSubmitting] = useState(false)
const [iconPopoverOpen, setIconPopoverOpen] = useState(false)
const [existingTemplate, setExistingTemplate] = useState<any>(null)
const [isLoadingTemplate, setIsLoadingTemplate] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [organizations, setOrganizations] = useState<Organization[]>([])
const [loadingOrgs, setLoadingOrgs] = useState(false)
const form = useForm<TemplateFormData>({
resolver: zodResolver(templateSchema),
defaultValues: {
name: '',
description: '',
author: session?.user?.name || session?.user?.email || '',
authorType: 'user',
organizationId: undefined,
icon: 'FileText',
color: '#3972F6',
},
})
// Watch form state to determine if all required fields are valid
const formValues = form.watch()
const authorType = form.watch('authorType')
const isFormValid =
form.formState.isValid &&
formValues.name?.trim() &&
formValues.description?.trim() &&
formValues.author?.trim()
// Fetch user's organizations when modal opens
useEffect(() => {
const fetchOrganizations = async () => {
if (!open || !session?.user?.id) return
setLoadingOrgs(true)
try {
const response = await fetch('/api/organizations')
if (response.ok) {
const data = await response.json()
setOrganizations(data.organizations || [])
}
} catch (error) {
logger.error('Error fetching organizations:', error)
setOrganizations([])
} finally {
setLoadingOrgs(false)
}
}
if (open) {
fetchOrganizations()
}
}, [open, session?.user?.id])
// Check for existing template when modal opens
useEffect(() => {
if (open && workflowId) {
checkExistingTemplate()
}
}, [open, workflowId])
const checkExistingTemplate = async () => {
setIsLoadingTemplate(true)
try {
const response = await fetch(`/api/templates?workflowId=${workflowId}&limit=1`)
if (response.ok) {
const result = await response.json()
const template = result.data?.[0] || null
setExistingTemplate(template)
// Pre-fill form with existing template data
if (template) {
form.reset({
name: template.name,
description: template.description,
author: template.author,
authorType: template.authorType || 'user',
organizationId: template.organizationId || undefined,
icon: template.icon,
color: template.color,
})
} else {
// No existing template found
setExistingTemplate(null)
// Reset form to defaults
form.reset({
name: '',
description: '',
author: session?.user?.name || session?.user?.email || '',
authorType: 'user',
organizationId: undefined,
icon: 'FileText',
color: '#3972F6',
})
}
}
} catch (error) {
logger.error('Error checking existing template:', error)
setExistingTemplate(null)
} finally {
setIsLoadingTemplate(false)
}
}
const onSubmit = async (data: TemplateFormData) => {
if (!session?.user) {
logger.error('User not authenticated')
return
}
setIsSubmitting(true)
try {
// Create the template state from current workflow using the same format as deployment
const templateState = buildWorkflowStateForTemplate(workflowId)
const templateData = {
workflowId,
name: data.name,
description: data.description || '',
author: data.author,
authorType: data.authorType,
organizationId: data.organizationId,
icon: data.icon,
color: data.color,
state: templateState,
}
let response
if (existingTemplate) {
// Update existing template
response = await fetch(`/api/templates/${existingTemplate.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(templateData),
})
} else {
// Create new template
response = await fetch('/api/templates', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(templateData),
})
}
if (!response.ok) {
const errorData = await response.json()
throw new Error(
errorData.error || `Failed to ${existingTemplate ? 'update' : 'create'} template`
)
}
const result = await response.json()
logger.info(`Template ${existingTemplate ? 'updated' : 'created'} successfully:`, result)
// Reset form and close modal
form.reset()
onOpenChange(false)
// TODO: Show success toast/notification
} catch (error) {
logger.error('Failed to create template:', error)
// TODO: Show error toast/notification
} finally {
setIsSubmitting(false)
}
}
const SelectedIconComponent =
icons.find((icon) => icon.value === form.watch('icon'))?.component || FileText
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className='flex h-[70vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-[600px]'
hideCloseButton
>
<DialogHeader className='flex-shrink-0 border-b px-6 py-4'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-3'>
<DialogTitle className='font-medium text-lg'>
{isLoadingTemplate
? 'Loading...'
: existingTemplate
? 'Update Template'
: 'Publish Template'}
</DialogTitle>
{existingTemplate && (
<div className='flex items-center gap-2'>
{existingTemplate.stars > 0 && (
<div className='flex items-center gap-1 rounded-full bg-yellow-50 px-2 py-1 dark:bg-yellow-900/20'>
<Star className='h-3 w-3 fill-yellow-400 text-yellow-400' />
<span className='font-medium text-xs text-yellow-700 dark:text-yellow-300'>
{existingTemplate.stars}
</span>
</div>
)}
{existingTemplate.views > 0 && (
<div className='flex items-center gap-1 rounded-full bg-blue-50 px-2 py-1 dark:bg-blue-900/20'>
<Eye className='h-3 w-3 text-blue-500' />
<span className='font-medium text-blue-700 text-xs dark:text-blue-300'>
{existingTemplate.views}
</span>
</div>
)}
</div>
)}
</div>
<Button
variant='ghost'
size='icon'
className={cn(
'h-8 w-8 rounded-md p-0 text-muted-foreground/70 transition-all duration-200',
'hover:scale-105 hover:bg-muted/50 hover:text-foreground',
'active:scale-95',
'focus-visible:ring-2 focus-visible:ring-muted-foreground/20 focus-visible:ring-offset-1'
)}
onClick={() => onOpenChange(false)}
>
<X className='h-4 w-4' />
<span className='sr-only'>Close</span>
</Button>
</div>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className='flex flex-1 flex-col overflow-hidden'
>
<div className='flex-1 overflow-y-auto px-6 py-4'>
{isLoadingTemplate ? (
<div className='space-y-6'>
{/* Icon and Color row */}
<div className='flex gap-3'>
<div className='w-20'>
<Skeleton className='mb-2 h-4 w-8' /> {/* Label */}
<Skeleton className='h-10 w-20' /> {/* Icon picker */}
</div>
<div className='w-20'>
<Skeleton className='mb-2 h-4 w-10' /> {/* Label */}
<Skeleton className='h-10 w-20' /> {/* Color picker */}
</div>
</div>
{/* Name field */}
<div>
<Skeleton className='mb-2 h-4 w-12' /> {/* Label */}
<Skeleton className='h-10 w-full' /> {/* Input */}
</div>
{/* Author and Author Type row */}
<div className='grid grid-cols-2 gap-4'>
<div>
<Skeleton className='mb-2 h-4 w-14' /> {/* Label */}
<Skeleton className='h-10 w-full' /> {/* Input */}
</div>
<div>
<Skeleton className='mb-2 h-4 w-24' /> {/* Label */}
<Skeleton className='h-10 w-full' /> {/* Select */}
</div>
</div>
{/* Description field */}
<div>
<Skeleton className='mb-2 h-4 w-20' /> {/* Label */}
<Skeleton className='h-20 w-full' /> {/* Textarea */}
</div>
</div>
) : (
<div className='space-y-5'>
<div className='flex gap-3'>
<FormField
control={form.control}
name='icon'
render={({ field }) => (
<FormItem className='w-20'>
<FormLabel className='!text-foreground font-medium text-sm'>
Icon
</FormLabel>
<Popover open={iconPopoverOpen} onOpenChange={setIconPopoverOpen}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
className='h-10 w-20 rounded-[8px] border-border/50 p-0 transition-all duration-200 hover:border-border hover:bg-muted/50'
>
<SelectedIconComponent className='h-4 w-4' />
</Button>
</PopoverTrigger>
<PopoverContent className='z-50 w-84 rounded-[8px] p-0' align='start'>
<div className='p-3'>
<div className='grid max-h-80 grid-cols-8 gap-2 overflow-y-auto'>
{icons.map((icon) => {
const IconComponent = icon.component
return (
<button
key={icon.value}
type='button'
onClick={() => {
field.onChange(icon.value)
setIconPopoverOpen(false)
}}
className={cn(
'flex h-8 w-8 items-center justify-center rounded-md border border-border/40 transition-all duration-200',
'hover:scale-105 hover:border-border hover:bg-muted/50 active:scale-95',
field.value === icon.value &&
'border-primary/30 bg-primary/10 text-primary'
)}
>
<IconComponent className='h-4 w-4' />
</button>
)
})}
</div>
</div>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='color'
render={({ field }) => (
<FormItem className='w-20'>
<FormLabel className='!text-foreground font-medium text-sm'>
Color
</FormLabel>
<FormControl>
<ColorPicker
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
className='h-10 w-20 rounded-[8px]'
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel className='!text-foreground font-medium text-sm'>Name</FormLabel>
<FormControl>
<Input
placeholder='Enter template name'
className='h-10 rounded-[8px]'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className='grid grid-cols-2 gap-4'>
<FormField
control={form.control}
name='author'
render={({ field }) => (
<FormItem>
<FormLabel className='!text-foreground font-medium text-sm'>
Author
</FormLabel>
<FormControl>
<Input
placeholder='Enter author name'
className='h-10 rounded-[8px]'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='authorType'
render={({ field }) => (
<FormItem>
<FormLabel className='!text-foreground font-medium text-sm'>
Author Type
</FormLabel>
<Select
onValueChange={(value) => {
field.onChange(value)
// Reset org selection when switching to user
if (value === 'user') {
form.setValue('organizationId', undefined)
}
}}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger className='h-10 rounded-[8px]'>
<SelectValue placeholder='Select author type' />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value='user'>User</SelectItem>
<SelectItem value='organization'>Organization</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Organization selector - only show when authorType is 'organization' */}
{authorType === 'organization' && (
<FormField
control={form.control}
name='organizationId'
render={({ field }) => (
<FormItem>
<FormLabel className='!text-foreground font-medium text-sm'>
Organization
</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className='h-10 rounded-[8px]'>
<SelectValue placeholder='Select an organization' />
</SelectTrigger>
</FormControl>
<SelectContent>
{loadingOrgs ? (
<SelectItem value='loading' disabled>
Loading organizations...
</SelectItem>
) : organizations.length === 0 ? (
<SelectItem value='none' disabled>
No organizations available
</SelectItem>
) : (
organizations.map((org) => (
<SelectItem key={org.id} value={org.id}>
{org.name}
</SelectItem>
))
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name='description'
render={({ field }) => (
<FormItem>
<FormLabel className='!text-foreground font-medium text-sm'>
Description
</FormLabel>
<FormControl>
<Textarea
placeholder='Describe what this template does...'
className='min-h-[80px] resize-none rounded-[8px]'
rows={3}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
{/* Fixed Footer */}
<div className='mt-auto border-t px-6 py-4'>
<div className='flex items-center'>
{existingTemplate && (
<Button
type='button'
variant='destructive'
onClick={() => setShowDeleteDialog(true)}
disabled={isSubmitting || isLoadingTemplate}
className='h-9 rounded-[8px] px-4'
>
Delete
</Button>
)}
<Button
type='submit'
disabled={isSubmitting || !isFormValid || isLoadingTemplate}
className={cn(
'ml-auto h-9 rounded-[8px] px-4 font-[480]',
'bg-[var(--brand-primary-hex)] hover:bg-[var(--brand-primary-hover-hex)]',
'shadow-[0_0_0_0_var(--brand-primary-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
'text-white transition-all duration-200',
'disabled:opacity-50 disabled:hover:bg-[var(--brand-primary-hex)] disabled:hover:shadow-none'
)}
>
{isSubmitting ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
{existingTemplate ? 'Updating...' : 'Publishing...'}
</>
) : existingTemplate ? (
'Update Template'
) : (
'Publish Template'
)}
</Button>
</div>
</div>
</form>
</Form>
{existingTemplate && (
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Template?</AlertDialogTitle>
<AlertDialogDescription>
Deleting this template will remove it from the gallery. This action cannot be
undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className='flex'>
<AlertDialogCancel className='h-9 w-full rounded-[8px]' disabled={isDeleting}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
className='h-9 w-full rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
disabled={isDeleting}
onClick={async () => {
if (!existingTemplate) return
setIsDeleting(true)
try {
const resp = await fetch(`/api/templates/${existingTemplate.id}`, {
method: 'DELETE',
})
if (!resp.ok) {
const err = await resp.json().catch(() => ({}))
throw new Error(err.error || 'Failed to delete template')
}
setShowDeleteDialog(false)
onOpenChange(false)
} catch (err) {
logger.error('Failed to delete template', err)
} finally {
setIsDeleting(false)
}
}}
>
{isDeleting ? 'Deleting...' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -35,37 +35,64 @@ const NoteMarkdown = memo(function NoteMarkdown({ content }: { content: string }
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
p: ({ children }) => <p className='mb-0 text-[#E5E5E5] text-sm'>{children}</p>,
h1: ({ children }) => (
<h1 className='mt-0 mb-[-2px] font-semibold text-[#E5E5E5] text-lg'>{children}</h1>
p: ({ children }: any) => (
<p className='mb-2 break-words text-[#E5E5E5] text-sm'>{children}</p>
),
h2: ({ children }) => (
<h2 className='mt-0 mb-[-2px] font-semibold text-[#E5E5E5] text-base'>{children}</h2>
h1: ({ children }: any) => (
<h1 className='mt-3 mb-1 break-words font-semibold text-[#E5E5E5] text-lg first:mt-0'>
{children}
</h1>
),
h3: ({ children }) => (
<h3 className='mt-0 mb-[-2px] font-semibold text-[#E5E5E5] text-sm'>{children}</h3>
h2: ({ children }: any) => (
<h2 className='mt-3 mb-1 break-words font-semibold text-[#E5E5E5] text-base first:mt-0'>
{children}
</h2>
),
h4: ({ children }) => (
<h4 className='mt-0 mb-[-2px] font-semibold text-[#E5E5E5] text-xs'>{children}</h4>
h3: ({ children }: any) => (
<h3 className='mt-3 mb-1 break-words font-semibold text-[#E5E5E5] text-sm first:mt-0'>
{children}
</h3>
),
ul: ({ children }) => (
<ul className='-mt-[2px] mb-0 list-disc pl-4 text-[#E5E5E5] text-sm'>{children}</ul>
h4: ({ children }: any) => (
<h4 className='mt-3 mb-1 break-words font-semibold text-[#E5E5E5] text-xs first:mt-0'>
{children}
</h4>
),
ol: ({ children }) => (
<ol className='-mt-[2px] mb-0 list-decimal pl-4 text-[#E5E5E5] text-sm'>{children}</ol>
ul: ({ children }: any) => (
<ul className='mt-1 mb-2 list-disc break-words pl-4 text-[#E5E5E5] text-sm'>
{children}
</ul>
),
li: ({ children }) => <li className='mb-0'>{children}</li>,
code: ({ inline, children }: any) =>
inline ? (
<code className='rounded bg-[var(--divider)] px-1 py-0.5 text-[#F59E0B] text-xs'>
ol: ({ children }: any) => (
<ol className='mt-1 mb-2 list-decimal break-words pl-4 text-[#E5E5E5] text-sm'>
{children}
</ol>
),
li: ({ children }: any) => <li className='mb-0 break-words'>{children}</li>,
code: ({ inline, className, children, ...props }: any) => {
const isInline = inline || !className?.includes('language-')
if (isInline) {
return (
<code
{...props}
className='whitespace-normal rounded bg-gray-200 px-1 py-0.5 font-mono text-[#F59E0B] text-xs dark:bg-[var(--surface-11)] dark:text-[#F59E0B]'
>
{children}
</code>
)
}
return (
<code
{...props}
className='block whitespace-pre-wrap break-words rounded bg-[#1A1A1A] p-2 text-[#E5E5E5] text-xs'
>
{children}
</code>
) : (
<code className='block rounded bg-[#1A1A1A] p-2 text-[#E5E5E5] text-xs'>
{children}
</code>
),
a: ({ href, children }) => (
)
},
a: ({ href, children }: any) => (
<a
href={href}
target='_blank'
@@ -75,10 +102,12 @@ const NoteMarkdown = memo(function NoteMarkdown({ content }: { content: string }
{children}
</a>
),
strong: ({ children }) => <strong className='font-semibold text-white'>{children}</strong>,
em: ({ children }) => <em className='text-[#B8B8B8]'>{children}</em>,
blockquote: ({ children }) => (
<blockquote className='m-0 border-[#F59E0B] border-l-2 pl-3 text-[#B8B8B8] italic'>
strong: ({ children }: any) => (
<strong className='break-words font-semibold text-white'>{children}</strong>
),
em: ({ children }: any) => <em className='break-words text-[#B8B8B8]'>{children}</em>,
blockquote: ({ children }: any) => (
<blockquote className='mt-1 mb-2 break-words border-[#F59E0B] border-l-2 pl-3 text-[#B8B8B8] italic'>
{children}
</blockquote>
),
@@ -181,15 +210,13 @@ export const NoteBlock = memo(function NoteBlock({ id, data }: NodeProps<NoteBlo
</div>
<div className='relative px-[12px] pt-[6px] pb-[8px]'>
<div className='relative whitespace-pre-wrap break-words'>
<div className='relative break-words'>
{isEmpty ? (
<p className='text-[#868686] text-sm italic'>Add a note...</p>
) : showMarkdown ? (
<NoteMarkdown content={content} />
) : (
<p className='whitespace-pre-wrap text-[#E5E5E5] text-sm leading-relaxed'>
{content}
</p>
<p className='whitespace-pre-wrap text-[#E5E5E5] text-sm leading-snug'>{content}</p>
)}
</div>
</div>

View File

@@ -1,6 +1,7 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { shallow } from 'zustand/shallow'
import { createLogger } from '@/lib/logs/console/logger'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -109,7 +110,11 @@ export function useMentionData(props: UseMentionDataProps) {
const [workflowBlocks, setWorkflowBlocks] = useState<WorkflowBlockItem[]>([])
const [isLoadingWorkflowBlocks, setIsLoadingWorkflowBlocks] = useState(false)
const workflowStoreBlocks = useWorkflowStore((state) => state.blocks)
// Only subscribe to block keys to avoid re-rendering on position updates
const blockKeys = useWorkflowStore(
useCallback((state) => Object.keys(state.blocks), []),
shallow
)
// Use workflow registry as source of truth for workflows
const registryWorkflows = useWorkflowRegistry((state) => state.workflows)
@@ -139,15 +144,19 @@ export function useMentionData(props: UseMentionDataProps) {
/**
* Syncs workflow blocks from store
* Only re-runs when blocks are added/removed (not on position updates)
*/
useEffect(() => {
const syncWorkflowBlocks = async () => {
if (!workflowId || !workflowStoreBlocks || Object.keys(workflowStoreBlocks).length === 0) {
if (!workflowId || blockKeys.length === 0) {
setWorkflowBlocks([])
return
}
try {
// Fetch current blocks from store
const workflowStoreBlocks = useWorkflowStore.getState().blocks
const { registry: blockRegistry } = await import('@/blocks/registry')
const mapped = Object.values(workflowStoreBlocks).map((b: any) => {
const reg = (blockRegistry as any)[b.type]
@@ -169,7 +178,7 @@ export function useMentionData(props: UseMentionDataProps) {
}
syncWorkflowBlocks()
}, [workflowStoreBlocks, workflowId])
}, [blockKeys, workflowId])
/**
* Ensures past chats are loaded
@@ -323,10 +332,10 @@ export function useMentionData(props: UseMentionDataProps) {
if (!workflowId) return
logger.debug('ensureWorkflowBlocksLoaded called', {
workflowId,
storeBlocksCount: Object.keys(workflowStoreBlocks || {}).length,
storeBlocksCount: blockKeys.length,
workflowBlocksCount: workflowBlocks.length,
})
}, [workflowId, workflowStoreBlocks, workflowBlocks.length])
}, [workflowId, blockKeys.length, workflowBlocks.length])
return {
// State

View File

@@ -1,16 +1,14 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useParams } from 'next/navigation'
import { Tooltip } from '@/components/emcn'
import {
type SlackChannelInfo,
SlackChannelSelector,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/channel-selector/components/slack-channel-selector'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-foreign-credential'
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 type { SelectorContext } from '@/hooks/selectors/types'
interface ChannelSelectorInputProps {
blockId: string
@@ -41,14 +39,12 @@ export function ChannelSelectorInput({
const effectiveAuthMethod = previewContextValues?.authMethod ?? authMethod
const effectiveBotToken = previewContextValues?.botToken ?? botToken
const effectiveCredential = previewContextValues?.credential ?? connectedCredential
const [selectedChannelId, setSelectedChannelId] = useState<string>('')
const [_channelInfo, setChannelInfo] = useState<SlackChannelInfo | null>(null)
const [_channelInfo, setChannelInfo] = useState<string | null>(null)
// Get provider-specific values
const provider = subBlock.provider || 'slack'
const isSlack = provider === 'slack'
// Central dependsOn gating
const { finalDisabled, dependsOn, dependencyValues } = useDependsOnGate(blockId, subBlock, {
const { finalDisabled, dependsOn } = useDependsOnGate(blockId, subBlock, {
disabled,
isPreview,
previewContextValues,
@@ -69,70 +65,60 @@ export function ChannelSelectorInput({
// Get the current value from the store or prop value if in preview mode (same pattern as file-selector)
useEffect(() => {
const val = isPreview && previewValue !== undefined ? previewValue : storeValue
if (val && typeof val === 'string') {
setSelectedChannelId(val)
if (typeof val === 'string') {
setChannelInfo(val)
}
}, [isPreview, previewValue, storeValue])
// Clear channel when any declared dependency changes (e.g., authMethod/credential)
const prevDepsSigRef = useRef<string>('')
useEffect(() => {
if (dependsOn.length === 0) return
const currentSig = JSON.stringify(dependencyValues)
if (prevDepsSigRef.current && prevDepsSigRef.current !== currentSig) {
if (!isPreview) {
setSelectedChannelId('')
setChannelInfo(null)
setStoreValue('')
}
}
prevDepsSigRef.current = currentSig
}, [dependsOn, dependencyValues, isPreview, setStoreValue])
const requiresCredential = dependsOn.includes('credential')
const missingCredential = !credential || credential.trim().length === 0
const shouldForceDisable = requiresCredential && (missingCredential || isForeignCredential)
// Handle channel selection (same pattern as file-selector)
const handleChannelChange = (channelId: string, info?: SlackChannelInfo) => {
setSelectedChannelId(channelId)
setChannelInfo(info || null)
if (!isPreview) {
setStoreValue(channelId)
}
onChannelSelect?.(channelId)
}
const context: SelectorContext = useMemo(
() => ({
credentialId: credential,
workflowId: workflowIdFromUrl,
}),
[credential, workflowIdFromUrl]
)
// Render Slack channel selector
if (isSlack) {
if (!isSlack) {
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='w-full'>
<SlackChannelSelector
value={selectedChannelId}
onChange={(channelId: string, channelInfo?: SlackChannelInfo) => {
handleChannelChange(channelId, channelInfo)
}}
credential={credential}
label={subBlock.placeholder || 'Select Slack channel'}
disabled={finalDisabled}
workflowId={workflowIdFromUrl}
isForeignCredential={isForeignCredential}
/>
<div className='w-full rounded border border-dashed p-4 text-center text-muted-foreground text-sm'>
Channel selector not supported for provider: {provider}
</div>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>This channel selector is not yet implemented for {provider}</p>
</Tooltip.Content>
</Tooltip.Root>
)
}
// Default fallback for unsupported providers
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='w-full rounded border border-dashed p-4 text-center text-muted-foreground text-sm'>
Channel selector not supported for provider: {provider}
<div className='w-full'>
<SelectorCombobox
blockId={blockId}
subBlock={subBlock}
selectorKey='slack.channels'
selectorContext={context}
disabled={finalDisabled || shouldForceDisable || isForeignCredential}
isPreview={isPreview}
previewValue={previewValue ?? null}
placeholder={subBlock.placeholder || 'Select Slack channel'}
onOptionChange={(value) => {
setChannelInfo(value)
if (!isPreview) {
onChannelSelect?.(value)
}
}}
/>
</div>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>This channel selector is not yet implemented for {provider}</p>
</Tooltip.Content>
</Tooltip.Root>
)
}

View File

@@ -1,219 +0,0 @@
import { useCallback, useState } from 'react'
import { Check, ChevronDown, Hash, Lock, RefreshCw } from 'lucide-react'
import { SlackIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { useDisplayNamesStore } from '@/stores/display-names/store'
export interface SlackChannelInfo {
id: string
name: string
isPrivate: boolean
}
interface SlackChannelSelectorProps {
value: string
onChange: (channelId: string, channelInfo?: SlackChannelInfo) => void
credential: string
label?: string
disabled?: boolean
workflowId?: string
isForeignCredential?: boolean
}
export function SlackChannelSelector({
value,
onChange,
credential,
label = 'Select Slack channel',
disabled = false,
workflowId,
isForeignCredential = false,
}: SlackChannelSelectorProps) {
const [channels, setChannels] = useState<SlackChannelInfo[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const [initialFetchDone, setInitialFetchDone] = useState(false)
// Get cached display name
const cachedChannelName = useDisplayNamesStore(
useCallback(
(state) => {
if (!credential || !value) return null
return state.cache.channels[credential]?.[value] || null
},
[credential, value]
)
)
// Fetch channels from Slack API
const fetchChannels = useCallback(async () => {
if (!credential) return
setLoading(true)
setError(null)
try {
const res = await fetch('/api/tools/slack/channels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential, workflowId }),
})
if (!res.ok) {
const errorData = await res
.json()
.catch(() => ({ error: `HTTP error! status: ${res.status}` }))
setError(errorData.error || `HTTP error! status: ${res.status}`)
setChannels([])
setInitialFetchDone(true)
return
}
const data = await res.json()
if (data.error) {
setError(data.error)
setChannels([])
setInitialFetchDone(true)
} else {
setChannels(data.channels)
setInitialFetchDone(true)
// Cache channel names in display names store
if (credential) {
const channelMap = data.channels.reduce(
(acc: Record<string, string>, ch: SlackChannelInfo) => {
acc[ch.id] = `#${ch.name}`
return acc
},
{}
)
useDisplayNamesStore.getState().setDisplayNames('channels', credential, channelMap)
}
}
} catch (err) {
if ((err as Error).name === 'AbortError') return
setError((err as Error).message)
setChannels([])
setInitialFetchDone(true)
} finally {
setLoading(false)
}
}, [credential])
// Handle dropdown open/close - fetch channels when opening
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen)
// Only fetch channels when opening the dropdown and if we have valid credential
if (isOpen && credential && (!initialFetchDone || channels.length === 0)) {
fetchChannels()
}
}
const handleSelectChannel = (channel: SlackChannelInfo) => {
onChange(channel.id, channel)
setOpen(false)
}
const getChannelIcon = (channel: SlackChannelInfo) => {
return channel.isPrivate ? <Lock className='h-1.5 w-1.5' /> : <Hash className='h-1.5 w-1.5' />
}
const formatChannelName = (channel: SlackChannelInfo) => {
return channel.name
}
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className='relative w-full justify-between'
disabled={disabled || !credential}
title={isForeignCredential ? 'Using a shared account' : undefined}
>
<div className='flex max-w-[calc(100%-20px)] items-center gap-2 overflow-hidden'>
<SlackIcon className='h-4 w-4 text-[#611f69]' />
{cachedChannelName ? (
<span className='truncate font-normal'>{cachedChannelName}</span>
) : (
<span className='truncate text-muted-foreground'>{label}</span>
)}
</div>
<ChevronDown className='absolute right-3 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[250px] p-0' align='start'>
<Command>
<CommandInput placeholder='Search channels...' />
<CommandList>
<CommandEmpty>
{loading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading channels...</span>
</div>
) : error ? (
<div className='p-4 text-center'>
<p className='text-destructive text-sm'>{error}</p>
</div>
) : !credential ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>Missing credentials</p>
<p className='text-muted-foreground text-xs'>
Please configure Slack credentials.
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No channels found</p>
<p className='text-muted-foreground text-xs'>
No channels available for this Slack workspace.
</p>
</div>
)}
</CommandEmpty>
{channels.length > 0 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Channels
</div>
{channels.map((channel) => (
<CommandItem
key={channel.id}
value={`channel-${channel.id}-${channel.name}`}
onSelect={() => handleSelectChannel(channel)}
className='cursor-pointer'
>
<div className='flex items-center gap-2 overflow-hidden'>
<SlackIcon className='h-4 w-4 text-[#611f69]' />
{getChannelIcon(channel)}
<span className='truncate font-normal'>{formatChannelName(channel)}</span>
{channel.isPrivate && (
<span className='ml-auto text-muted-foreground text-xs'>Private</span>
)}
</div>
{channel.id === value && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

View File

@@ -35,6 +35,7 @@ import { WandPromptBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/comp
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand'
import type { GenerationType } from '@/blocks/types'
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
import { useTagSelection } from '@/hooks/use-tag-selection'
import { normalizeBlockName } from '@/stores/workflows/utils'
@@ -99,14 +100,15 @@ const createHighlightFunction = (
let processedCode = codeToHighlight
// Replace environment variables with placeholders
processedCode = processedCode.replace(/\{\{([^}]+)\}\}/g, (match) => {
processedCode = processedCode.replace(createEnvVarPattern(), (match) => {
const placeholder = `__ENV_VAR_${placeholders.length}__`
placeholders.push({ placeholder, original: match, type: 'env' })
return placeholder
})
// Replace variable references with placeholders
processedCode = processedCode.replace(/<([^>]+)>/g, (match) => {
// Use [^<>]+ to prevent matching across nested brackets (e.g., "<3 <real.ref>" should match separately)
processedCode = processedCode.replace(createReferencePattern(), (match) => {
if (shouldHighlightReference(match)) {
const placeholder = `__VAR_REF_${placeholders.length}__`
placeholders.push({ placeholder, original: match, type: 'var' })

View File

@@ -31,6 +31,7 @@ import {
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
import { useTagSelection } from '@/hooks/use-tag-selection'
import { normalizeBlockName } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -864,25 +865,41 @@ export function ConditionInput({
placeholder: string
original: string
type: 'var' | 'env'
shouldHighlight: boolean
}[] = []
let processedCode = codeToHighlight
// Replace environment variables with placeholders
processedCode = processedCode.replace(/\{\{([^}]+)\}\}/g, (match) => {
processedCode = processedCode.replace(createEnvVarPattern(), (match) => {
const placeholder = `__ENV_VAR_${placeholders.length}__`
placeholders.push({ placeholder, original: match, type: 'env' })
placeholders.push({
placeholder,
original: match,
type: 'env',
shouldHighlight: true,
})
return placeholder
})
// Replace variable references with placeholders
processedCode = processedCode.replace(/<([^>]+)>/g, (match) => {
if (shouldHighlightReference(match)) {
const placeholder = `__VAR_REF_${placeholders.length}__`
placeholders.push({ placeholder, original: match, type: 'var' })
return placeholder
// Use [^<>]+ to prevent matching across nested brackets (e.g., "<3 <real.ref>" should match separately)
processedCode = processedCode.replace(
createReferencePattern(),
(match) => {
const shouldHighlight = shouldHighlightReference(match)
if (shouldHighlight) {
const placeholder = `__VAR_REF_${placeholders.length}__`
placeholders.push({
placeholder,
original: match,
type: 'var',
shouldHighlight: true,
})
return placeholder
}
return match
}
return match
})
)
// Apply Prism syntax highlighting
let highlightedCode = highlight(
@@ -892,21 +909,25 @@ export function ConditionInput({
)
// Restore and highlight the placeholders
placeholders.forEach(({ placeholder, original, type }) => {
if (type === 'env') {
highlightedCode = highlightedCode.replace(
placeholder,
`<span class="text-blue-500">${original}</span>`
)
} else if (type === 'var') {
// Escape the < and > for display
const escaped = original.replace(/</g, '&lt;').replace(/>/g, '&gt;')
highlightedCode = highlightedCode.replace(
placeholder,
`<span class="text-blue-500">${escaped}</span>`
)
placeholders.forEach(
({ placeholder, original, type, shouldHighlight }) => {
if (!shouldHighlight) return
if (type === 'env') {
highlightedCode = highlightedCode.replace(
placeholder,
`<span class="text-blue-500">${original}</span>`
)
} else if (type === 'var') {
// Escape the < and > for display
const escaped = original.replace(/</g, '&lt;').replace(/>/g, '&gt;')
highlightedCode = highlightedCode.replace(
placeholder,
`<span class="text-blue-500">${escaped}</span>`
)
}
}
})
)
return highlightedCode
}}

View File

@@ -1,20 +1,10 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Check, ChevronDown, ExternalLink, RefreshCw } from 'lucide-react'
import { Button } from '@/components/emcn/components/button/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { ExternalLink } from 'lucide-react'
import { Button, Combobox } from '@/components/emcn/components'
import { createLogger } from '@/lib/logs/console/logger'
import {
type Credential,
getCanonicalScopesForProvider,
getProviderIdFromServiceId,
getServiceIdFromScopes,
@@ -25,9 +15,8 @@ import {
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
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 { useOAuthCredentialDetail, useOAuthCredentials } from '@/hooks/queries/oauth-credentials'
import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
import { useDisplayNamesStore } from '@/stores/display-names/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('CredentialSelector')
@@ -47,262 +36,133 @@ export function CredentialSelector({
isPreview = false,
previewValue,
}: CredentialSelectorProps) {
const [open, setOpen] = useState(false)
const [credentials, setCredentials] = useState<Credential[]>([])
const [isLoading, setIsLoading] = useState(false)
const [showOAuthModal, setShowOAuthModal] = useState(false)
const [selectedId, setSelectedId] = useState('')
const [hasForeignMeta, setHasForeignMeta] = useState(false)
const [inputValue, setInputValue] = useState('')
const [isEditing, setIsEditing] = useState(false)
const { activeWorkflowId } = useWorkflowRegistry()
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
const [storeValue, setStoreValue] = useSubBlockValue<string | null>(blockId, subBlock.id)
// Use collaborative state management via useSubBlockValue hook
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
// Extract values from subBlock config
const provider = subBlock.provider as OAuthProvider
const requiredScopes = subBlock.requiredScopes || []
const label = subBlock.placeholder || 'Select credential'
const serviceId = subBlock.serviceId
// Get the effective value (preview or store value)
const effectiveValue = isPreview && previewValue !== undefined ? previewValue : storeValue
const selectedId = typeof effectiveValue === 'string' ? effectiveValue : ''
const effectiveServiceId = useMemo(
() => serviceId || getServiceIdFromScopes(provider, requiredScopes),
[provider, requiredScopes, serviceId]
)
const effectiveProviderId = useMemo(
() => getProviderIdFromServiceId(effectiveServiceId),
[effectiveServiceId]
)
const {
data: credentials = [],
isFetching: credentialsLoading,
refetch: refetchCredentials,
} = useOAuthCredentials(effectiveProviderId, Boolean(effectiveProviderId))
const selectedCredential = useMemo(
() => credentials.find((cred) => cred.id === selectedId),
[credentials, selectedId]
)
const shouldFetchForeignMeta =
Boolean(selectedId) &&
!selectedCredential &&
Boolean(activeWorkflowId) &&
Boolean(effectiveProviderId)
const { data: foreignCredentials = [], isFetching: foreignMetaLoading } =
useOAuthCredentialDetail(
shouldFetchForeignMeta ? selectedId : undefined,
activeWorkflowId || undefined,
shouldFetchForeignMeta
)
const hasForeignMeta = foreignCredentials.length > 0
const isForeign = Boolean(selectedId && !selectedCredential && hasForeignMeta)
const resolvedLabel = useMemo(() => {
if (selectedCredential) return selectedCredential.name
if (isForeign) return 'Saved by collaborator'
return ''
}, [selectedCredential, isForeign])
// Initialize selectedId with the effective value
useEffect(() => {
setSelectedId(effectiveValue || '')
}, [effectiveValue])
if (!isEditing) {
setInputValue(resolvedLabel)
}
}, [resolvedLabel, isEditing])
// Derive service and provider IDs using useMemo
const effectiveServiceId = useMemo(() => {
return serviceId || getServiceIdFromScopes(provider, requiredScopes)
}, [provider, requiredScopes, serviceId])
const invalidSelection =
!isPreview &&
Boolean(selectedId) &&
!selectedCredential &&
!hasForeignMeta &&
!credentialsLoading &&
!foreignMetaLoading
const effectiveProviderId = useMemo(() => {
return getProviderIdFromServiceId(effectiveServiceId)
}, [effectiveServiceId])
useEffect(() => {
if (!invalidSelection) return
logger.info('Clearing invalid credential selection - credential was disconnected', {
selectedId,
provider: effectiveProviderId,
})
setStoreValue('')
}, [invalidSelection, selectedId, effectiveProviderId, setStoreValue])
// Fetch available credentials for this provider
const fetchCredentials = useCallback(async () => {
setIsLoading(true)
try {
const response = await fetch(`/api/auth/oauth/credentials?provider=${effectiveProviderId}`)
if (response.ok) {
const data = await response.json()
const creds = data.credentials as Credential[]
let foreignMetaFound = false
useCredentialRefreshTriggers(refetchCredentials, effectiveProviderId, provider)
// If persisted selection is not among viewer's credentials, attempt to fetch its metadata
if (
selectedId &&
!(creds || []).some((cred: Credential) => cred.id === selectedId) &&
activeWorkflowId
) {
try {
const metaResp = await fetch(
`/api/auth/oauth/credentials?credentialId=${selectedId}&workflowId=${activeWorkflowId}`
)
if (metaResp.ok) {
const meta = await metaResp.json()
if (meta.credentials?.length) {
// Mark as foreign, but do NOT merge into list to avoid leaking owner email
foreignMetaFound = true
}
}
} catch {
// ignore meta errors
}
}
setHasForeignMeta(foreignMetaFound)
setCredentials(creds)
// Cache credential names in display names store
if (effectiveProviderId) {
const credentialMap = creds.reduce((acc: Record<string, string>, cred: Credential) => {
acc[cred.id] = cred.name
return acc
}, {})
useDisplayNamesStore
.getState()
.setDisplayNames('credentials', effectiveProviderId, credentialMap)
}
// Check if the currently selected credential still exists
const selectedCredentialStillExists = (creds || []).some(
(cred: Credential) => cred.id === selectedId
)
const shouldClearPersistedSelection =
!isPreview && selectedId && !selectedCredentialStillExists && !foreignMetaFound
if (shouldClearPersistedSelection) {
logger.info('Clearing invalid credential selection - credential was disconnected', {
selectedId,
provider: effectiveProviderId,
})
// Clear via setStoreValue to trigger cascade
setStoreValue('')
setSelectedId('')
if (effectiveProviderId) {
useDisplayNamesStore
.getState()
.removeDisplayName('credentials', effectiveProviderId, selectedId)
}
}
const handleOpenChange = useCallback(
(isOpen: boolean) => {
if (isOpen) {
void refetchCredentials()
}
} catch (error) {
logger.error('Error fetching credentials:', { error })
} finally {
setIsLoading(false)
}
}, [effectiveProviderId, selectedId, activeWorkflowId, isPreview, setStoreValue])
},
[refetchCredentials]
)
// Fetch credentials on initial mount and whenever the subblock value changes externally
useEffect(() => {
fetchCredentials()
}, [fetchCredentials, effectiveValue])
// When the selectedId changes (e.g., collaborator saved a credential), determine if it's foreign
useEffect(() => {
let aborted = false
;(async () => {
try {
if (!selectedId) {
setHasForeignMeta(false)
return
}
// If the selected credential exists in viewer's list, it's not foreign
if ((credentials || []).some((cred) => cred.id === selectedId)) {
setHasForeignMeta(false)
return
}
if (!activeWorkflowId) return
const metaResp = await fetch(
`/api/auth/oauth/credentials?credentialId=${selectedId}&workflowId=${activeWorkflowId}`
)
if (aborted) return
if (metaResp.ok) {
const meta = await metaResp.json()
setHasForeignMeta(!!meta.credentials?.length)
}
} catch {
// ignore
}
})()
return () => {
aborted = true
}
}, [selectedId, credentials, activeWorkflowId])
// This effect is no longer needed since we're using effectiveValue directly
// Listen for visibility changes to update credentials when user returns from settings
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
fetchCredentials()
}
}
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
}, [fetchCredentials])
// Also handle BFCache restores (back/forward navigation) where visibility change may not fire reliably
useEffect(() => {
const handlePageShow = (event: any) => {
if (event?.persisted) {
fetchCredentials()
}
}
window.addEventListener('pageshow', handlePageShow)
return () => {
window.removeEventListener('pageshow', handlePageShow)
}
}, [fetchCredentials])
// Listen for credential disconnection events from settings modal
useEffect(() => {
const handleCredentialDisconnected = (event: Event) => {
const customEvent = event as CustomEvent
const { providerId } = customEvent.detail
// Re-fetch if this disconnection affects our provider
if (providerId && (providerId === effectiveProviderId || providerId.startsWith(provider))) {
fetchCredentials()
}
}
window.addEventListener('credential-disconnected', handleCredentialDisconnected)
return () => {
window.removeEventListener('credential-disconnected', handleCredentialDisconnected)
}
}, [fetchCredentials, effectiveProviderId, provider])
// Handle popover open to fetch fresh credentials
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen)
if (isOpen) {
// Fetch fresh credentials when opening the dropdown
fetchCredentials()
}
}
// Get the selected credential
const selectedCredential = credentials.find((cred) => cred.id === selectedId)
const isForeign = !!(selectedId && !selectedCredential && hasForeignMeta)
// If the list doesnt contain the effective value but meta says it exists, synthesize a non-leaky placeholder to render stable UI
const displayName = selectedCredential
? selectedCredential.name
: isForeign
? 'Saved by collaborator'
: undefined
// Determine if additional permissions are required for the selected credential
const hasSelection = !!selectedCredential
const hasSelection = Boolean(selectedCredential)
const missingRequiredScopes = hasSelection
? getMissingRequiredScopes(selectedCredential, requiredScopes || [])
? getMissingRequiredScopes(selectedCredential!, requiredScopes || [])
: []
const needsUpdate =
hasSelection && missingRequiredScopes.length > 0 && !disabled && !isPreview && !isLoading
hasSelection &&
missingRequiredScopes.length > 0 &&
!disabled &&
!isPreview &&
!credentialsLoading
// Handle selection
const handleSelect = (credentialId: string) => {
const previousId = selectedId || (effectiveValue as string) || ''
setSelectedId(credentialId)
if (!isPreview) {
const handleSelect = useCallback(
(credentialId: string) => {
if (isPreview) return
setStoreValue(credentialId)
}
setOpen(false)
}
setIsEditing(false)
},
[isPreview, setStoreValue]
)
// Handle adding a new credential
const handleAddCredential = () => {
// Show the OAuth modal
const handleAddCredential = useCallback(() => {
setShowOAuthModal(true)
setOpen(false)
}
}, [])
// Get provider icon
const getProviderIcon = (providerName: OAuthProvider) => {
const getProviderIcon = useCallback((providerName: OAuthProvider) => {
const { baseProvider } = parseProvider(providerName)
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
if (!baseProviderConfig) {
return <ExternalLink className='h-4 w-4' />
return <ExternalLink className='h-3 w-3' />
}
// Always use the base provider icon for a more consistent UI
return baseProviderConfig.icon({ className: 'h-4 w-4' })
}
return baseProviderConfig.icon({ className: 'h-3 w-3' })
}, [])
// Get provider name
const getProviderName = (providerName: OAuthProvider) => {
const getProviderName = useCallback((providerName: OAuthProvider) => {
const { baseProvider } = parseProvider(providerName)
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
@@ -310,88 +170,79 @@ export function CredentialSelector({
return baseProviderConfig.name
}
// Fallback: capitalize the provider name
return providerName
.split('-')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
}
}, [])
const comboboxOptions = useMemo(() => {
const options = credentials.map((cred) => ({
label: cred.name,
value: cred.id,
}))
if (credentials.length === 0) {
options.push({
label: `Connect ${getProviderName(provider)} account`,
value: '__connect_account__',
})
}
return options
}, [credentials, provider, getProviderName])
const selectedCredentialProvider = selectedCredential?.provider ?? provider
const overlayContent = useMemo(() => {
if (!inputValue) return null
return (
<div className='flex w-full items-center truncate'>
<div className='mr-2 flex-shrink-0 opacity-90'>
{getProviderIcon(selectedCredentialProvider)}
</div>
<span className='truncate'>{inputValue}</span>
</div>
)
}, [getProviderIcon, inputValue, selectedCredentialProvider])
const handleComboboxChange = useCallback(
(value: string) => {
if (value === '__connect_account__') {
handleAddCredential()
return
}
const matchedCred = credentials.find((c) => c.id === value)
if (matchedCred) {
setInputValue(matchedCred.name)
handleSelect(value)
return
}
setIsEditing(true)
setInputValue(value)
},
[credentials, handleAddCredential, handleSelect]
)
return (
<>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className='relative w-full justify-between'
disabled={disabled}
>
<div className='flex max-w-[calc(100%-20px)] items-center gap-2 overflow-hidden'>
{getProviderIcon(provider)}
<span
className={displayName ? 'truncate font-normal' : 'truncate text-muted-foreground'}
>
{displayName || label}
</span>
</div>
<ChevronDown className='absolute right-3 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[250px] p-0' align='start'>
<Command>
<CommandInput
placeholder='Search credentials...'
className='text-foreground placeholder:text-muted-foreground'
/>
<CommandList>
<CommandEmpty>
{isLoading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading credentials...</span>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No credentials found.</p>
<p className='text-muted-foreground text-xs'>
Connect a new account to continue.
</p>
</div>
)}
</CommandEmpty>
{credentials.length > 0 && (
<CommandGroup>
{credentials.map((cred) => (
<CommandItem
key={cred.id}
value={cred.id}
onSelect={() => handleSelect(cred.id)}
>
<div className='flex items-center gap-2'>
{getProviderIcon(cred.provider)}
<span className='font-normal'>{cred.name}</span>
</div>
{cred.id === selectedId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
{credentials.length === 0 && (
<CommandGroup>
<CommandItem onSelect={handleAddCredential}>
<div className='flex items-center gap-2 text-foreground'>
{getProviderIcon(provider)}
<span>Connect {getProviderName(provider)} account</span>
</div>
</CommandItem>
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Combobox
options={comboboxOptions}
value={inputValue}
selectedValue={selectedId}
onChange={handleComboboxChange}
onOpenChange={handleOpenChange}
placeholder={label}
disabled={disabled}
editable={true}
filterOptions={true}
isLoading={credentialsLoading}
overlayContent={overlayContent}
className={selectedId ? 'pl-[28px]' : ''}
/>
{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'>
@@ -414,3 +265,49 @@ export function CredentialSelector({
</>
)
}
function useCredentialRefreshTriggers(
refetchCredentials: () => Promise<unknown>,
effectiveProviderId?: string,
provider?: OAuthProvider
) {
useEffect(() => {
const refresh = () => {
void refetchCredentials()
}
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
refresh()
}
}
const handlePageShow = (event: Event) => {
if ('persisted' in event && (event as PageTransitionEvent).persisted) {
refresh()
}
}
const handleCredentialDisconnected = (event: Event) => {
const customEvent = event as CustomEvent<{ providerId?: string }>
const providerId = customEvent.detail?.providerId
if (
providerId &&
(providerId === effectiveProviderId || (provider && providerId.startsWith(provider)))
) {
refresh()
}
}
document.addEventListener('visibilitychange', handleVisibilityChange)
window.addEventListener('pageshow', handlePageShow)
window.addEventListener('credential-disconnected', handleCredentialDisconnected)
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
window.removeEventListener('pageshow', handlePageShow)
window.removeEventListener('credential-disconnected', handleCredentialDisconnected)
}
}, [refetchCredentials, effectiveProviderId, provider])
}

View File

@@ -1,23 +1,12 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { Check, ChevronDown, FileText, RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { useCallback, useMemo } from 'react'
import { Tooltip } from '@/components/emcn'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-depends-on-gate'
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 { useKnowledgeBaseDocuments } from '@/hooks/use-knowledge'
import { useDisplayNamesStore } from '@/stores/display-names/store'
import type { DocumentData } from '@/stores/knowledge/store'
import type { SelectorContext } from '@/hooks/selectors/types'
interface DocumentSelectorProps {
blockId: string
@@ -36,186 +25,54 @@ export function DocumentSelector({
isPreview = false,
previewValue,
}: DocumentSelectorProps) {
const [error, setError] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
const [knowledgeBaseId] = useSubBlockValue(blockId, 'knowledgeBaseId')
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
const [knowledgeBaseIdValue] = useSubBlockValue(blockId, 'knowledgeBaseId')
const normalizedKnowledgeBaseId =
typeof knowledgeBaseId === 'string' && knowledgeBaseId.trim().length > 0
? knowledgeBaseId
typeof knowledgeBaseIdValue === 'string' && knowledgeBaseIdValue.trim().length > 0
? knowledgeBaseIdValue
: null
const value = isPreview ? previewValue : storeValue
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
const isDisabled = finalDisabled
const {
documents,
isLoading: documentsLoading,
error: documentsError,
refreshDocuments,
} = useKnowledgeBaseDocuments(normalizedKnowledgeBaseId ?? '', {
limit: 500,
offset: 0,
enabled: open && Boolean(normalizedKnowledgeBaseId),
})
const handleOpenChange = (isOpen: boolean) => {
if (isPreview || isDisabled) return
setOpen(isOpen)
if (isOpen && normalizedKnowledgeBaseId) {
void refreshDocuments()
}
}
const handleSelectDocument = (document: DocumentData) => {
if (isPreview) return
setStoreValue(document.id)
onDocumentSelect?.(document.id)
setOpen(false)
}
useEffect(() => {
if (!normalizedKnowledgeBaseId) {
setError(null)
}
}, [normalizedKnowledgeBaseId])
useEffect(() => {
setError(documentsError)
}, [documentsError])
useEffect(() => {
if (!normalizedKnowledgeBaseId || documents.length === 0) return
const documentMap = documents.reduce<Record<string, string>>((acc, doc) => {
acc[doc.id] = doc.filename
return acc
}, {})
useDisplayNamesStore
.getState()
.setDisplayNames('documents', normalizedKnowledgeBaseId as string, documentMap)
}, [documents, normalizedKnowledgeBaseId])
const formatDocumentName = (document: DocumentData) => document.filename
const getDocumentDescription = (document: DocumentData) => {
const statusMap: Record<string, string> = {
pending: 'Processing pending',
processing: 'Processing...',
completed: 'Ready',
failed: 'Processing failed',
}
const status = statusMap[document.processingStatus] || document.processingStatus
const chunkText = `${document.chunkCount} chunk${document.chunkCount !== 1 ? 's' : ''}`
return `${status}${chunkText}`
}
const label = subBlock.placeholder || 'Select document'
const isLoading = documentsLoading && !error
// Always use cached display name
const displayName = useDisplayNamesStore(
useCallback(
(state) => {
if (!normalizedKnowledgeBaseId || !value || typeof value !== 'string') return null
return state.cache.documents[normalizedKnowledgeBaseId]?.[value] || null
},
[normalizedKnowledgeBaseId, value]
)
const selectorContext = useMemo<SelectorContext>(
() => ({
knowledgeBaseId: normalizedKnowledgeBaseId ?? undefined,
}),
[normalizedKnowledgeBaseId]
)
const handleDocumentChange = useCallback(
(documentId: string) => {
if (isPreview) return
onDocumentSelect?.(documentId)
},
[isPreview, onDocumentSelect]
)
const missingKnowledgeBase = !normalizedKnowledgeBaseId
const isDisabled = finalDisabled || missingKnowledgeBase
const placeholder = subBlock.placeholder || 'Select document'
return (
<div className='w-full'>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className='relative w-full justify-between'
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='w-full'>
<SelectorCombobox
blockId={blockId}
subBlock={subBlock}
selectorKey='knowledge.documents'
selectorContext={selectorContext}
disabled={isDisabled}
>
<div className='flex max-w-[calc(100%-20px)] items-center gap-2 overflow-hidden'>
<FileText className='h-4 w-4 text-muted-foreground' />
{displayName ? (
<span className='truncate font-normal'>{displayName}</span>
) : (
<span className='truncate text-muted-foreground'>{label}</span>
)}
</div>
<ChevronDown className='absolute right-3 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[300px] p-0' align='start'>
<Command>
<CommandInput placeholder='Search documents...' />
<CommandList>
<CommandEmpty>
{isLoading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading documents...</span>
</div>
) : error ? (
<div className='p-4 text-center'>
<p className='text-destructive text-sm'>{error}</p>
</div>
) : !normalizedKnowledgeBaseId ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No knowledge base selected</p>
<p className='text-muted-foreground text-xs'>
Please select a knowledge base first.
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No documents found</p>
<p className='text-muted-foreground text-xs'>
Upload documents to this knowledge base to get started.
</p>
</div>
)}
</CommandEmpty>
{documents.length > 0 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Documents
</div>
{documents.map((document) => (
<CommandItem
key={document.id}
value={`doc-${document.id}-${document.filename}`}
onSelect={() => handleSelectDocument(document)}
className='cursor-pointer'
>
<div className='flex items-center gap-2 overflow-hidden'>
<FileText className='h-4 w-4 text-muted-foreground' />
<div className='min-w-0 flex-1 overflow-hidden'>
<div className='truncate font-normal'>{formatDocumentName(document)}</div>
<div className='truncate text-muted-foreground text-xs'>
{getDocumentDescription(document)}
</div>
</div>
</div>
{document.id === value && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
isPreview={isPreview}
previewValue={previewValue ?? null}
placeholder={placeholder}
onOptionChange={handleDocumentChange}
/>
</div>
</Tooltip.Trigger>
{missingKnowledgeBase && (
<Tooltip.Content side='top'>
<p>Select a knowledge base first.</p>
</Tooltip.Content>
)}
</Tooltip.Root>
)
}

View File

@@ -1,630 +0,0 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Check, ChevronDown, ExternalLink, RefreshCw, X } from 'lucide-react'
import { ConfluenceIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { createLogger } from '@/lib/logs/console/logger'
import {
type Credential,
getProviderIdFromServiceId,
getServiceIdFromScopes,
type OAuthProvider,
} 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 { useDisplayNamesStore } from '@/stores/display-names/store'
const logger = createLogger('ConfluenceFileSelector')
export interface ConfluenceFileInfo {
id: string
name: string
mimeType: string
webViewLink?: string
modifiedTime?: string
spaceId?: string
url?: string
}
interface ConfluenceFileSelectorProps {
value: string
onChange: (value: string, fileInfo?: ConfluenceFileInfo) => void
provider: OAuthProvider
requiredScopes?: string[]
label?: string
disabled?: boolean
serviceId?: string
domain: string
showPreview?: boolean
onFileInfoChange?: (fileInfo: ConfluenceFileInfo | null) => void
credentialId?: string
workflowId?: string
isForeignCredential?: boolean
}
export function ConfluenceFileSelector({
value,
onChange,
provider,
requiredScopes = [],
label = 'Select Confluence page',
disabled = false,
serviceId,
domain,
showPreview = true,
onFileInfoChange,
credentialId,
workflowId,
isForeignCredential = false,
}: ConfluenceFileSelectorProps) {
const [open, setOpen] = useState(false)
const [credentials, setCredentials] = useState<Credential[]>([])
const [files, setFiles] = useState<ConfluenceFileInfo[]>([])
const [selectedCredentialId, setSelectedCredentialId] = useState<string>(credentialId || '')
const [selectedFileId, setSelectedFileId] = useState(value)
const [selectedFile, setSelectedFile] = useState<ConfluenceFileInfo | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [showOAuthModal, setShowOAuthModal] = useState(false)
const initialFetchRef = useRef(false)
const [error, setError] = useState<string | null>(null)
// Get cached display name
const cachedFileName = useDisplayNamesStore(
useCallback(
(state) => {
const effectiveCredentialId = credentialId || selectedCredentialId
if (!effectiveCredentialId || !value) return null
return state.cache.files[effectiveCredentialId]?.[value] || null
},
[credentialId, selectedCredentialId, value]
)
)
// Keep internal credential in sync with prop (handles late arrival and BFCache restores)
useEffect(() => {
if (credentialId && credentialId !== selectedCredentialId) {
setSelectedCredentialId(credentialId)
}
}, [credentialId, selectedCredentialId])
// Handle search with debounce
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const handleSearch = (value: string) => {
// Clear any existing timeout
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current)
}
// Set a new timeout
searchTimeoutRef.current = setTimeout(() => {
if (value.length > 2) {
fetchFiles(value)
} else if (value.length === 0) {
fetchFiles()
}
}, 500) // 500ms debounce
}
// Clean up the timeout on unmount
useEffect(() => {
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current)
}
}
}, [])
// Determine the appropriate service ID based on provider and scopes
const getServiceId = (): string => {
if (serviceId) return serviceId
return getServiceIdFromScopes(provider, requiredScopes)
}
// Determine the appropriate provider ID based on service and scopes
const getProviderId = (): string => {
const effectiveServiceId = getServiceId()
return getProviderIdFromServiceId(effectiveServiceId)
}
// Fetch available credentials for this provider
const fetchCredentials = useCallback(async () => {
setIsLoading(true)
try {
const providerId = getProviderId()
const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`)
if (response.ok) {
const data = await response.json()
setCredentials(data.credentials)
}
} catch (error) {
logger.error('Error fetching credentials:', error)
} finally {
setIsLoading(false)
}
}, [provider, getProviderId, selectedCredentialId])
// Fetch page info when we have a selected file ID
const fetchPageInfo = useCallback(
async (pageId: string) => {
if (!selectedCredentialId || !domain) return
// Validate domain format
const trimmedDomain = domain.trim().toLowerCase()
if (!trimmedDomain.includes('.')) {
setError(
'Invalid domain format. Please provide the full domain (e.g., your-site.atlassian.net)'
)
return
}
setIsLoading(true)
setError(null)
try {
// Get the access token from the selected credential
const tokenResponse = await fetch('/api/auth/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
credentialId: selectedCredentialId,
workflowId,
}),
})
if (!tokenResponse.ok) {
const errorData = await tokenResponse.json()
throw new Error(errorData.error || 'Failed to get access token')
}
const tokenData = await tokenResponse.json()
const accessToken = tokenData.accessToken
// Use the access token to fetch the page info
const response = await fetch('/api/tools/confluence/page', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
domain,
accessToken,
pageId,
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to fetch page info')
}
const data = await response.json()
const fileInfo: ConfluenceFileInfo = {
id: data.id || pageId,
name: data.title || `Page ${pageId}`,
mimeType: 'confluence/page',
webViewLink: `https://${domain}/wiki/pages/${data.id}`,
modifiedTime: data.version?.when,
spaceId: data.spaceId,
url: `https://${domain}/wiki/pages/${data.id}`,
}
setSelectedFile(fileInfo)
onFileInfoChange?.(fileInfo)
// Cache the page name in display names store
if (selectedCredentialId) {
useDisplayNamesStore
.getState()
.setDisplayNames('files', selectedCredentialId, { [fileInfo.id]: fileInfo.name })
}
} catch (error) {
logger.error('Error fetching page info:', error)
setError((error as Error).message)
} finally {
setIsLoading(false)
}
},
[selectedCredentialId, domain, onFileInfoChange, workflowId]
)
// Fetch pages from Confluence
const fetchFiles = useCallback(
async (searchQuery?: string) => {
if (!selectedCredentialId || !domain) return
if (isForeignCredential) return
// Validate domain format
const trimmedDomain = domain.trim().toLowerCase()
if (!trimmedDomain.includes('.')) {
setError(
'Invalid domain format. Please provide the full domain (e.g., your-site.atlassian.net)'
)
setFiles([])
setIsLoading(false)
return
}
setIsLoading(true)
setError(null)
try {
// Get the access token from the selected credential
const tokenResponse = await fetch('/api/auth/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
credentialId: selectedCredentialId,
workflowId,
}),
})
if (!tokenResponse.ok) {
const errorData = await tokenResponse.json()
logger.error('Access token error:', errorData)
// If there's a token error, we might need to reconnect the account
setError('Authentication failed. Please reconnect your Confluence account.')
setIsLoading(false)
return
}
const tokenData = await tokenResponse.json()
const accessToken = tokenData.accessToken
if (!accessToken) {
logger.error('No access token returned')
setError('Authentication failed. Please reconnect your Confluence account.')
setIsLoading(false)
return
}
// Simply fetch pages directly using the endpoint
const response = await fetch('/api/tools/confluence/pages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
domain,
accessToken,
title: searchQuery || undefined,
limit: 50,
}),
})
if (!response.ok) {
const errorData = await response.json()
if (response.status === 401 || response.status === 403) {
logger.info('Confluence pages fetch unauthorized (expected for collaborator)')
setFiles([])
setIsLoading(false)
return
}
logger.error('Confluence API error:', errorData)
throw new Error(errorData.error || 'Failed to fetch pages')
}
const data = await response.json()
logger.info(`Received ${data.files?.length || 0} files from API`)
setFiles(data.files || [])
// Cache file names in display names store
if (selectedCredentialId && data.files) {
const fileMap = data.files.reduce(
(acc: Record<string, string>, file: ConfluenceFileInfo) => {
acc[file.id] = file.name
return acc
},
{}
)
useDisplayNamesStore.getState().setDisplayNames('files', selectedCredentialId, fileMap)
}
// If we have a selected file ID, update state and notify parent
if (selectedFileId) {
const fileInfo = data.files.find((file: ConfluenceFileInfo) => file.id === selectedFileId)
if (fileInfo) {
setSelectedFile(fileInfo)
onFileInfoChange?.(fileInfo)
} else if (!searchQuery && selectedFileId) {
// If we can't find the file in the list, try to fetch it directly
fetchPageInfo(selectedFileId)
}
}
} catch (error) {
logger.error('Error fetching pages:', error)
setError((error as Error).message)
setFiles([])
} finally {
setIsLoading(false)
}
},
[
selectedCredentialId,
domain,
selectedFileId,
onFileInfoChange,
fetchPageInfo,
workflowId,
isForeignCredential,
]
)
// Fetch credentials on initial mount
useEffect(() => {
if (!initialFetchRef.current) {
fetchCredentials()
initialFetchRef.current = true
}
}, [fetchCredentials])
// Only fetch files when the dropdown is opened, not on credential selection
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen)
// Only fetch files when opening the dropdown and if we have valid credentials and domain
if (isOpen && !isForeignCredential && selectedCredentialId && domain && domain.includes('.')) {
fetchFiles()
}
}
// Keep internal selectedFileId in sync with the value prop
useEffect(() => {
if (value !== selectedFileId) {
setSelectedFileId(value)
}
}, [value])
// Clear callback when value is cleared
useEffect(() => {
if (!value) {
setSelectedFile(null)
onFileInfoChange?.(null)
}
}, [value, onFileInfoChange])
// Fetch page info on mount if we have a value but no selectedFile state
useEffect(() => {
if (value && selectedCredentialId && domain && !selectedFile) {
fetchPageInfo(value)
}
}, [value, selectedCredentialId, domain, selectedFile, fetchPageInfo])
// Handle file selection
const handleSelectFile = (file: ConfluenceFileInfo) => {
setSelectedFileId(file.id)
setSelectedFile(file)
onChange(file.id, file)
onFileInfoChange?.(file)
setOpen(false)
}
// Handle adding a new credential
const handleAddCredential = () => {
// Show the OAuth modal
setShowOAuthModal(true)
setOpen(false)
}
// Clear selection
const handleClearSelection = () => {
setSelectedFileId('')
onChange('', undefined)
onFileInfoChange?.(null)
}
return (
<>
<div className='space-y-2'>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className='h-10 w-full min-w-0 justify-between'
disabled={disabled || !domain || isForeignCredential}
>
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
{cachedFileName ? (
<>
<ConfluenceIcon className='h-4 w-4' />
<span className='truncate font-normal'>{cachedFileName}</span>
</>
) : (
<>
<ConfluenceIcon className='h-4 w-4' />
<span className='truncate text-muted-foreground'>{label}</span>
</>
)}
</div>
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
{!isForeignCredential && (
<PopoverContent className='w-[300px] p-0' align='start'>
{/* Current account indicator */}
{selectedCredentialId && credentials.length > 0 && (
<div className='flex items-center justify-between border-b px-3 py-2'>
<div className='flex items-center gap-2'>
<ConfluenceIcon className='h-4 w-4' />
<span className='text-muted-foreground text-xs'>
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
'Unknown'}
</span>
</div>
{credentials.length > 1 && (
<Button
variant='ghost'
size='sm'
className='h-6 px-2 text-xs'
onClick={() => setOpen(true)}
>
Switch
</Button>
)}
</div>
)}
<Command>
<CommandInput placeholder='Search pages...' onValueChange={handleSearch} />
<CommandList>
<CommandEmpty>
{isLoading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading pages...</span>
</div>
) : error ? (
<div className='p-4 text-center'>
<p className='text-destructive text-sm'>{error}</p>
</div>
) : credentials.length === 0 ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No accounts connected.</p>
<p className='text-muted-foreground text-xs'>
Connect a Confluence account to continue.
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No pages found.</p>
<p className='text-muted-foreground text-xs'>
Try a different search or account.
</p>
</div>
)}
</CommandEmpty>
{/* Account selection - only show if we have multiple accounts */}
{credentials.length > 1 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Switch Account
</div>
{credentials.map((cred) => (
<CommandItem
key={cred.id}
value={`account-${cred.id}`}
onSelect={() => setSelectedCredentialId(cred.id)}
>
<div className='flex items-center gap-2'>
<ConfluenceIcon className='h-4 w-4' />
<span className='font-normal'>{cred.name}</span>
</div>
{cred.id === selectedCredentialId && (
<Check className='ml-auto h-4 w-4' />
)}
</CommandItem>
))}
</CommandGroup>
)}
{/* Files list */}
{files.length > 0 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Pages
</div>
{files.map((file) => (
<CommandItem
key={file.id}
value={`file-${file.id}-${file.name}`}
onSelect={() => handleSelectFile(file)}
>
<div className='flex items-center gap-2 overflow-hidden'>
<ConfluenceIcon className='h-4 w-4' />
<span className='truncate font-normal'>{file.name}</span>
</div>
{file.id === selectedFileId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
{/* Connect account option - only show if no credentials */}
{credentials.length === 0 && (
<CommandGroup>
<CommandItem onSelect={handleAddCredential}>
<div className='flex items-center gap-2 text-foreground'>
<ConfluenceIcon className='h-4 w-4' />
<span>Connect Confluence account</span>
</div>
</CommandItem>
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
)}
</Popover>
{showPreview && selectedFile && selectedFileId && selectedFile.id === selectedFileId && (
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
<div className='absolute top-2 right-2'>
<Button
variant='ghost'
size='icon'
className='h-5 w-5 hover:bg-muted'
onClick={handleClearSelection}
>
<X className='h-3 w-3' />
</Button>
</div>
<div className='flex items-center gap-3 pr-4'>
<div className='flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-muted/20'>
<ConfluenceIcon className='h-4 w-4' />
</div>
<div className='min-w-0 flex-1 overflow-hidden'>
<div className='flex items-center gap-2'>
<h4 className='truncate font-medium text-xs'>{selectedFile.name}</h4>
{selectedFile.modifiedTime && (
<span className='whitespace-nowrap text-muted-foreground text-xs'>
{new Date(selectedFile.modifiedTime).toLocaleDateString()}
</span>
)}
</div>
{selectedFile.webViewLink && (
<a
href={selectedFile.webViewLink}
target='_blank'
rel='noopener noreferrer'
className='flex items-center gap-1 text-foreground text-xs hover:underline'
onClick={(e) => e.stopPropagation()}
>
<span>Open in Confluence</span>
<ExternalLink className='h-3 w-3' />
</a>
)}
</div>
</div>
</div>
)}
</div>
{showOAuthModal && (
<OAuthRequiredModal
isOpen={showOAuthModal}
onClose={() => setShowOAuthModal(false)}
provider={provider}
toolName='Confluence'
requiredScopes={requiredScopes}
serviceId={getServiceId()}
/>
)}
</>
)
}

View File

@@ -1,288 +0,0 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { Check, ChevronDown, RefreshCw, X } from 'lucide-react'
import { GoogleCalendarIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { createLogger } from '@/lib/logs/console/logger'
import { useDisplayNamesStore } from '@/stores/display-names/store'
const logger = createLogger('GoogleCalendarSelector')
export interface GoogleCalendarInfo {
id: string
summary: string
description?: string
primary?: boolean
accessRole: string
backgroundColor?: string
foregroundColor?: string
}
interface GoogleCalendarSelectorProps {
value: string
onChange: (value: string, calendarInfo?: GoogleCalendarInfo) => void
label?: string
disabled?: boolean
showPreview?: boolean
onCalendarInfoChange?: (info: GoogleCalendarInfo | null) => void
credentialId: string
workflowId?: string
}
export function GoogleCalendarSelector({
value,
onChange,
label = 'Select Google Calendar',
disabled = false,
showPreview = true,
onCalendarInfoChange,
credentialId,
workflowId,
}: GoogleCalendarSelectorProps) {
const [open, setOpen] = useState(false)
const [calendars, setCalendars] = useState<GoogleCalendarInfo[]>([])
const [selectedCalendarId, setSelectedCalendarId] = useState(value)
const [selectedCalendar, setSelectedCalendar] = useState<GoogleCalendarInfo | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [initialFetchDone, setInitialFetchDone] = useState(false)
// Get cached display name
const cachedCalendarName = useDisplayNamesStore(
useCallback(
(state) => {
if (!credentialId || !value) return null
return state.cache.files[credentialId]?.[value] || null
},
[credentialId, value]
)
)
const fetchCalendarsFromAPI = useCallback(async (): Promise<GoogleCalendarInfo[]> => {
if (!credentialId) {
throw new Error('Google Calendar account is required')
}
const queryParams = new URLSearchParams({
credentialId: credentialId,
})
if (workflowId) {
queryParams.set('workflowId', workflowId)
}
const response = await fetch(`/api/tools/google_calendar/calendars?${queryParams.toString()}`)
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to fetch Google Calendar calendars')
}
const data = await response.json()
return data.calendars || []
}, [credentialId])
const fetchCalendars = useCallback(async () => {
setIsLoading(true)
setError(null)
try {
const calendars = await fetchCalendarsFromAPI()
setCalendars(calendars)
// Cache calendar names
if (credentialId && calendars.length > 0) {
const calendarMap = calendars.reduce<Record<string, string>>((acc, cal) => {
acc[cal.id] = cal.summary
return acc
}, {})
useDisplayNamesStore.getState().setDisplayNames('files', credentialId, calendarMap)
}
// Update selected calendar if we have a value
if (selectedCalendarId && calendars.length > 0) {
const calendar = calendars.find((c) => c.id === selectedCalendarId)
setSelectedCalendar(calendar || null)
}
} catch (error) {
logger.error('Error fetching calendars:', error)
setError((error as Error).message)
setCalendars([])
} finally {
setIsLoading(false)
setInitialFetchDone(true)
}
}, [fetchCalendarsFromAPI, credentialId])
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen)
if (isOpen && credentialId && (!initialFetchDone || calendars.length === 0)) {
fetchCalendars()
}
}
// Sync selected ID with external value
useEffect(() => {
if (value !== selectedCalendarId) {
setSelectedCalendarId(value)
}
}, [value, selectedCalendarId])
// Handle calendar selection
const handleSelectCalendar = (calendar: GoogleCalendarInfo) => {
setSelectedCalendarId(calendar.id)
setSelectedCalendar(calendar)
onChange(calendar.id, calendar)
onCalendarInfoChange?.(calendar)
setOpen(false)
}
// Clear selection
const handleClearSelection = () => {
setSelectedCalendarId('')
onChange('', undefined)
onCalendarInfoChange?.(null)
setError(null)
}
// Get calendar display name
const getCalendarDisplayName = (calendar: GoogleCalendarInfo) => {
if (calendar.primary) {
return `${calendar.summary} (Primary)`
}
return calendar.summary
}
return (
<div className='space-y-2'>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className='h-10 w-full min-w-0 justify-between'
disabled={disabled || !credentialId}
>
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
{cachedCalendarName ? (
<>
<GoogleCalendarIcon className='h-4 w-4' />
<span className='truncate font-normal'>{cachedCalendarName}</span>
</>
) : (
<>
<GoogleCalendarIcon className='h-4 w-4' />
<span className='truncate text-muted-foreground'>{label}</span>
</>
)}
</div>
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[300px] p-0' align='start'>
<Command>
<CommandInput placeholder='Search calendars...' />
<CommandList>
<CommandEmpty>
{isLoading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading calendars...</span>
</div>
) : error ? (
<div className='p-4 text-center'>
<p className='text-destructive text-sm'>{error}</p>
</div>
) : calendars.length === 0 ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No calendars found</p>
<p className='text-muted-foreground text-xs'>
Please check your Google Calendar account access
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No matching calendars</p>
</div>
)}
</CommandEmpty>
{calendars.length > 0 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Calendars
</div>
{calendars.map((calendar) => (
<CommandItem
key={calendar.id}
value={`calendar-${calendar.id}-${calendar.summary}`}
onSelect={() => handleSelectCalendar(calendar)}
className='cursor-pointer'
>
<div className='flex items-center gap-2 overflow-hidden'>
<div
className='h-3 w-3 flex-shrink-0 rounded-full'
style={{
backgroundColor: calendar.backgroundColor || '#4285f4',
}}
/>
<span className='truncate font-normal'>
{getCalendarDisplayName(calendar)}
</span>
</div>
{calendar.id === selectedCalendarId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{showPreview && selectedCalendar && (
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
<div className='absolute top-2 right-2'>
<Button
variant='ghost'
size='icon'
className='h-5 w-5 hover:bg-muted'
onClick={handleClearSelection}
>
<X className='h-3 w-3' />
</Button>
</div>
<div className='flex items-center gap-3 pr-4'>
<div className='flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-muted/20'>
<div
className='h-3 w-3 rounded-full'
style={{
backgroundColor: selectedCalendar.backgroundColor || '#4285f4',
}}
/>
</div>
<div className='min-w-0 flex-1 overflow-hidden'>
<h4 className='truncate font-medium text-xs'>
{getCalendarDisplayName(selectedCalendar)}
</h4>
<div className='text-muted-foreground text-xs'>
Access: {selectedCalendar.accessRole}
</div>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,572 +0,0 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { ExternalLink, FileIcon, FolderIcon, RefreshCw, X } from 'lucide-react'
import useDrivePicker from 'react-google-drive-picker'
import { GoogleDocsIcon, GoogleSheetsIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import { getEnv } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import {
type Credential,
getProviderIdFromServiceId,
getServiceByProviderAndId,
getServiceIdFromScopes,
OAUTH_PROVIDERS,
type OAuthProvider,
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 { useDisplayNamesStore } from '@/stores/display-names/store'
const logger = createLogger('GoogleDrivePicker')
export interface FileInfo {
id: string
name: string
mimeType: string
iconLink?: string
webViewLink?: string
thumbnailLink?: string
createdTime?: string
modifiedTime?: string
size?: string
owners?: { displayName: string; emailAddress: string }[]
}
interface GoogleDrivePickerProps {
value: string
onChange: (value: string, fileInfo?: FileInfo) => void
provider: OAuthProvider
requiredScopes?: string[]
label?: string
disabled?: boolean
serviceId?: string
mimeTypeFilter?: string
showPreview?: boolean
onFileInfoChange?: (fileInfo: FileInfo | null) => void
clientId: string
apiKey: string
credentialId?: string
workflowId?: string
}
export function GoogleDrivePicker({
value,
onChange,
provider,
requiredScopes = [],
label = 'Select file',
disabled = false,
serviceId,
mimeTypeFilter,
showPreview = true,
onFileInfoChange,
clientId,
apiKey,
credentialId,
workflowId,
}: GoogleDrivePickerProps) {
const [credentials, setCredentials] = useState<Credential[]>([])
const [selectedCredentialId, setSelectedCredentialId] = useState<string>('')
const [selectedFileId, setSelectedFileId] = useState(value)
const [selectedFile, setSelectedFile] = useState<FileInfo | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [isLoadingSelectedFile, setIsLoadingSelectedFile] = useState(false)
const [showOAuthModal, setShowOAuthModal] = useState(false)
const [credentialsLoaded, setCredentialsLoaded] = useState(false)
const initialFetchRef = useRef(false)
const [openPicker, _authResponse] = useDrivePicker()
// Determine the appropriate service ID based on provider and scopes
const getServiceId = (): string => {
if (serviceId) return serviceId
return getServiceIdFromScopes(provider, requiredScopes)
}
// Determine the appropriate provider ID based on service and scopes
const getProviderId = (): string => {
const effectiveServiceId = getServiceId()
return getProviderIdFromServiceId(effectiveServiceId)
}
// Fetch available credentials for this provider
const fetchCredentials = useCallback(async () => {
setIsLoading(true)
setCredentialsLoaded(false)
try {
const providerId = getProviderId()
const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`)
if (response.ok) {
const data = await response.json()
setCredentials(data.credentials)
const credentialMap = (data.credentials || []).reduce(
(acc: Record<string, string>, cred: Credential) => {
acc[cred.id] = cred.name
return acc
},
{}
)
useDisplayNamesStore.getState().setDisplayNames('credentials', providerId, credentialMap)
if (credentialId && !data.credentials.some((c: any) => c.id === credentialId)) {
setSelectedCredentialId('')
}
}
} catch (error) {
logger.error('Error fetching credentials:', { error })
} finally {
setIsLoading(false)
setCredentialsLoaded(true)
}
}, [provider, getProviderId, selectedCredentialId])
// Prefer persisted credentialId if provided
useEffect(() => {
if (credentialId && credentialId !== selectedCredentialId) {
setSelectedCredentialId(credentialId)
}
}, [credentialId, selectedCredentialId])
// Fetch a single file by ID when we have a selectedFileId but no metadata
const fetchFileById = useCallback(
async (fileId: string) => {
if (!selectedCredentialId || !fileId) return null
setIsLoadingSelectedFile(true)
try {
// Construct query parameters
const queryParams = new URLSearchParams({
credentialId: selectedCredentialId,
fileId: fileId,
})
if (workflowId) queryParams.set('workflowId', workflowId)
const response = await fetch(`/api/tools/drive/file?${queryParams.toString()}`)
if (response.ok) {
const data = await response.json()
if (data.file) {
setSelectedFile(data.file)
onFileInfoChange?.(data.file)
// Cache the file name
if (selectedCredentialId && data.file.id && data.file.name) {
useDisplayNamesStore.getState().setDisplayNames('files', selectedCredentialId, {
[data.file.id]: data.file.name,
})
}
return data.file
}
} else {
const errorText = await response.text()
logger.error('Error fetching file by ID:', { error: errorText })
// If file not found or access denied, clear the selection
if (response.status === 404 || response.status === 403) {
logger.info('File not accessible, clearing selection')
setSelectedFileId('')
onChange('')
onFileInfoChange?.(null)
}
if (response.status === 401) {
logger.info('Credential unauthorized (401), clearing selection and prompting re-auth')
setSelectedFileId('')
onChange('')
onFileInfoChange?.(null)
setShowOAuthModal(true)
}
}
return null
} catch (error) {
logger.error('Error fetching file by ID:', { error })
return null
} finally {
setIsLoadingSelectedFile(false)
}
},
[selectedCredentialId, onChange, onFileInfoChange]
)
// Fetch credentials on initial mount
useEffect(() => {
if (!initialFetchRef.current) {
fetchCredentials()
initialFetchRef.current = true
}
}, [fetchCredentials])
// Keep internal selectedFileId in sync with the value prop
useEffect(() => {
if (value !== selectedFileId) {
const previousFileId = selectedFileId
setSelectedFileId(value)
// Only clear selected file info if we had a different file before (not initial load)
if (previousFileId && previousFileId !== value && selectedFile) {
setSelectedFile(null)
}
}
}, [value, selectedFileId, selectedFile])
// Track previous credential ID to detect changes
const prevCredentialIdRef = useRef<string>('')
// Clear selected file when credentials are removed or changed
useEffect(() => {
const prevCredentialId = prevCredentialIdRef.current
prevCredentialIdRef.current = selectedCredentialId
if (!selectedCredentialId) {
// No credentials - clear everything
if (selectedFile) {
setSelectedFile(null)
setSelectedFileId('')
onChange('')
}
} else if (prevCredentialId && prevCredentialId !== selectedCredentialId) {
// Credentials changed (not initial load) - clear file info to force refetch
if (selectedFile) {
setSelectedFile(null)
}
}
}, [selectedCredentialId, selectedFile, onChange])
// Fetch the selected file metadata once credentials are loaded or changed
useEffect(() => {
// Only fetch if we have both a file ID and credentials, credentials are loaded, but no file info yet
if (
value &&
selectedCredentialId &&
credentialsLoaded &&
!selectedFile &&
!isLoadingSelectedFile
) {
fetchFileById(value)
}
}, [
value,
selectedCredentialId,
credentialsLoaded,
selectedFile,
isLoadingSelectedFile,
fetchFileById,
])
// Fetch the access token for the selected credential
const fetchAccessToken = async (credentialOverrideId?: string): Promise<string | null> => {
const effectiveCredentialId = credentialOverrideId || selectedCredentialId
if (!effectiveCredentialId) {
logger.error('No credential ID selected for Google Drive Picker')
return null
}
setIsLoading(true)
try {
const response = await fetch('/api/auth/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credentialId: effectiveCredentialId, workflowId }),
})
if (!response.ok) {
throw new Error(`Failed to fetch access token: ${response.status}`)
}
const data = await response.json()
return data.accessToken || null
} catch (error) {
logger.error('Error fetching access token:', { error })
return null
} finally {
setIsLoading(false)
}
}
// Handle opening the Google Drive Picker
const handleOpenPicker = async (credentialOverrideId?: string) => {
try {
// First, get the access token for the selected credential
const accessToken = await fetchAccessToken(credentialOverrideId)
if (!accessToken) {
logger.error('Failed to get access token for Google Drive Picker')
return
}
const viewIdForMimeType = () => {
// Return appropriate view based on mime type filter
if (mimeTypeFilter?.includes('folder')) {
return 'FOLDERS'
}
if (mimeTypeFilter?.includes('spreadsheet')) {
return 'SPREADSHEETS'
}
if (mimeTypeFilter?.includes('document')) {
return 'DOCUMENTS'
}
return 'DOCS' // Default view
}
openPicker({
clientId,
developerKey: apiKey,
viewId: viewIdForMimeType(),
token: accessToken, // Use the fetched access token
showUploadView: true,
showUploadFolders: true,
supportDrives: true,
multiselect: false,
appId: getEnv('NEXT_PUBLIC_GOOGLE_PROJECT_NUMBER'),
// Enable folder selection when mimeType is folder
setSelectFolderEnabled: !!mimeTypeFilter?.includes('folder'),
callbackFunction: (data) => {
if (data.action === 'picked') {
const file = data.docs[0]
if (file) {
const fileInfo: FileInfo = {
id: file.id,
name: file.name,
mimeType: file.mimeType,
iconLink: file.iconUrl,
webViewLink: file.url,
// thumbnailLink is not directly available from the picker
thumbnailLink: file.iconUrl, // Use iconUrl as fallback
modifiedTime: file.lastEditedUtc
? new Date(file.lastEditedUtc).toISOString()
: undefined,
}
setSelectedFileId(file.id)
setSelectedFile(fileInfo)
onChange(file.id, fileInfo)
onFileInfoChange?.(fileInfo)
// Cache the selected file name
if (selectedCredentialId) {
useDisplayNamesStore
.getState()
.setDisplayNames('files', selectedCredentialId, { [file.id]: file.name })
}
}
}
},
})
} catch (error) {
logger.error('Error opening Google Drive Picker:', { error })
}
}
// Handle adding a new credential
const handleAddCredential = () => {
// Show the OAuth modal
setShowOAuthModal(true)
}
// Clear selection
const handleClearSelection = () => {
setSelectedFileId('')
setSelectedFile(null)
onChange('', undefined)
onFileInfoChange?.(null)
}
// Get provider icon
const getProviderIcon = (providerName: OAuthProvider) => {
const { baseProvider } = parseProvider(providerName)
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
if (!baseProviderConfig) {
return <ExternalLink className='h-4 w-4' />
}
// For compound providers, find the specific service
if (providerName.includes('-')) {
for (const service of Object.values(baseProviderConfig.services)) {
if (service.providerId === providerName) {
return service.icon({ className: 'h-4 w-4' })
}
}
}
// Fallback to base provider icon
return baseProviderConfig.icon({ className: 'h-4 w-4' })
}
// Get provider name
const getProviderName = (providerName: OAuthProvider) => {
const effectiveServiceId = getServiceId()
try {
// First try to get the service by provider and service ID
const service = getServiceByProviderAndId(providerName, effectiveServiceId)
return service.name
} catch (_error) {
// If that fails, try to get the service by parsing the provider
try {
const { baseProvider } = parseProvider(providerName)
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
// For compound providers like 'google-drive', try to find the specific service
if (providerName.includes('-')) {
const serviceKey = providerName.split('-')[1] || ''
for (const [key, service] of Object.entries(baseProviderConfig?.services || {})) {
if (key === serviceKey || key === providerName || service.providerId === providerName) {
return service.name
}
}
}
// Fallback to provider name if service not found
if (baseProviderConfig) {
return baseProviderConfig.name
}
} catch (_parseError) {
// Ignore parse error and continue to final fallback
}
// Final fallback: capitalize the provider name
return providerName
.split('-')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
}
}
// Get file icon based on mime type
const getFileIcon = (file: FileInfo, size: 'sm' | 'md' = 'sm') => {
const iconSize = size === 'sm' ? 'h-4 w-4' : 'h-5 w-5'
if (file.mimeType === 'application/vnd.google-apps.folder') {
return <FolderIcon className={`${iconSize} text-muted-foreground`} />
}
if (file.mimeType === 'application/vnd.google-apps.spreadsheet') {
return <GoogleSheetsIcon className={iconSize} />
}
if (file.mimeType === 'application/vnd.google-apps.document') {
return <GoogleDocsIcon className={iconSize} />
}
return <FileIcon className={`${iconSize} text-muted-foreground`} />
}
const canShowPreview = !!(
showPreview &&
selectedFile &&
selectedFileId &&
selectedFile.id === selectedFileId
)
return (
<>
<div className='space-y-2'>
<Button
variant='outline'
role='combobox'
className='h-10 w-full min-w-0 justify-between'
disabled={disabled || isLoading}
onClick={async () => {
// Decide which credential to use
let idToUse = selectedCredentialId
if (!idToUse && credentials.length === 1) {
idToUse = credentials[0].id
setSelectedCredentialId(idToUse)
}
if (!idToUse) {
// No credentials — prompt OAuth
handleAddCredential()
return
}
await handleOpenPicker(idToUse)
}}
>
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
{canShowPreview ? (
<>
{getFileIcon(selectedFile, 'sm')}
<span className='truncate font-normal'>{selectedFile.name}</span>
</>
) : selectedFileId && isLoadingSelectedFile && selectedCredentialId ? (
<>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='truncate text-muted-foreground'>Loading document...</span>
</>
) : (
<>
{getProviderIcon(provider)}
<span className='truncate text-muted-foreground'>{label}</span>
</>
)}
</div>
</Button>
{/* File preview */}
{canShowPreview && (
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
<div className='absolute top-2 right-2'>
<Button
variant='ghost'
size='icon'
className='h-5 w-5 hover:bg-muted'
onClick={handleClearSelection}
>
<X className='h-3 w-3' />
</Button>
</div>
<div className='flex items-center gap-3 pr-4'>
<div className='flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-muted/20'>
{getFileIcon(selectedFile, 'sm')}
</div>
<div className='min-w-0 flex-1 overflow-hidden'>
<div className='flex items-center gap-2'>
<h4 className='truncate font-medium text-xs'>{selectedFile.name}</h4>
{selectedFile.modifiedTime && (
<span className='whitespace-nowrap text-muted-foreground text-xs'>
{new Date(selectedFile.modifiedTime).toLocaleDateString()}
</span>
)}
</div>
{selectedFile.webViewLink ? (
<a
href={selectedFile.webViewLink}
target='_blank'
rel='noopener noreferrer'
className='flex items-center gap-1 text-muted-foreground text-xs hover:underline'
onClick={(e) => e.stopPropagation()}
>
<span>Open in Drive</span>
<ExternalLink className='h-3 w-3' />
</a>
) : (
<a
href={`https://drive.google.com/file/d/${selectedFile.id}/view`}
target='_blank'
rel='noopener noreferrer'
className='flex items-center gap-1 text-muted-foreground text-xs hover:underline'
onClick={(e) => e.stopPropagation()}
>
<span>Open in Drive</span>
<ExternalLink className='h-3 w-3' />
</a>
)}
</div>
</div>
</div>
)}
</div>
{showOAuthModal && (
<OAuthRequiredModal
isOpen={showOAuthModal}
onClose={() => setShowOAuthModal(false)}
provider={provider}
toolName={getProviderName(provider)}
requiredScopes={requiredScopes}
serviceId={getServiceId()}
/>
)}
</>
)
}

View File

@@ -1,14 +0,0 @@
export type { ConfluenceFileInfo } from './confluence-file-selector'
export { ConfluenceFileSelector } from './confluence-file-selector'
export type { GoogleCalendarInfo } from './google-calendar-selector'
export { GoogleCalendarSelector } from './google-calendar-selector'
export type { FileInfo } from './google-drive-picker'
export { GoogleDrivePicker } from './google-drive-picker'
export type { JiraIssueInfo } from './jira-issue-selector'
export { JiraIssueSelector } from './jira-issue-selector'
export type { MicrosoftFileInfo } from './microsoft-file-selector'
export { MicrosoftFileSelector } from './microsoft-file-selector'
export type { TeamsMessageInfo } from './teams-message-selector'
export { TeamsMessageSelector } from './teams-message-selector'
export type { WealthboxItemInfo } from './wealthbox-file-selector'
export { WealthboxFileSelector } from './wealthbox-file-selector'

View File

@@ -1,670 +0,0 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Check, ChevronDown, ExternalLink, RefreshCw, X } from 'lucide-react'
import { JiraIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { createLogger } from '@/lib/logs/console/logger'
import {
type Credential,
getProviderIdFromServiceId,
getServiceIdFromScopes,
type OAuthProvider,
} 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 { useDisplayNamesStore } from '@/stores/display-names/store'
const logger = createLogger('JiraIssueSelector')
export interface JiraIssueInfo {
id: string
name: string
mimeType: string
webViewLink?: string
modifiedTime?: string
spaceId?: string
url?: string
}
interface JiraIssueSelectorProps {
value: string
onChange: (value: string, issueInfo?: JiraIssueInfo) => void
provider: OAuthProvider
requiredScopes?: string[]
label?: string
disabled?: boolean
serviceId?: string
domain: string
showPreview?: boolean
onIssueInfoChange?: (issueInfo: JiraIssueInfo | null) => void
projectId?: string
credentialId?: string
isForeignCredential?: boolean
workflowId?: string
}
export function JiraIssueSelector({
value,
onChange,
provider,
requiredScopes = [],
label = 'Select Jira issue',
disabled = false,
serviceId,
domain,
showPreview = true,
onIssueInfoChange,
projectId,
credentialId,
isForeignCredential = false,
workflowId,
}: JiraIssueSelectorProps) {
const [open, setOpen] = useState(false)
const [credentials, setCredentials] = useState<Credential[]>([])
const [issues, setIssues] = useState<JiraIssueInfo[]>([])
const [selectedCredentialId, setSelectedCredentialId] = useState<string>(credentialId || '')
const [selectedIssueId, setSelectedIssueId] = useState(value)
const [selectedIssue, setSelectedIssue] = useState<JiraIssueInfo | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [showOAuthModal, setShowOAuthModal] = useState(false)
const [error, setError] = useState<string | null>(null)
const [cloudId, setCloudId] = useState<string | null>(null)
// Get cached display name
const cachedIssueName = useDisplayNamesStore(
useCallback(
(state) => {
const effectiveCredentialId = credentialId || selectedCredentialId
if (!effectiveCredentialId || !value) return null
return state.cache.files[effectiveCredentialId]?.[value] || null
},
[credentialId, selectedCredentialId, value]
)
)
// Keep local credential state in sync with persisted credentialId prop
useEffect(() => {
if (credentialId && credentialId !== selectedCredentialId) {
setSelectedCredentialId(credentialId)
} else if (!credentialId && selectedCredentialId) {
setSelectedCredentialId('')
}
}, [credentialId, selectedCredentialId])
// Handle search with debounce
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const handleSearch = (value: string) => {
// Clear any existing timeout
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current)
}
// Set a new timeout
searchTimeoutRef.current = setTimeout(() => {
if (value.length >= 1) {
// Changed from > 2 to >= 1 to be more responsive
fetchIssues(value)
} else {
setIssues([]) // Clear issues if search is empty
}
}, 500) // 500ms debounce
}
// Clean up the timeout on unmount
useEffect(() => {
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current)
}
}
}, [])
// Determine the appropriate service ID based on provider and scopes
const getServiceId = (): string => {
if (serviceId) return serviceId
return getServiceIdFromScopes(provider, requiredScopes)
}
// Determine the appropriate provider ID based on service and scopes (stabilized)
const providerId = useMemo(() => {
const effectiveServiceId = getServiceId()
return getProviderIdFromServiceId(effectiveServiceId)
}, [serviceId, provider, requiredScopes])
// Fetch available credentials for this provider
const fetchCredentials = useCallback(async () => {
if (!providerId) return
setIsLoading(true)
try {
const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`)
if (response.ok) {
const data = await response.json()
setCredentials(data.credentials)
}
} catch (error) {
logger.error('Error fetching credentials:', error)
} finally {
setIsLoading(false)
}
}, [providerId])
// Fetch issue info when we have a selected issue ID
const fetchIssueInfo = useCallback(
async (issueId: string) => {
// Validate domain format
const trimmedDomain = domain.trim().toLowerCase()
if (!trimmedDomain.includes('.')) {
setError(
'Invalid domain format. Please provide the full domain (e.g., your-site.atlassian.net)'
)
return
}
setIsLoading(true)
setError(null)
try {
// Get the access token from the selected credential
const tokenResponse = await fetch('/api/auth/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
credentialId: selectedCredentialId,
workflowId,
}),
})
if (!tokenResponse.ok) {
const errorData = await tokenResponse.json()
throw new Error(errorData.error || 'Failed to get access token')
}
const tokenData = await tokenResponse.json()
const accessToken = tokenData.accessToken
if (!accessToken) {
throw new Error('No access token received')
}
// Use the access token to fetch the issue info
const response = await fetch('/api/tools/jira/issue', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
domain,
accessToken,
issueId,
cloudId,
}),
})
if (!response.ok) {
const errorData = await response.json()
logger.error('Failed to fetch issue info:', errorData)
throw new Error(errorData.error || 'Failed to fetch issue info')
}
const data = await response.json()
if (data.cloudId) {
logger.info('Using cloud ID:', data.cloudId)
setCloudId(data.cloudId)
}
if (data.issue) {
logger.info('Successfully fetched issue:', data.issue.name)
setSelectedIssue(data.issue)
onIssueInfoChange?.(data.issue)
} else {
logger.warn('No issue data received in response')
setSelectedIssue(null)
onIssueInfoChange?.(null)
}
} catch (error) {
logger.error('Error fetching issue info:', error)
setError((error as Error).message)
onIssueInfoChange?.(null)
} finally {
setIsLoading(false)
}
},
[selectedCredentialId, domain, onIssueInfoChange, cloudId]
)
// Fetch issues from Jira
const fetchIssues = useCallback(
async (searchQuery?: string) => {
if (!selectedCredentialId || !domain) return
// If no search query is provided, require a projectId before fetching
if (!searchQuery && !projectId) {
setIssues([])
return
}
// Validate domain format
const trimmedDomain = domain.trim().toLowerCase()
if (!trimmedDomain.includes('.')) {
setError(
'Invalid domain format. Please provide the full domain (e.g., your-site.atlassian.net)'
)
setIssues([])
setIsLoading(false)
return
}
setIsLoading(true)
setError(null)
try {
// Get the access token from the selected credential
const tokenResponse = await fetch('/api/auth/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
credentialId: selectedCredentialId,
workflowId,
}),
})
if (!tokenResponse.ok) {
const errorData = await tokenResponse.json()
logger.error('Access token error:', errorData)
// If there's a token error, we might need to reconnect the account
setError('Authentication failed. Please reconnect your Jira account.')
setIsLoading(false)
return
}
const tokenData = await tokenResponse.json()
const accessToken = tokenData.accessToken
if (!accessToken) {
logger.error('No access token returned')
setError('Authentication failed. Please reconnect your Jira account.')
setIsLoading(false)
return
}
// Build query parameters for the issues endpoint
const queryParams = new URLSearchParams({
domain,
accessToken,
...(projectId && { projectId }),
...(searchQuery && { query: searchQuery }),
...(cloudId && { cloudId }),
})
const response = await fetch(`/api/tools/jira/issues?${queryParams.toString()}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
const errorData = await response.json()
logger.error('Jira API error:', errorData)
throw new Error(errorData.error || 'Failed to fetch issues')
}
const data = await response.json()
if (data.cloudId) {
setCloudId(data.cloudId)
}
// Process the issue picker results
let foundIssues: JiraIssueInfo[] = []
// Handle the sections returned by the issue picker API
if (data.sections) {
// Combine issues from all sections
data.sections.forEach((section: any) => {
if (section.issues && section.issues.length > 0) {
const sectionIssues = section.issues.map((issue: any) => ({
id: issue.key,
name: issue.summary || issue.summaryText || issue.key,
mimeType: 'jira/issue',
url: `https://${domain}/browse/${issue.key}`,
webViewLink: `https://${domain}/browse/${issue.key}`,
}))
foundIssues = [...foundIssues, ...sectionIssues]
}
})
}
logger.info(`Received ${foundIssues.length} issues from API`)
setIssues(foundIssues)
// Cache issue names in display names store
if (selectedCredentialId && foundIssues.length > 0) {
const issueMap = foundIssues.reduce(
(acc: Record<string, string>, issue: JiraIssueInfo) => {
acc[issue.id] = issue.name
return acc
},
{}
)
useDisplayNamesStore.getState().setDisplayNames('files', selectedCredentialId, issueMap)
}
// If we have a selected issue ID, update state and notify parent
if (selectedIssueId) {
const issueInfo = foundIssues.find((issue: JiraIssueInfo) => issue.id === selectedIssueId)
if (issueInfo) {
setSelectedIssue(issueInfo)
onIssueInfoChange?.(issueInfo)
} else if (!searchQuery && selectedIssueId) {
// If we can't find the issue in the list, try to fetch it directly
fetchIssueInfo(selectedIssueId)
}
}
} catch (error) {
logger.error('Error fetching issues:', error)
setError((error as Error).message)
setIssues([])
} finally {
setIsLoading(false)
}
},
[
selectedCredentialId,
domain,
selectedIssueId,
onIssueInfoChange,
fetchIssueInfo,
cloudId,
projectId,
]
)
// Fetch credentials when the dropdown opens (avoid fetching on mount with no credential)
useEffect(() => {
if (open) {
fetchCredentials()
}
}, [open, fetchCredentials])
// Handle open change
const handleOpenChange = (isOpen: boolean) => {
if (disabled || isForeignCredential) {
setOpen(false)
return
}
setOpen(isOpen)
// Only fetch recent/default issues when opening the dropdown
if (isOpen && selectedCredentialId && domain && domain.includes('.')) {
// Only fetch on open when a project is selected; otherwise wait for user search
if (projectId) {
fetchIssues('')
}
}
}
// Fetch selected issue metadata once credentials are ready or changed
// Keep internal selectedIssueId in sync with the value prop
useEffect(() => {
if (value !== selectedIssueId) {
setSelectedIssueId(value)
}
// When the upstream value is cleared (e.g., project changed or remote user cleared),
// clear local selection and preview immediately
if (!value) {
setSelectedIssue(null)
setIssues([])
setError(null)
onIssueInfoChange?.(null)
}
}, [value, onIssueInfoChange])
// Fetch issue info on mount if we have a value but no selectedIssue state
useEffect(() => {
if (value && selectedCredentialId && domain && projectId && !selectedIssue) {
fetchIssueInfo(value)
}
}, [value, selectedCredentialId, domain, projectId, selectedIssue, fetchIssueInfo])
// Handle issue selection
const handleSelectIssue = (issue: JiraIssueInfo) => {
setSelectedIssueId(issue.id)
setSelectedIssue(issue)
onChange(issue.id, issue)
onIssueInfoChange?.(issue)
setOpen(false)
}
// Handle adding a new credential
const handleAddCredential = () => {
// Show the OAuth modal
setShowOAuthModal(true)
setOpen(false)
}
// Clear selection
const handleClearSelection = () => {
setSelectedIssueId('')
setError(null)
onChange('', undefined)
onIssueInfoChange?.(null)
}
return (
<>
<div className='space-y-2'>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className='h-10 w-full min-w-0 justify-between'
disabled={disabled || !domain || !selectedCredentialId || isForeignCredential}
>
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
{cachedIssueName ? (
<>
<JiraIcon className='h-4 w-4' />
<span className='truncate font-normal'>{cachedIssueName}</span>
</>
) : (
<>
<JiraIcon className='h-4 w-4' />
<span className='truncate text-muted-foreground'>{label}</span>
</>
)}
</div>
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
{!isForeignCredential && (
<PopoverContent className='w-[300px] p-0' align='start'>
{/* Current account indicator */}
{selectedCredentialId && credentials.length > 0 && (
<div className='flex items-center justify-between border-b px-3 py-2'>
<div className='flex items-center gap-2'>
<JiraIcon className='h-4 w-4' />
<span className='text-muted-foreground text-xs'>
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
'Unknown'}
</span>
</div>
{credentials.length > 1 && (
<Button
variant='ghost'
size='sm'
className='h-6 px-2 text-xs'
onClick={() => setOpen(true)}
>
Switch
</Button>
)}
</div>
)}
<Command>
<CommandInput placeholder='Search issues...' onValueChange={handleSearch} />
<CommandList>
<CommandEmpty>
{isLoading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading issues...</span>
</div>
) : error ? (
<div className='p-4 text-center'>
<p className='text-destructive text-sm'>{error}</p>
</div>
) : credentials.length === 0 ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No accounts connected.</p>
<p className='text-muted-foreground text-xs'>
Connect a Jira account to continue.
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No issues found.</p>
<p className='text-muted-foreground text-xs'>
Try a different search or account.
</p>
</div>
)}
</CommandEmpty>
{/* Account selection - only show if we have multiple accounts */}
{credentials.length > 1 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Switch Account
</div>
{credentials.map((cred) => (
<CommandItem
key={cred.id}
value={`account-${cred.id}`}
onSelect={() => setSelectedCredentialId(cred.id)}
>
<div className='flex items-center gap-2'>
<JiraIcon className='h-4 w-4' />
<span className='font-normal'>{cred.name}</span>
</div>
{cred.id === selectedCredentialId && (
<Check className='ml-auto h-4 w-4' />
)}
</CommandItem>
))}
</CommandGroup>
)}
{/* Issues list */}
{issues.length > 0 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Issues
</div>
{issues.map((issue) => (
<CommandItem
key={issue.id}
value={`issue-${issue.id}-${issue.name}`}
onSelect={() => handleSelectIssue(issue)}
>
<div className='flex items-center gap-2 overflow-hidden'>
<JiraIcon className='h-4 w-4' />
<span className='truncate font-normal'>{issue.name}</span>
</div>
{issue.id === selectedIssueId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
{/* Connect account option - only show if no credentials */}
{credentials.length === 0 && (
<CommandGroup>
<CommandItem onSelect={handleAddCredential}>
<div className='flex items-center gap-2 text-foreground'>
<JiraIcon className='h-4 w-4' />
<span>Connect Jira account</span>
</div>
</CommandItem>
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
)}
</Popover>
{showPreview && selectedIssue && (
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
<div className='absolute top-2 right-2'>
<Button
variant='ghost'
size='icon'
className='h-5 w-5 hover:bg-muted'
onClick={handleClearSelection}
>
<X className='h-3 w-3' />
</Button>
</div>
<div className='flex items-center gap-3 pr-4'>
<div className='flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-muted/20'>
<JiraIcon className='h-4 w-4' />
</div>
<div className='min-w-0 flex-1 overflow-hidden'>
<div className='flex items-center gap-2'>
<h4 className='truncate font-medium text-xs'>{selectedIssue.name}</h4>
{selectedIssue.modifiedTime && (
<span className='whitespace-nowrap text-muted-foreground text-xs'>
{new Date(selectedIssue.modifiedTime).toLocaleDateString()}
</span>
)}
</div>
{selectedIssue.webViewLink && (
<a
href={selectedIssue.webViewLink}
target='_blank'
rel='noopener noreferrer'
className='flex items-center gap-1 text-foreground text-xs hover:underline'
onClick={(e) => e.stopPropagation()}
>
<span>Open in Jira</span>
<ExternalLink className='h-3 w-3' />
</a>
)}
</div>
</div>
</div>
)}
</div>
{showOAuthModal && (
<OAuthRequiredModal
isOpen={showOAuthModal}
onClose={() => setShowOAuthModal(false)}
provider={provider}
toolName='Jira'
requiredScopes={requiredScopes}
serviceId={getServiceId()}
/>
)}
</>
)
}

View File

@@ -1,961 +0,0 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Check, ChevronDown, ExternalLink, RefreshCw, X } from 'lucide-react'
import { MicrosoftTeamsIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { createLogger } from '@/lib/logs/console/logger'
import {
type Credential,
getProviderIdFromServiceId,
getServiceIdFromScopes,
type OAuthProvider,
} 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 { useDisplayNamesStore } from '@/stores/display-names/store'
const logger = createLogger('TeamsMessageSelector')
export interface TeamsMessageInfo {
id: string
displayName: string
type: 'team' | 'channel' | 'chat'
teamId?: string
channelId?: string
chatId?: string
webViewLink?: string
}
interface TeamsMessageSelectorProps {
value: string
onChange: (value: string, messageInfo?: TeamsMessageInfo) => void
provider: OAuthProvider
requiredScopes?: string[]
label?: string
disabled?: boolean
serviceId?: string
showPreview?: boolean
onMessageInfoChange?: (messageInfo: TeamsMessageInfo | null) => void
credential: string
selectionType?: 'team' | 'channel' | 'chat'
initialTeamId?: string
workflowId: string
isForeignCredential?: boolean
}
export function TeamsMessageSelector({
value,
onChange,
provider,
requiredScopes = [],
label = 'Select Teams message location',
disabled = false,
serviceId,
showPreview = true,
onMessageInfoChange,
credential,
selectionType = 'team',
initialTeamId,
workflowId,
isForeignCredential = false,
}: TeamsMessageSelectorProps) {
const [open, setOpen] = useState(false)
const [credentials, setCredentials] = useState<Credential[]>([])
const [teams, setTeams] = useState<TeamsMessageInfo[]>([])
const [channels, setChannels] = useState<TeamsMessageInfo[]>([])
const [chats, setChats] = useState<TeamsMessageInfo[]>([])
const [selectedCredentialId, setSelectedCredentialId] = useState<string>(credential || '')
const [selectedTeamId, setSelectedTeamId] = useState<string>('')
const [selectedChannelId, setSelectedChannelId] = useState<string>('')
const [selectedChatId, setSelectedChatId] = useState<string>('')
const [selectedMessageId, setSelectedMessageId] = useState(value)
const [selectedMessage, setSelectedMessage] = useState<TeamsMessageInfo | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [showOAuthModal, setShowOAuthModal] = useState(false)
const initialFetchRef = useRef(false)
const [error, setError] = useState<string | null>(null)
const [selectionStage, setSelectionStage] = useState<'team' | 'channel' | 'chat'>(selectionType)
const lastRestoredValueRef = useRef<string | null>(null)
// Get cached display name
const cachedMessageName = useDisplayNamesStore(
useCallback(
(state) => {
if (!credential || !value) return null
return state.cache.files[credential]?.[value] || null
},
[credential, value]
)
)
// Determine the appropriate service ID based on provider and scopes
const getServiceId = (): string => {
if (serviceId) return serviceId
return getServiceIdFromScopes(provider, requiredScopes)
}
// Determine the appropriate provider ID based on service and scopes
const getProviderId = (): string => {
const effectiveServiceId = getServiceId()
return getProviderIdFromServiceId(effectiveServiceId)
}
const fetchCredentials = useCallback(async () => {
setIsLoading(true)
try {
const providerId = getProviderId()
const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`)
if (response.ok) {
const data = await response.json()
setCredentials(data.credentials)
}
} catch (error) {
logger.error('Error fetching credentials:', error)
} finally {
setIsLoading(false)
}
}, [provider, getProviderId, selectedCredentialId])
// Fetch teams
const fetchTeams = useCallback(async () => {
if (!selectedCredentialId) return
setIsLoading(true)
setError(null)
try {
const response = await fetch('/api/tools/microsoft-teams/teams', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
credential: selectedCredentialId,
workflowId,
}),
})
if (!response.ok) {
const errorData = await response.json()
// If server indicates auth is required, show the auth modal
if (response.status === 401 && errorData.authRequired) {
logger.warn('Authentication required for Microsoft Teams')
setShowOAuthModal(true)
throw new Error('Microsoft Teams authentication required')
}
throw new Error(errorData.error || 'Failed to fetch teams')
}
const data = await response.json()
const teamsData = data.teams.map((team: { id: string; displayName: string }) => ({
id: team.id,
displayName: team.displayName,
type: 'team' as const,
teamId: team.id,
webViewLink: `https://teams.microsoft.com/l/team/${team.id}`,
}))
setTeams(teamsData)
// Cache team names in display names store
if (selectedCredentialId && teamsData.length > 0) {
const teamMap = teamsData.reduce((acc: Record<string, string>, team: TeamsMessageInfo) => {
acc[team.id] = team.displayName
return acc
}, {})
useDisplayNamesStore.getState().setDisplayNames('files', selectedCredentialId, teamMap)
}
// If we have a selected team ID, find it in the list
if (selectedTeamId) {
const team = teamsData.find((t: TeamsMessageInfo) => t.teamId === selectedTeamId)
if (team) {
setSelectedMessage(team)
onMessageInfoChange?.(team)
}
}
} catch (error) {
logger.error('Error fetching teams:', error)
setError((error as Error).message)
setTeams([])
} finally {
setIsLoading(false)
}
}, [selectedCredentialId, selectedTeamId, onMessageInfoChange, workflowId])
// Fetch channels for a selected team
const fetchChannels = useCallback(
async (teamId: string) => {
if (!selectedCredentialId || !teamId) return
setIsLoading(true)
setError(null)
try {
const response = await fetch('/api/tools/microsoft-teams/channels', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
credential: selectedCredentialId,
teamId,
workflowId,
}),
})
if (!response.ok) {
const errorData = await response.json()
// If server indicates auth is required, show the auth modal
if (response.status === 401 && errorData.authRequired) {
logger.warn('Authentication required for Microsoft Teams')
setShowOAuthModal(true)
throw new Error('Microsoft Teams authentication required')
}
throw new Error(errorData.error || 'Failed to fetch channels')
}
const data = await response.json()
const channelsData = data.channels.map((channel: { id: string; displayName: string }) => ({
id: `${teamId}-${channel.id}`,
displayName: channel.displayName,
type: 'channel' as const,
teamId,
channelId: channel.id,
webViewLink: `https://teams.microsoft.com/l/channel/${teamId}/${encodeURIComponent(channel.displayName)}/${channel.id}`,
}))
setChannels(channelsData)
// Cache channel names in display names store
if (selectedCredentialId && channelsData.length > 0) {
const channelMap = channelsData.reduce(
(acc: Record<string, string>, channel: TeamsMessageInfo) => {
acc[channel.channelId!] = channel.displayName
return acc
},
{}
)
useDisplayNamesStore.getState().setDisplayNames('files', selectedCredentialId, channelMap)
}
// If we have a selected channel ID, find it in the list
if (selectedChannelId) {
const channel = channelsData.find(
(c: TeamsMessageInfo) => c.channelId === selectedChannelId
)
if (channel) {
setSelectedMessage(channel)
onMessageInfoChange?.(channel)
}
}
} catch (error) {
logger.error('Error fetching channels:', error)
setError((error as Error).message)
setChannels([])
} finally {
setIsLoading(false)
}
},
[selectedCredentialId, selectedChannelId, onMessageInfoChange, workflowId]
)
// Fetch chats
const fetchChats = useCallback(async () => {
if (!selectedCredentialId) return
setIsLoading(true)
setError(null)
try {
const response = await fetch('/api/tools/microsoft-teams/chats', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
credential: selectedCredentialId,
workflowId: workflowId, // Pass the workflowId for server-side authentication
}),
})
if (!response.ok) {
const errorData = await response.json()
// If server indicates auth is required, show the auth modal
if (response.status === 401 && errorData.authRequired) {
logger.warn('Authentication required for Microsoft Teams')
setShowOAuthModal(true)
throw new Error('Microsoft Teams authentication required')
}
throw new Error(errorData.error || 'Failed to fetch chats')
}
const data = await response.json()
const chatsData = data.chats.map((chat: { id: string; displayName: string }) => ({
id: chat.id,
displayName: chat.displayName,
type: 'chat' as const,
chatId: chat.id,
webViewLink: `https://teams.microsoft.com/l/chat/${chat.id}`,
}))
setChats(chatsData)
if (selectedCredentialId && chatsData.length > 0) {
const chatMap = chatsData.reduce((acc: Record<string, string>, chat: TeamsMessageInfo) => {
acc[chat.id] = chat.displayName
return acc
}, {})
useDisplayNamesStore.getState().setDisplayNames('files', selectedCredentialId, chatMap)
}
// If we have a selected chat ID, find it in the list
if (selectedChatId) {
const chat = chatsData.find((c: TeamsMessageInfo) => c.chatId === selectedChatId)
if (chat) {
setSelectedMessage(chat)
onMessageInfoChange?.(chat)
}
}
} catch (error) {
logger.error('Error fetching chats:', error)
setError((error as Error).message)
setChats([])
} finally {
setIsLoading(false)
}
}, [selectedCredentialId, selectedChatId, onMessageInfoChange, workflowId])
// Update selection stage based on selected values and selectionType
useEffect(() => {
// If we have explicit values selected, use those to determine the stage
if (selectedChatId) {
setSelectionStage('chat')
} else if (selectedChannelId) {
setSelectionStage('channel')
} else if (selectionType === 'channel' && selectedTeamId) {
// If we're in channel mode and have a team selected, go to channel selection
setSelectionStage('channel')
} else if (selectionType !== 'team' && !selectedTeamId) {
// If no selections but we have a specific selection type, use that
// But for channel selection, start with team selection if no team is selected
if (selectionType === 'channel') {
setSelectionStage('team')
} else {
setSelectionStage(selectionType)
}
} else {
// Default to team selection
setSelectionStage('team')
}
}, [selectedTeamId, selectedChannelId, selectedChatId, selectionType])
// Handle open change
const handleOpenChange = (isOpen: boolean) => {
if (disabled || isForeignCredential) {
setOpen(false)
return
}
setOpen(isOpen)
// Only fetch data when opening the dropdown
if (isOpen && selectedCredentialId) {
if (selectionStage === 'team') {
fetchTeams()
} else if (selectionStage === 'channel' && selectedTeamId) {
fetchChannels(selectedTeamId)
} else if (selectionStage === 'chat') {
fetchChats()
}
}
}
// Keep internal selectedMessageId in sync with the value prop
useEffect(() => {
if (value !== selectedMessageId) {
setSelectedMessageId(value)
}
}, [value])
// Handle team selection
const handleSelectTeam = (team: TeamsMessageInfo) => {
setSelectedTeamId(team.teamId || '')
setSelectedChannelId('')
setSelectedChatId('')
setSelectedMessage(team)
setSelectedMessageId(team.id)
onChange(team.id, team)
onMessageInfoChange?.(team)
setSelectionStage('channel')
fetchChannels(team.teamId || '')
setOpen(false)
}
// Handle channel selection
const handleSelectChannel = (channel: TeamsMessageInfo) => {
setSelectedChannelId(channel.channelId || '')
setSelectedChatId('')
setSelectedMessage(channel)
setSelectedMessageId(channel.channelId || '')
onChange(channel.channelId || '', channel)
onMessageInfoChange?.(channel)
setOpen(false)
}
// Handle chat selection
const handleSelectChat = (chat: TeamsMessageInfo) => {
setSelectedChatId(chat.chatId || '')
setSelectedMessage(chat)
setSelectedMessageId(chat.id)
onChange(chat.id, chat)
onMessageInfoChange?.(chat)
setOpen(false)
}
// Handle adding a new credential
const handleAddCredential = () => {
// Show the OAuth modal
setShowOAuthModal(true)
setOpen(false)
}
// Clear selection
const handleClearSelection = () => {
setSelectedMessageId('')
setSelectedTeamId('')
setSelectedChannelId('')
setSelectedChatId('')
setSelectedMessage(null)
setError(null)
onChange('', undefined)
onMessageInfoChange?.(null)
setSelectionStage(selectionType) // Reset to the initial selection type
}
// Render dropdown options based on the current selection stage
const renderSelectionOptions = () => {
if (selectionStage === 'team' && teams.length > 0) {
return (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>Teams</div>
{teams.map((team) => (
<CommandItem
key={team.id}
value={`team-${team.id}-${team.displayName}`}
onSelect={() => handleSelectTeam(team)}
>
<div className='flex items-center gap-2 overflow-hidden'>
<MicrosoftTeamsIcon className='h-4 w-4' />
<span className='truncate font-normal'>{team.displayName}</span>
</div>
{team.teamId === selectedTeamId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)
}
if (selectionStage === 'channel' && channels.length > 0) {
return (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>Channels</div>
{channels.map((channel) => (
<CommandItem
key={channel.id}
value={`channel-${channel.id}-${channel.displayName}`}
onSelect={() => handleSelectChannel(channel)}
>
<div className='flex items-center gap-2 overflow-hidden'>
<MicrosoftTeamsIcon className='h-4 w-4' />
<span className='truncate font-normal'>{channel.displayName}</span>
</div>
{channel.channelId === selectedChannelId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)
}
if (selectionStage === 'chat' && chats.length > 0) {
return (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>Chats</div>
{chats.map((chat) => (
<CommandItem
key={chat.id}
value={`chat-${chat.id}-${chat.displayName}`}
onSelect={() => handleSelectChat(chat)}
>
<div className='flex items-center gap-2 overflow-hidden'>
<MicrosoftTeamsIcon className='h-4 w-4' />
<span className='truncate font-normal'>{chat.displayName}</span>
</div>
{chat.chatId === selectedChatId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)
}
return null
}
// Restore team selection on page refresh
const restoreTeamSelection = useCallback(
async (teamId: string) => {
if (!selectedCredentialId || !teamId || selectionType !== 'team') return
setIsLoading(true)
try {
const response = await fetch('/api/tools/microsoft-teams/teams', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential: selectedCredentialId, workflowId }),
})
if (response.ok) {
const data = await response.json()
const team = data.teams.find((t: { id: string; displayName: string }) => t.id === teamId)
if (team) {
const teamInfo: TeamsMessageInfo = {
id: team.id,
displayName: team.displayName,
type: 'team',
teamId: team.id,
webViewLink: `https://teams.microsoft.com/l/team/${team.id}`,
}
setSelectedTeamId(team.id)
setSelectedMessage(teamInfo)
onMessageInfoChange?.(teamInfo)
}
}
} catch (error) {
logger.error('Error restoring team selection:', error)
} finally {
setIsLoading(false)
}
},
[selectedCredentialId, selectionType, onMessageInfoChange, workflowId]
)
// Restore chat selection on page refresh
const restoreChatSelection = useCallback(
async (chatId: string) => {
if (!selectedCredentialId || !chatId || selectionType !== 'chat') return
setIsLoading(true)
try {
const response = await fetch('/api/tools/microsoft-teams/chats', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential: selectedCredentialId, workflowId }),
})
if (response.ok) {
const data = await response.json()
// Cache all chat names
if (data.chats && selectedCredentialId) {
const chatMap = data.chats.reduce(
(acc: Record<string, string>, c: { id: string; displayName: string }) => {
acc[c.id] = c.displayName
return acc
},
{}
)
useDisplayNamesStore.getState().setDisplayNames('files', selectedCredentialId, chatMap)
}
const chat = data.chats.find((c: { id: string; displayName: string }) => c.id === chatId)
if (chat) {
const chatInfo: TeamsMessageInfo = {
id: chat.id,
displayName: chat.displayName,
type: 'chat',
chatId: chat.id,
webViewLink: `https://teams.microsoft.com/l/chat/${chat.id}`,
}
setSelectedChatId(chat.id)
setSelectedMessage(chatInfo)
onMessageInfoChange?.(chatInfo)
}
}
} catch (error) {
logger.error('Error restoring chat selection:', error)
} finally {
setIsLoading(false)
}
},
[selectedCredentialId, selectionType, onMessageInfoChange, workflowId]
)
// Restore channel selection on page refresh
const restoreChannelSelection = useCallback(
async (channelId: string) => {
if (!selectedCredentialId || !channelId || selectionType !== 'channel') return
setIsLoading(true)
try {
// First fetch teams to search through them
const teamsResponse = await fetch('/api/tools/microsoft-teams/teams', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential: selectedCredentialId, workflowId }),
})
if (teamsResponse.ok) {
const teamsData = await teamsResponse.json()
// Create parallel promises for all teams to search for the channel
const channelSearchPromises = teamsData.teams.map(
async (team: { id: string; displayName: string }) => {
try {
const channelsResponse = await fetch('/api/tools/microsoft-teams/channels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
credential: selectedCredentialId,
teamId: team.id,
workflowId,
}),
})
if (channelsResponse.ok) {
const channelsData = await channelsResponse.json()
const channel = channelsData.channels.find(
(c: { id: string; displayName: string }) => c.id === channelId
)
if (channel) {
return {
team,
channel,
channelInfo: {
id: `${team.id}-${channel.id}`,
displayName: channel.displayName,
type: 'channel' as const,
teamId: team.id,
channelId: channel.id,
webViewLink: `https://teams.microsoft.com/l/channel/${team.id}/${encodeURIComponent(channel.displayName)}/${channel.id}`,
},
}
}
}
} catch (error) {
logger.warn(
`Error searching for channel in team ${team.id}:`,
error instanceof Error ? error.message : String(error)
)
}
return null
}
)
// Wait for all parallel requests to complete (or fail)
const results = await Promise.allSettled(channelSearchPromises)
// Find the first successful result that contains our channel
for (const result of results) {
if (result.status === 'fulfilled' && result.value) {
const { channelInfo } = result.value
setSelectedTeamId(channelInfo.teamId!)
setSelectedChannelId(channelInfo.channelId!)
setSelectedMessage(channelInfo)
onMessageInfoChange?.(channelInfo)
return // Found the channel, exit successfully
}
}
// If we get here, the channel wasn't found in any team
logger.warn(`Channel ${channelId} not found in any accessible team`)
}
} catch (error) {
logger.error('Error restoring channel selection:', error)
} finally {
setIsLoading(false)
}
},
[selectedCredentialId, selectionType, onMessageInfoChange, workflowId]
)
// Set initial team ID if provided
useEffect(() => {
if (initialTeamId && !selectedTeamId && selectionType === 'channel') {
setSelectedTeamId(initialTeamId)
}
}, [initialTeamId, selectedTeamId, selectionType])
// Clear selection when selectionType changes to allow proper restoration
useEffect(() => {
setSelectedMessage(null)
setSelectedTeamId('')
setSelectedChannelId('')
setSelectedChatId('')
}, [selectionType])
// Fetch appropriate data on initial mount based on selectionType
useEffect(() => {
if (!initialFetchRef.current) {
fetchCredentials()
initialFetchRef.current = true
}
}, [fetchCredentials])
// Keep local credential state in sync with persisted credential
useEffect(() => {
if (credential && credential !== selectedCredentialId) {
setSelectedCredentialId(credential)
}
}, [credential, selectedCredentialId])
// Restore selection whenever the canonical value changes
useEffect(() => {
if (value && selectedCredentialId) {
// Only restore if we haven't already restored this value
if (lastRestoredValueRef.current !== value) {
lastRestoredValueRef.current = value
if (selectionType === 'team') {
restoreTeamSelection(value)
} else if (selectionType === 'chat') {
restoreChatSelection(value)
} else if (selectionType === 'channel') {
restoreChannelSelection(value)
}
}
} else {
lastRestoredValueRef.current = null
setSelectedMessage(null)
}
}, [
value,
selectedCredentialId,
selectionType,
restoreTeamSelection,
restoreChatSelection,
restoreChannelSelection,
])
return (
<>
<div className='space-y-2'>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className='h-10 w-full min-w-0 justify-between'
disabled={disabled || isForeignCredential}
>
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
{cachedMessageName ? (
<>
<MicrosoftTeamsIcon className='h-4 w-4' />
<span className='truncate font-normal'>{cachedMessageName}</span>
</>
) : (
<>
<MicrosoftTeamsIcon className='h-4 w-4' />
<span className='truncate text-muted-foreground'>
{selectionType === 'channel' && selectionStage === 'team'
? 'Select a team first'
: label}
</span>
</>
)}
</div>
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
{!isForeignCredential && (
<PopoverContent className='w-[300px] p-0' align='start'>
{/* Current account indicator */}
{selectedCredentialId && credentials.length > 0 && (
<div className='flex items-center justify-between border-b px-3 py-2'>
<div className='flex items-center gap-2'>
<MicrosoftTeamsIcon className='h-4 w-4' />
<span className='text-muted-foreground text-xs'>
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
'Unknown'}
</span>
</div>
{credentials.length > 1 && (
<Button
variant='ghost'
size='sm'
className='h-6 px-2 text-xs'
onClick={() => setOpen(true)}
>
Switch
</Button>
)}
</div>
)}
<Command>
<CommandInput placeholder={`Search ${selectionStage}s...`} />
<CommandList>
<CommandEmpty>
{isLoading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading {selectionStage}s...</span>
</div>
) : error ? (
<div className='p-4 text-center'>
<p className='text-destructive text-sm'>{error}</p>
{selectionStage === 'chat' && error.includes('teams') && (
<p className='mt-1 text-muted-foreground text-xs'>
There was an issue fetching chats. Please try again or connect a
different account.
</p>
)}
</div>
) : credentials.length === 0 ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No accounts connected.</p>
<p className='text-muted-foreground text-xs'>
Connect a Microsoft Teams account to{' '}
{selectionStage === 'chat'
? 'access your chats'
: selectionStage === 'channel'
? 'see your channels'
: 'continue'}
.
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No {selectionStage}s found.</p>
<p className='text-muted-foreground text-xs'>
{selectionStage === 'team'
? 'Try a different account.'
: selectionStage === 'channel'
? selectedTeamId
? 'This team has no channels or you may not have access.'
: 'Please select a team first to see its channels.'
: 'Try a different account or check if you have any active chats.'}
</p>
</div>
)}
</CommandEmpty>
{/* Account selection - only show if we have multiple accounts */}
{credentials.length > 1 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Switch Account
</div>
{credentials.map((cred) => (
<CommandItem
key={cred.id}
value={`account-${cred.id}`}
onSelect={() => {
setSelectedCredentialId(cred.id)
setOpen(false)
}}
>
<div className='flex items-center gap-2'>
<MicrosoftTeamsIcon className='h-4 w-4' />
<span className='font-normal'>{cred.name}</span>
</div>
{cred.id === selectedCredentialId && (
<Check className='ml-auto h-4 w-4' />
)}
</CommandItem>
))}
</CommandGroup>
)}
{/* Display appropriate options based on selection stage */}
{renderSelectionOptions()}
{/* Connect account option - only show if no credentials */}
{credentials.length === 0 && (
<CommandGroup>
<CommandItem onSelect={handleAddCredential}>
<div className='flex items-center gap-2 text-foreground'>
<MicrosoftTeamsIcon className='h-4 w-4' />
<span>Connect Microsoft Teams account</span>
</div>
</CommandItem>
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
)}
</Popover>
{/* Selection preview */}
{showPreview && selectedMessage && (
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
<div className='absolute top-2 right-2'>
<Button
variant='ghost'
size='icon'
className='h-5 w-5 hover:bg-muted'
onClick={handleClearSelection}
>
<X className='h-3 w-3' />
</Button>
</div>
<div className='flex items-center gap-3 pr-4'>
<div className='flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-muted/20'>
<MicrosoftTeamsIcon className='h-4 w-4' />
</div>
<div className='min-w-0 flex-1 overflow-hidden'>
<div className='flex items-center gap-2'>
<h4 className='truncate font-medium text-xs'>{selectedMessage.displayName}</h4>
<span className='whitespace-nowrap text-muted-foreground text-xs'>
{selectedMessage.type}
</span>
</div>
{selectedMessage.webViewLink ? (
<a
href={selectedMessage.webViewLink}
target='_blank'
rel='noopener noreferrer'
className='flex items-center gap-1 text-foreground text-xs hover:underline'
onClick={(e) => e.stopPropagation()}
>
<span>Open in Microsoft Teams</span>
<ExternalLink className='h-3 w-3' />
</a>
) : (
<></>
)}
</div>
</div>
</div>
)}
</div>
{showOAuthModal && (
<OAuthRequiredModal
isOpen={showOAuthModal}
onClose={() => setShowOAuthModal(false)}
provider={provider}
toolName='Microsoft Teams'
requiredScopes={requiredScopes}
serviceId={getServiceId()}
/>
)}
</>
)
}

View File

@@ -1,484 +0,0 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Check, ChevronDown, X } from 'lucide-react'
import { WealthboxIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { createLogger } from '@/lib/logs/console/logger'
import {
type Credential,
getProviderIdFromServiceId,
getServiceIdFromScopes,
type OAuthProvider,
} 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 { useDisplayNamesStore } from '@/stores/display-names/store'
const logger = createLogger('WealthboxFileSelector')
export interface WealthboxItemInfo {
id: string
name: string
type: 'contact'
content?: string
createdAt?: string
updatedAt?: string
}
interface WealthboxFileSelectorProps {
value: string
onChange: (value: string, itemInfo?: WealthboxItemInfo) => void
provider: OAuthProvider
requiredScopes?: string[]
label?: string
disabled?: boolean
serviceId?: string
showPreview?: boolean
onFileInfoChange?: (itemInfo: WealthboxItemInfo | null) => void
itemType?: 'contact'
credentialId?: string
}
export function WealthboxFileSelector({
value,
onChange,
provider,
requiredScopes = [],
label = 'Select item',
disabled = false,
serviceId,
showPreview = true,
onFileInfoChange,
itemType = 'contact',
credentialId,
}: WealthboxFileSelectorProps) {
const [open, setOpen] = useState(false)
const [credentials, setCredentials] = useState<Credential[]>([])
const [selectedCredentialId, setSelectedCredentialId] = useState<string>(credentialId || '')
const [selectedItemId, setSelectedItemId] = useState(value)
const [selectedItem, setSelectedItem] = useState<WealthboxItemInfo | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [isLoadingSelectedItem, setIsLoadingSelectedItem] = useState(false)
const [isLoadingItems, setIsLoadingItems] = useState(false)
const [availableItems, setAvailableItems] = useState<WealthboxItemInfo[]>([])
const [searchQuery, setSearchQuery] = useState<string>('')
const [showOAuthModal, setShowOAuthModal] = useState(false)
const [credentialsLoaded, setCredentialsLoaded] = useState(false)
const initialFetchRef = useRef(false)
// Get cached display name
const cachedItemName = useDisplayNamesStore(
useCallback(
(state) => {
const effectiveCredentialId = credentialId || selectedCredentialId
if (!effectiveCredentialId || !value) return null
return state.cache.files[effectiveCredentialId]?.[value] || null
},
[credentialId, selectedCredentialId, value]
)
)
// Determine the appropriate service ID based on provider and scopes
const getServiceId = (): string => {
if (serviceId) return serviceId
return getServiceIdFromScopes(provider, requiredScopes)
}
// Determine the appropriate provider ID based on service and scopes
const getProviderId = (): string => {
const effectiveServiceId = getServiceId()
return getProviderIdFromServiceId(effectiveServiceId)
}
// Fetch available credentials for this provider
const fetchCredentials = useCallback(async () => {
setIsLoading(true)
setCredentialsLoaded(false)
try {
const providerId = getProviderId()
const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`)
if (response.ok) {
const data = await response.json()
setCredentials(data.credentials)
}
} catch (error) {
logger.error('Error fetching credentials:', { error })
} finally {
setIsLoading(false)
setCredentialsLoaded(true)
}
}, [provider, getProviderId, selectedCredentialId])
// Keep local credential state in sync with persisted credential
useEffect(() => {
if (credentialId && credentialId !== selectedCredentialId) {
setSelectedCredentialId(credentialId)
}
}, [credentialId, selectedCredentialId])
// Debounced search function
const [searchTimeout, setSearchTimeout] = useState<NodeJS.Timeout | null>(null)
// Fetch available items for the selected credential
const fetchAvailableItems = useCallback(async () => {
if (!selectedCredentialId) return
setIsLoadingItems(true)
try {
const queryParams = new URLSearchParams({
credentialId: selectedCredentialId,
type: itemType,
})
if (searchQuery.trim()) {
queryParams.append('query', searchQuery.trim())
}
const response = await fetch(`/api/auth/oauth/wealthbox/items?${queryParams.toString()}`)
if (response.ok) {
const data = await response.json()
setAvailableItems(data.items || [])
// Cache item names in display names store
if (selectedCredentialId && data.items) {
const itemMap = data.items.reduce(
(acc: Record<string, string>, item: WealthboxItemInfo) => {
acc[item.id] = item.name
return acc
},
{}
)
useDisplayNamesStore.getState().setDisplayNames('files', selectedCredentialId, itemMap)
}
} else {
logger.error('Error fetching available items:', {
error: await response.text(),
})
setAvailableItems([])
}
} catch (error) {
logger.error('Error fetching available items:', { error })
setAvailableItems([])
} finally {
setIsLoadingItems(false)
}
}, [selectedCredentialId, searchQuery, itemType])
// Fetch a single item by ID
const fetchItemById = useCallback(
async (itemId: string) => {
if (!selectedCredentialId || !itemId) return null
setIsLoadingSelectedItem(true)
try {
const queryParams = new URLSearchParams({
credentialId: selectedCredentialId,
itemId: itemId,
type: itemType,
})
const response = await fetch(`/api/auth/oauth/wealthbox/item?${queryParams.toString()}`)
if (response.ok) {
const data = await response.json()
if (data.item) {
setSelectedItem(data.item)
onFileInfoChange?.(data.item)
// Cache the item name in display names store
if (selectedCredentialId) {
useDisplayNamesStore
.getState()
.setDisplayNames('files', selectedCredentialId, { [data.item.id]: data.item.name })
}
return data.item
}
} else {
const errorText = await response.text()
logger.error('Error fetching item by ID:', { error: errorText })
if (response.status === 404 || response.status === 403) {
logger.info('Item not accessible, clearing selection')
setSelectedItemId('')
onChange('')
onFileInfoChange?.(null)
}
}
return null
} catch (error) {
logger.error('Error fetching item by ID:', { error })
return null
} finally {
setIsLoadingSelectedItem(false)
}
},
[selectedCredentialId, itemType, onFileInfoChange, onChange]
)
// Fetch credentials on initial mount
useEffect(() => {
if (!initialFetchRef.current) {
fetchCredentials()
initialFetchRef.current = true
}
}, [fetchCredentials])
// Fetch available items only when dropdown is opened
useEffect(() => {
if (selectedCredentialId && open) {
fetchAvailableItems()
}
}, [selectedCredentialId, open, fetchAvailableItems])
// Fetch item info on mount if we have a value but no selectedItem state
useEffect(() => {
if (value && selectedCredentialId && !selectedItem) {
fetchItemById(value)
}
}, [value, selectedCredentialId, selectedItem, fetchItemById])
// Clear selectedItem when value is cleared
useEffect(() => {
if (!value) {
setSelectedItem(null)
onFileInfoChange?.(null)
}
}, [value, onFileInfoChange])
// Handle search input changes with debouncing
const handleSearchChange = useCallback(
(newQuery: string) => {
setSearchQuery(newQuery)
// Clear existing timeout
if (searchTimeout) {
clearTimeout(searchTimeout)
}
// Set new timeout for search
const timeout = setTimeout(() => {
if (selectedCredentialId) {
fetchAvailableItems()
}
}, 300) // 300ms debounce
setSearchTimeout(timeout)
},
[selectedCredentialId, fetchAvailableItems, searchTimeout]
)
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (searchTimeout) {
clearTimeout(searchTimeout)
}
}
}, [searchTimeout])
// Handle selecting an item
const handleItemSelect = (item: WealthboxItemInfo) => {
setSelectedItemId(item.id)
setSelectedItem(item)
onChange(item.id, item)
onFileInfoChange?.(item)
setOpen(false)
setSearchQuery('')
}
// Handle adding a new credential
const handleAddCredential = () => {
setShowOAuthModal(true)
setOpen(false)
setSearchQuery('')
}
// Clear selection
const handleClearSelection = () => {
setSelectedItemId('')
onChange('', undefined)
onFileInfoChange?.(null)
}
const getItemTypeLabel = () => {
switch (itemType) {
case 'contact':
return 'Contacts'
default:
return 'Contacts'
}
}
return (
<>
<div className='space-y-2'>
<Popover
open={open}
onOpenChange={(isOpen) => {
setOpen(isOpen)
if (!isOpen) {
setSearchQuery('')
if (searchTimeout) {
clearTimeout(searchTimeout)
setSearchTimeout(null)
}
}
}}
>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className='w-full justify-between'
disabled={disabled}
>
{cachedItemName ? (
<div className='flex items-center gap-2 overflow-hidden'>
<WealthboxIcon className='h-4 w-4' />
<span className='truncate font-normal'>{cachedItemName}</span>
</div>
) : (
<div className='flex items-center gap-2'>
<WealthboxIcon className='h-4 w-4' />
<span className='text-muted-foreground'>{label}</span>
</div>
)}
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[300px] p-0' align='start'>
<Command shouldFilter={false}>
<div className='flex items-center border-b px-3' cmdk-input-wrapper=''>
<input
placeholder={`Search ${itemType}s...`}
value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
className='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'
/>
</div>
<CommandList>
<CommandEmpty>
{isLoadingItems ? `Loading ${itemType}s...` : `No ${itemType}s found.`}
</CommandEmpty>
{credentials.length > 1 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Switch Account
</div>
{credentials.map((cred) => (
<CommandItem
key={cred.id}
value={`account-${cred.id}`}
onSelect={() => setSelectedCredentialId(cred.id)}
>
<div className='flex items-center gap-2'>
<WealthboxIcon className='h-4 w-4' />
<span className='font-normal'>{cred.name}</span>
</div>
{cred.id === selectedCredentialId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
{availableItems.length > 0 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
{getItemTypeLabel()}
</div>
{availableItems.map((item) => (
<CommandItem
key={item.id}
value={`item-${item.id}-${item.name}`}
onSelect={() => handleItemSelect(item)}
>
<div className='flex items-center gap-2 overflow-hidden'>
<WealthboxIcon className='h-4 w-4' />
<div className='min-w-0 flex-1'>
<span className='truncate font-normal'>{item.name}</span>
{item.updatedAt && (
<div className='text-muted-foreground text-xs'>
Updated {new Date(item.updatedAt).toLocaleDateString()}
</div>
)}
</div>
</div>
{item.id === selectedItemId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
{credentials.length === 0 && (
<CommandGroup>
<CommandItem onSelect={handleAddCredential}>
<div className='flex items-center gap-2 text-foreground'>
<WealthboxIcon className='h-4 w-4' />
<span>Connect Wealthbox account</span>
</div>
</CommandItem>
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{showPreview && selectedItem && (
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
<div className='absolute top-2 right-2'>
<Button
variant='ghost'
size='icon'
className='h-5 w-5 hover:bg-muted'
onClick={handleClearSelection}
>
<X className='h-3 w-3' />
</Button>
</div>
<div className='flex items-center gap-3 pr-4'>
<div className='flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-muted/20'>
<WealthboxIcon className='h-4 w-4' />
</div>
<div className='min-w-0 flex-1 overflow-hidden'>
<div className='flex items-center gap-2'>
<h4 className='truncate font-medium text-xs'>{selectedItem.name}</h4>
{selectedItem.updatedAt && (
<span className='whitespace-nowrap text-muted-foreground text-xs'>
{new Date(selectedItem.updatedAt).toLocaleDateString()}
</span>
)}
</div>
<div className='text-muted-foreground text-xs capitalize'>{selectedItem.type}</div>
</div>
</div>
</div>
)}
</div>
{showOAuthModal && (
<OAuthRequiredModal
isOpen={showOAuthModal}
onClose={() => setShowOAuthModal(false)}
toolName='Wealthbox'
provider={provider}
requiredScopes={requiredScopes}
serviceId={getServiceId()}
/>
)}
</>
)
}

View File

@@ -1,22 +1,14 @@
'use client'
import { useMemo } from 'react'
import { useParams } from 'next/navigation'
import { Tooltip } from '@/components/emcn'
import { getEnv } from '@/lib/env'
import { getProviderIdFromServiceId } from '@/lib/oauth'
import {
ConfluenceFileSelector,
GoogleCalendarSelector,
GoogleDrivePicker,
JiraIssueSelector,
MicrosoftFileSelector,
TeamsMessageSelector,
WealthboxFileSelector,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/file-selector/components'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-foreign-credential'
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 { resolveSelectorForSubBlock, type SelectorResolution } from '@/hooks/selectors/resolution'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -41,506 +33,108 @@ export function FileSelectorInput({
const { activeWorkflowId } = useWorkflowRegistry()
const params = useParams()
const workflowIdFromUrl = (params?.workflowId as string) || activeWorkflowId || ''
// Central dependsOn gating for this selector instance
const { finalDisabled, dependsOn } = useDependsOnGate(blockId, subBlock, {
const { finalDisabled } = useDependsOnGate(blockId, subBlock, {
disabled,
isPreview,
previewContextValues,
})
// Helper to coerce various preview value shapes into a string ID
const coerceToIdString = (val: unknown): string => {
if (!val) return ''
if (typeof val === 'string') return val
if (typeof val === 'number') return String(val)
if (typeof val === 'object') {
const obj = val as Record<string, any>
return (obj.id ||
obj.fileId ||
obj.value ||
obj.documentId ||
obj.spreadsheetId ||
'') as string
}
return ''
}
// Use the proper hook to get the current value and setter
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
const [connectedCredentialFromStore] = useSubBlockValue(blockId, 'credential')
const [domainValueFromStore] = useSubBlockValue(blockId, 'domain')
const [projectIdValueFromStore] = useSubBlockValue(blockId, 'projectId')
const [planIdValueFromStore] = useSubBlockValue(blockId, 'planId')
const [teamIdValueFromStore] = useSubBlockValue(blockId, 'teamId')
const [operationValueFromStore] = useSubBlockValue(blockId, 'operation')
// Use previewContextValues if provided (for tools inside agent blocks), otherwise use store values
const connectedCredential = previewContextValues?.credential ?? connectedCredentialFromStore
const domainValue = previewContextValues?.domain ?? domainValueFromStore
const projectIdValue = previewContextValues?.projectId ?? projectIdValueFromStore
const planIdValue = previewContextValues?.planId ?? planIdValueFromStore
const teamIdValue = previewContextValues?.teamId ?? teamIdValueFromStore
const operationValue = previewContextValues?.operation ?? operationValueFromStore
// Determine if the persisted credential belongs to the current viewer
// Use service providerId where available (e.g., onedrive/sharepoint) instead of base provider ("microsoft")
const foreignCheckProvider = subBlock.serviceId
? getProviderIdFromServiceId(subBlock.serviceId)
: (subBlock.provider as string) || ''
const normalizedCredentialId = coerceToIdString(connectedCredential)
const providerForForeignCheck = foreignCheckProvider || (subBlock.provider as string) || undefined
const normalizedCredentialId =
typeof connectedCredential === 'string'
? connectedCredential
: typeof connectedCredential === 'object' && connectedCredential !== null
? ((connectedCredential as Record<string, any>).id ?? '')
: ''
const { isForeignCredential } = useForeignCredential(
providerForForeignCheck,
subBlock.serviceId || subBlock.provider,
normalizedCredentialId
)
// Get provider-specific values
const provider = subBlock.provider || 'google-drive'
const isConfluence = provider === 'confluence'
const isJira = provider === 'jira'
const isMicrosoftTeams = provider === 'microsoft-teams'
const isMicrosoftExcel = provider === 'microsoft-excel'
const isMicrosoftWord = provider === 'microsoft-word'
const isMicrosoftOneDrive = provider === 'microsoft' && subBlock.serviceId === 'onedrive'
const isGoogleCalendar = subBlock.provider === 'google-calendar'
const isWealthbox = provider === 'wealthbox'
const isMicrosoftSharePoint = provider === 'microsoft' && subBlock.serviceId === 'sharepoint'
const isMicrosoftPlanner = provider === 'microsoft-planner'
const selectorResolution = useMemo<SelectorResolution | null>(() => {
return resolveSelectorForSubBlock(subBlock, {
workflowId: workflowIdFromUrl,
credentialId: normalizedCredentialId,
domain: (domainValue as string) || undefined,
projectId: (projectIdValue as string) || undefined,
planId: (planIdValue as string) || undefined,
teamId: (teamIdValue as string) || undefined,
})
}, [
subBlock,
workflowIdFromUrl,
normalizedCredentialId,
domainValue,
projectIdValue,
planIdValue,
teamIdValue,
])
// For Confluence and Jira, we need the domain and credentials
const domain =
isConfluence || isJira
? (isPreview && previewContextValues?.domain?.value) || (domainValue as string) || ''
: ''
const jiraCredential = isJira
? (isPreview && previewContextValues?.credential?.value) ||
(connectedCredential as string) ||
''
: ''
const missingCredential = !normalizedCredentialId
const missingDomain =
selectorResolution?.key &&
(selectorResolution.key === 'confluence.pages' || selectorResolution.key === 'jira.issues') &&
!selectorResolution.context.domain
const missingProject =
selectorResolution?.key === 'jira.issues' &&
subBlock.dependsOn?.includes('projectId') &&
!selectorResolution.context.projectId
const missingPlan =
selectorResolution?.key === 'microsoft.planner' && !selectorResolution.context.planId
// Discord channel selector removed; no special values used here
// Use preview value when in preview mode, otherwise use store value
const value = isPreview ? previewValue : storeValue
const credentialDependencySatisfied = (() => {
if (!dependsOn.includes('credential')) return true
if (!normalizedCredentialId || normalizedCredentialId.trim().length === 0) {
return false
}
if (isForeignCredential) {
return false
}
return true
})()
const shouldForceDisable = !credentialDependencySatisfied
// For Google Drive
const clientId = getEnv('NEXT_PUBLIC_GOOGLE_CLIENT_ID') || ''
const apiKey = getEnv('NEXT_PUBLIC_GOOGLE_API_KEY') || ''
// Render Google Calendar selector
if (isGoogleCalendar) {
const credential = (connectedCredential as string) || ''
const disabledReason =
finalDisabled ||
isForeignCredential ||
missingCredential ||
missingDomain ||
missingProject ||
missingPlan ||
!selectorResolution?.key
if (!selectorResolution?.key) {
return (
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='w-full'>
<GoogleCalendarSelector
value={
(isPreview && previewValue !== undefined
? (previewValue as string)
: (storeValue as string)) || ''
}
onChange={(val: string) => {
collaborativeSetSubblockValue(blockId, subBlock.id, val)
}}
label={subBlock.placeholder || 'Select Google Calendar'}
disabled={finalDisabled || shouldForceDisable}
showPreview={true}
credentialId={credential}
workflowId={workflowIdFromUrl}
/>
</div>
</Tooltip.Trigger>
</Tooltip.Root>
</Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='w-full rounded border border-dashed p-4 text-center text-muted-foreground text-sm'>
File selector not supported for provider: {subBlock.provider || subBlock.serviceId}
</div>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>This file selector is not implemented for {subBlock.provider || subBlock.serviceId}</p>
</Tooltip.Content>
</Tooltip.Root>
)
}
// Render the appropriate picker based on provider
if (isConfluence) {
const credential = (connectedCredential as string) || ''
return (
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='w-full'>
<ConfluenceFileSelector
value={
(isPreview && previewValue !== undefined
? (previewValue as string)
: (storeValue as string)) || ''
}
onChange={(val) => {
collaborativeSetSubblockValue(blockId, subBlock.id, val)
}}
domain={domain}
provider='confluence'
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select Confluence page'}
disabled={finalDisabled || shouldForceDisable}
showPreview={true}
credentialId={credential}
workflowId={workflowIdFromUrl}
isForeignCredential={isForeignCredential}
/>
</div>
</Tooltip.Trigger>
</Tooltip.Root>
</Tooltip.Provider>
)
}
if (isJira) {
const credential = (connectedCredential as string) || ''
return (
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='w-full'>
<JiraIssueSelector
value={
(isPreview && previewValue !== undefined
? (previewValue as string)
: (storeValue as string)) || ''
}
onChange={(issueKey) => {
collaborativeSetSubblockValue(blockId, subBlock.id, issueKey)
}}
domain={domain}
provider='jira'
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select Jira issue'}
disabled={finalDisabled || shouldForceDisable}
showPreview={true}
credentialId={credential}
projectId={(projectIdValue as string) || ''}
isForeignCredential={isForeignCredential}
workflowId={activeWorkflowId || ''}
/>
</div>
</Tooltip.Trigger>
</Tooltip.Root>
</Tooltip.Provider>
)
}
if (isMicrosoftExcel) {
const credential = (connectedCredential as string) || ''
return (
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='w-full'>
<MicrosoftFileSelector
value={coerceToIdString(
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
)}
onChange={(fileId) => setStoreValue(fileId)}
provider='microsoft-excel'
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select Microsoft Excel file'}
disabled={finalDisabled || shouldForceDisable}
showPreview={true}
workflowId={activeWorkflowId || ''}
credentialId={credential}
isForeignCredential={isForeignCredential}
/>
</div>
</Tooltip.Trigger>
</Tooltip.Root>
</Tooltip.Provider>
)
}
// Microsoft Word selector
if (isMicrosoftWord) {
const credential = (connectedCredential as string) || ''
return (
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='w-full'>
<MicrosoftFileSelector
value={coerceToIdString(
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
)}
onChange={(fileId) => setStoreValue(fileId)}
provider='microsoft-word'
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select Microsoft Word document'}
disabled={finalDisabled || shouldForceDisable}
showPreview={true}
/>
</div>
</Tooltip.Trigger>
</Tooltip.Root>
</Tooltip.Provider>
)
}
// Microsoft OneDrive selector
if (isMicrosoftOneDrive) {
const credential = (connectedCredential as string) || ''
return (
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='w-full'>
<MicrosoftFileSelector
value={coerceToIdString(
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
)}
onChange={(fileId) => setStoreValue(fileId)}
provider='microsoft'
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
mimeType={subBlock.mimeType}
label={subBlock.placeholder || 'Select OneDrive folder'}
disabled={finalDisabled || shouldForceDisable}
showPreview={true}
workflowId={activeWorkflowId || ''}
credentialId={credential}
isForeignCredential={isForeignCredential}
/>
</div>
</Tooltip.Trigger>
</Tooltip.Root>
</Tooltip.Provider>
)
}
// Microsoft SharePoint selector
if (isMicrosoftSharePoint) {
const credential = (connectedCredential as string) || ''
return (
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='w-full'>
<MicrosoftFileSelector
value={coerceToIdString(
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
)}
onChange={(fileId) => setStoreValue(fileId)}
provider='microsoft'
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select SharePoint site'}
disabled={finalDisabled || shouldForceDisable}
showPreview={true}
workflowId={activeWorkflowId || ''}
credentialId={credential}
isForeignCredential={isForeignCredential}
/>
</div>
</Tooltip.Trigger>
{!credential && (
<Tooltip.Content side='top'>
<p>Please select SharePoint credentials first</p>
</Tooltip.Content>
)}
</Tooltip.Root>
</Tooltip.Provider>
)
}
// Microsoft Planner task selector
if (isMicrosoftPlanner) {
const credential = (connectedCredential as string) || ''
const planId = (planIdValue as string) || ''
return (
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='w-full'>
<MicrosoftFileSelector
value={coerceToIdString(
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
)}
onChange={(fileId) => setStoreValue(fileId)}
provider='microsoft-planner'
requiredScopes={subBlock.requiredScopes || []}
serviceId='microsoft-planner'
label={subBlock.placeholder || 'Select task'}
disabled={finalDisabled || shouldForceDisable}
showPreview={true}
planId={planId}
workflowId={activeWorkflowId || ''}
credentialId={credential}
isForeignCredential={isForeignCredential}
/>
</div>
</Tooltip.Trigger>
{!credential ? (
<Tooltip.Content side='top'>
<p>Please select Microsoft Planner credentials first</p>
</Tooltip.Content>
) : !planId ? (
<Tooltip.Content side='top'>
<p>Please enter a Plan ID first</p>
</Tooltip.Content>
) : null}
</Tooltip.Root>
</Tooltip.Provider>
)
}
// Microsoft Teams selector
if (isMicrosoftTeams) {
const credential = (connectedCredential as string) || ''
// Determine the selector type based on the subBlock ID / operation
let selectionType: 'team' | 'channel' | 'chat' = 'team'
if (subBlock.id === 'teamId') selectionType = 'team'
else if (subBlock.id === 'channelId') selectionType = 'channel'
else if (subBlock.id === 'chatId') selectionType = 'chat'
else {
const operation = (operationValue as string) || ''
if (operation.includes('chat')) selectionType = 'chat'
else if (operation.includes('channel')) selectionType = 'channel'
}
const selectedTeamId = (teamIdValue as string) || ''
return (
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='w-full'>
<TeamsMessageSelector
value={
(isPreview && previewValue !== undefined
? (previewValue as string)
: (storeValue as string)) || ''
}
onChange={(val) => {
collaborativeSetSubblockValue(blockId, subBlock.id, val)
}}
provider='microsoft-teams'
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select Teams message location'}
disabled={finalDisabled || shouldForceDisable}
showPreview={true}
credential={credential}
selectionType={selectionType}
initialTeamId={selectedTeamId}
workflowId={activeWorkflowId || ''}
isForeignCredential={isForeignCredential}
/>
</div>
</Tooltip.Trigger>
{!credential && (
<Tooltip.Content side='top'>
<p>Please select Microsoft Teams credentials first</p>
</Tooltip.Content>
)}
</Tooltip.Root>
</Tooltip.Provider>
)
}
// Wealthbox selector
if (isWealthbox) {
const credential = (connectedCredential as string) || ''
if (subBlock.id === 'contactId') {
const itemType = 'contact'
return (
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='w-full'>
<WealthboxFileSelector
value={
(isPreview && previewValue !== undefined
? (previewValue as string)
: (storeValue as string)) || ''
}
onChange={(val) => {
collaborativeSetSubblockValue(blockId, subBlock.id, val)
}}
provider='wealthbox'
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || `Select ${itemType}`}
disabled={finalDisabled || shouldForceDisable}
showPreview={true}
credentialId={credential}
itemType={itemType}
/>
</div>
</Tooltip.Trigger>
{!credential && (
<Tooltip.Content side='top'>
<p>Please select Wealthbox credentials first</p>
</Tooltip.Content>
)}
</Tooltip.Root>
</Tooltip.Provider>
)
}
// noteId or taskId now use short-input
return null
}
// Default to Google Drive picker
{
const credential = ((isPreview && previewContextValues?.credential?.value) ||
(connectedCredential as string) ||
'') as string
return (
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='w-full'>
<GoogleDrivePicker
value={coerceToIdString(
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
)}
onChange={(val) => {
collaborativeSetSubblockValue(blockId, subBlock.id, val)
}}
provider={provider}
requiredScopes={subBlock.requiredScopes || []}
label={subBlock.placeholder || 'Select file'}
disabled={finalDisabled || shouldForceDisable}
serviceId={subBlock.serviceId}
mimeTypeFilter={subBlock.mimeType}
showPreview={true}
clientId={clientId}
apiKey={apiKey}
credentialId={credential}
workflowId={workflowIdFromUrl}
/>
</div>
</Tooltip.Trigger>
{!credential && (
<Tooltip.Content side='top'>
<p>Please select Google Drive credentials first</p>
</Tooltip.Content>
)}
</Tooltip.Root>
</Tooltip.Provider>
)
}
return (
<SelectorCombobox
blockId={blockId}
subBlock={subBlock}
selectorKey={selectorResolution.key}
selectorContext={selectorResolution.context}
disabled={disabledReason}
isPreview={isPreview}
previewValue={previewValue ?? null}
placeholder={subBlock.placeholder || 'Select resource'}
allowSearch={selectorResolution.allowSearch}
onOptionChange={(value) => {
if (!isPreview) {
collaborativeSetSubblockValue(blockId, subBlock.id, value)
}
}}
/>
)
}

View File

@@ -1,20 +1,9 @@
'use client'
import { useRef, useState } from 'react'
import { ChevronDown, X } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { X } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui'
import { Button } from '@/components/ui/button'
import { Button, Combobox } from '@/components/emcn/components'
import { Progress } from '@/components/ui/progress'
import { createLogger } from '@/lib/logs/console/logger'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
@@ -59,31 +48,24 @@ export function FileUpload({
previewValue,
disabled = false,
}: FileUploadProps) {
// State management - handle both single file and array of files
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId)
const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([])
const [uploadProgress, setUploadProgress] = useState(0)
const [workspaceFiles, setWorkspaceFiles] = useState<WorkspaceFileRecord[]>([])
const [loadingWorkspaceFiles, setLoadingWorkspaceFiles] = useState(false)
const [uploadError, setUploadError] = useState<string | null>(null)
const [addMoreOpen, setAddMoreOpen] = useState(false)
const [pickerOpen, setPickerOpen] = useState(false)
const [inputValue, setInputValue] = useState('')
// For file deletion status
const [deletingFiles, setDeletingFiles] = useState<Record<string, boolean>>({})
// Refs
const fileInputRef = useRef<HTMLInputElement>(null)
// Stores
const { activeWorkflowId } = useWorkflowRegistry()
const params = useParams()
const workspaceId = params?.workspaceId as string
// Use preview value when in preview mode, otherwise use store value
const value = isPreview ? previewValue : storeValue
// Load workspace files function
const loadWorkspaceFiles = async () => {
if (!workspaceId || isPreview) return
@@ -102,10 +84,8 @@ export function FileUpload({
}
}
// Filter out already selected files
const availableWorkspaceFiles = workspaceFiles.filter((workspaceFile) => {
const existingFiles = Array.isArray(value) ? value : value ? [value] : []
// Check if this workspace file is already added (match by name or key)
return !existingFiles.some(
(existing) =>
existing.name === workspaceFile.name ||
@@ -114,9 +94,12 @@ export function FileUpload({
)
})
useEffect(() => {
void loadWorkspaceFiles()
}, [workspaceId])
/**
* Opens file dialog
* Prevents event propagation to avoid ReactFlow capturing the event
*/
const handleOpenFileDialog = (e: React.MouseEvent) => {
e.preventDefault()
@@ -159,18 +142,15 @@ export function FileUpload({
const files = e.target.files
if (!files || files.length === 0) return
// Get existing files and their total size
const existingFiles = Array.isArray(value) ? value : value ? [value] : []
const existingTotalSize = existingFiles.reduce((sum, file) => sum + file.size, 0)
// Validate file sizes
const maxSizeInBytes = maxSize * 1024 * 1024
const validFiles: File[] = []
let totalNewSize = 0
for (let i = 0; i < files.length; i++) {
const file = files[i]
// Check if adding this file would exceed the total limit
if (existingTotalSize + totalNewSize + file.size > maxSizeInBytes) {
logger.error(
`Adding ${file.name} would exceed the maximum size limit of ${maxSize}MB`,
@@ -184,7 +164,6 @@ export function FileUpload({
if (validFiles.length === 0) return
// Create placeholder uploading files - ensure unique IDs
const uploading = validFiles.map((file) => ({
id: `upload-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
name: file.name,
@@ -194,13 +173,11 @@ export function FileUpload({
setUploadingFiles(uploading)
setUploadProgress(0)
// Track progress simulation interval
let progressInterval: NodeJS.Timeout | null = null
try {
setUploadError(null) // Clear previous errors
setUploadError(null)
// Simulate upload progress
progressInterval = setInterval(() => {
setUploadProgress((prev) => {
const newProgress = prev + Math.random() * 10
@@ -211,20 +188,16 @@ export function FileUpload({
const uploadedFiles: UploadedFile[] = []
const uploadErrors: string[] = []
// Upload each file via server (workspace files need DB records)
for (const file of validFiles) {
try {
// Create FormData for upload
const formData = new FormData()
formData.append('file', file)
formData.append('context', 'workspace')
// Add workspace ID for workspace-scoped storage
if (workspaceId) {
formData.append('workspaceId', workspaceId)
}
// Upload the file via server
const response = await fetch('/api/files/upload', {
method: 'POST',
body: formData,
@@ -232,37 +205,30 @@ export function FileUpload({
const data = await response.json()
// Handle error response
if (!response.ok) {
const errorMessage = data.error || `Failed to upload file: ${response.status}`
uploadErrors.push(`${file.name}: ${errorMessage}`)
// Set error message with conditional auto-dismiss
setUploadError(errorMessage)
// Only auto-dismiss duplicate errors, keep other errors (like storage limits) visible
if (data.isDuplicate || response.status === 409) {
setTimeout(() => setUploadError(null), 5000)
}
continue
}
// Check if response has error even with 200 status
if (data.success === false) {
const errorMessage = data.error || 'Upload failed'
uploadErrors.push(`${file.name}: ${errorMessage}`)
// Set error message with conditional auto-dismiss
setUploadError(errorMessage)
// Only auto-dismiss duplicate errors, keep other errors (like storage limits) visible
if (data.isDuplicate) {
setTimeout(() => setUploadError(null), 5000)
}
continue
}
// Process successful upload - handle both workspace and regular uploads
uploadedFiles.push({
name: file.name,
path: data.file?.url || data.url, // Workspace: data.file.url, Non-workspace: data.url
@@ -277,7 +243,6 @@ export function FileUpload({
}
}
// Clear progress interval
if (progressInterval) {
clearInterval(progressInterval)
progressInterval = null
@@ -285,11 +250,9 @@ export function FileUpload({
setUploadProgress(100)
// Send consolidated notification about uploaded files
if (uploadedFiles.length > 0) {
setUploadError(null) // Clear error on successful upload
setUploadError(null)
// Refresh workspace files list to keep dropdown up to date
if (workspaceId) {
void loadWorkspaceFiles()
}
@@ -304,7 +267,6 @@ export function FileUpload({
}
}
// Send consolidated error notification if any
if (uploadErrors.length > 0) {
if (uploadErrors.length === 1) {
logger.error(uploadErrors[0], activeWorkflowId)
@@ -316,30 +278,23 @@ export function FileUpload({
}
}
// Update the file value in state based on multiple setting
if (multiple) {
// For multiple files: Append to existing files if any
const existingFiles = Array.isArray(value) ? value : value ? [value] : []
// Create a map to identify duplicates by url
const uniqueFiles = new Map()
// Add existing files to the map
existingFiles.forEach((file) => {
uniqueFiles.set(file.url || file.path, file) // Use url, fallback to path for backward compatibility
uniqueFiles.set(file.url || file.path, file)
})
// Add new files to the map (will overwrite if same path)
uploadedFiles.forEach((file) => {
uniqueFiles.set(file.path, file)
})
// Convert map values back to array
const newFiles = Array.from(uniqueFiles.values())
setStoreValue(newFiles)
useWorkflowStore.getState().triggerUpdate()
} else {
// For single file: Replace with last uploaded file
setStoreValue(uploadedFiles[0] || null)
useWorkflowStore.getState().triggerUpdate()
}
@@ -349,7 +304,6 @@ export function FileUpload({
activeWorkflowId
)
} finally {
// Clean up and reset upload state
if (progressInterval) {
clearInterval(progressInterval)
}
@@ -368,8 +322,6 @@ export function FileUpload({
const selectedFile = workspaceFiles.find((f) => f.id === fileId)
if (!selectedFile) return
// Convert workspace file record to uploaded file format
// Path will be converted to presigned URL during execution if needed
const uploadedFile: UploadedFile = {
name: selectedFile.name,
path: selectedFile.path,
@@ -378,7 +330,6 @@ export function FileUpload({
}
if (multiple) {
// For multiple files: Append to existing
const existingFiles = Array.isArray(value) ? value : value ? [value] : []
const uniqueFiles = new Map()
@@ -391,7 +342,6 @@ export function FileUpload({
setStoreValue(newFiles)
} else {
// For single file: Replace
setStoreValue(uploadedFile)
}
@@ -408,19 +358,15 @@ export function FileUpload({
e.stopPropagation()
}
// Mark this file as being deleted
setDeletingFiles((prev) => ({ ...prev, [file.path || '']: true }))
try {
// Check if this is a workspace file (decoded path contains workspaceId pattern)
const decodedPath = file.path ? decodeURIComponent(file.path) : ''
const isWorkspaceFile =
workspaceId &&
(decodedPath.includes(`/${workspaceId}/`) || decodedPath.includes(`${workspaceId}/`))
if (!isWorkspaceFile) {
// Only delete from storage if it's NOT a workspace file
// Workspace files are permanent and managed through Settings
const response = await fetch('/api/files/delete', {
method: 'POST',
headers: {
@@ -436,14 +382,11 @@ export function FileUpload({
}
}
// Update the UI state (remove from selection)
if (multiple) {
// For multiple files: Remove the specific file
const filesArray = Array.isArray(value) ? value : value ? [value] : []
const updatedFiles = filesArray.filter((f) => f.path !== file.path)
setStoreValue(updatedFiles.length > 0 ? updatedFiles : null)
} else {
// For single file: Clear the value
setStoreValue(null)
}
@@ -454,7 +397,6 @@ export function FileUpload({
activeWorkflowId
)
} finally {
// Remove file from the deleting state
setDeletingFiles((prev) => {
const updated = { ...prev }
delete updated[file.path || '']
@@ -463,80 +405,6 @@ export function FileUpload({
}
}
/**
* Handles deletion of all files (for multiple mode)
*/
const handleRemoveAllFiles = async (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (!value) return
const filesToDelete = Array.isArray(value) ? value : [value]
// Mark all files as deleting
const deletingStatus: Record<string, boolean> = {}
filesToDelete.forEach((file) => {
deletingStatus[file.path || ''] = true
})
setDeletingFiles(deletingStatus)
// Clear input state immediately for better UX
setStoreValue(null)
useWorkflowStore.getState().triggerUpdate()
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
// Track successful and failed deletions
const deletionResults = {
success: 0,
failures: [] as string[],
}
// Delete each file
for (const file of filesToDelete) {
try {
const response = await fetch('/api/files/delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ filePath: file.path }),
})
if (response.ok) {
deletionResults.success++
} else {
const errorData = await response.json().catch(() => ({ error: response.statusText }))
const errorMessage = errorData.error || `Failed to delete file: ${response.status}`
deletionResults.failures.push(`${file.name}: ${errorMessage}`)
}
} catch (error) {
logger.error(`Failed to delete file ${file.name}:`, error)
deletionResults.failures.push(
`${file.name}: ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
}
// Show error notification if any deletions failed
if (deletionResults.failures.length > 0) {
if (deletionResults.failures.length === 1) {
logger.error(`Failed to delete file: ${deletionResults.failures[0]}`, activeWorkflowId)
} else {
logger.error(
`Failed to delete ${deletionResults.failures.length} files: ${deletionResults.failures.join('; ')}`,
activeWorkflowId
)
}
}
setDeletingFiles({})
}
// Helper to render a single file item
const renderFileItem = (file: UploadedFile) => {
const fileKey = file.path || ''
const isDeleting = deletingFiles[fileKey]
@@ -544,19 +412,16 @@ export function FileUpload({
return (
<div
key={fileKey}
className='flex items-center justify-between rounded border border-border bg-background px-3 py-2'
className='flex items-center justify-between rounded-[4px] border border-[var(--surface-11)] bg-[var(--surface-6)] px-[8px] py-[6px] hover:border-[var(--surface-14)] hover:bg-[var(--surface-9)] dark:bg-[var(--surface-9)] dark:hover:bg-[var(--surface-11)]'
>
<div className='flex-1 truncate pr-2'>
<div className='truncate font-normal text-sm' title={file.name}>
{truncateMiddle(file.name)}
</div>
<div className='text-muted-foreground text-xs'>{formatFileSize(file.size)}</div>
<div className='flex-1 truncate pr-2 text-sm' title={file.name}>
<span className='text-[var(--text-primary)]'>{truncateMiddle(file.name)}</span>
<span className='ml-2 text-[var(--text-muted)]'>({formatFileSize(file.size)})</span>
</div>
<Button
type='button'
variant='ghost'
size='icon'
className='h-8 w-8 shrink-0'
className='h-6 w-6 shrink-0 p-0'
onClick={(e) => handleRemoveFile(file, e)}
disabled={isDeleting}
>
@@ -570,16 +435,15 @@ export function FileUpload({
)
}
// Render a placeholder item for files being uploaded
const renderUploadingItem = (file: UploadingFile) => {
return (
<div
key={file.id}
className='flex items-center justify-between rounded border border-border bg-background px-3 py-2'
className='flex items-center justify-between rounded-[4px] border border-[var(--surface-11)] bg-[var(--surface-6)] px-[8px] py-[6px] dark:bg-[var(--surface-9)]'
>
<div className='flex-1 truncate pr-2'>
<div className='truncate font-normal text-sm'>{file.name}</div>
<div className='text-muted-foreground text-xs'>{formatFileSize(file.size)}</div>
<div className='flex-1 truncate pr-2 text-sm'>
<span className='text-[var(--text-primary)]'>{file.name}</span>
<span className='ml-2 text-[var(--text-muted)]'>({formatFileSize(file.size)})</span>
</div>
<div className='flex h-8 w-8 shrink-0 items-center justify-center'>
<div className='h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
@@ -588,11 +452,43 @@ export function FileUpload({
)
}
// Get files array regardless of multiple setting
const filesArray = Array.isArray(value) ? value : value ? [value] : []
const hasFiles = filesArray.length > 0
const isUploading = uploadingFiles.length > 0
const comboboxOptions = useMemo(
() => [
{ label: 'Upload New File', value: '__upload_new__' },
...availableWorkspaceFiles.map((file) => ({
label: file.name,
value: file.id,
})),
],
[availableWorkspaceFiles]
)
const handleComboboxChange = (value: string) => {
setInputValue(value)
const isValidOption =
value === '__upload_new__' || availableWorkspaceFiles.some((file) => file.id === value)
if (!isValidOption) {
return
}
setInputValue('')
if (value === '__upload_new__') {
handleOpenFileDialog({
preventDefault: () => {},
stopPropagation: () => {},
} as React.MouseEvent)
} else {
handleSelectWorkspaceFile(value)
}
}
return (
<div className='w-full' onClick={(e) => e.stopPropagation()}>
<input
@@ -614,7 +510,6 @@ export function FileUpload({
<div className='mb-2 space-y-2'>
{/* Only show files that aren't currently uploading */}
{filesArray.map((file) => {
// Don't show files that have duplicates in the uploading list
const isCurrentlyUploading = uploadingFiles.some(
(uploadingFile) => uploadingFile.name === file.name
)
@@ -641,73 +536,19 @@ export function FileUpload({
{/* Add More dropdown for multiple files */}
{hasFiles && multiple && !isUploading && (
<div>
<Popover
open={addMoreOpen}
<Combobox
options={comboboxOptions}
value={inputValue}
onChange={handleComboboxChange}
onOpenChange={(open) => {
setAddMoreOpen(open)
if (open) void loadWorkspaceFiles()
}}
>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={addMoreOpen}
className='relative w-full justify-between'
disabled={disabled || loadingWorkspaceFiles}
>
<span className='truncate font-normal'>+ Add More</span>
<ChevronDown className='absolute right-3 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[320px] p-0' align='start'>
<Command>
<CommandInput
placeholder='Search files...'
className='text-foreground placeholder:text-muted-foreground'
/>
<CommandList onWheel={(e) => e.stopPropagation()}>
<CommandGroup>
<CommandItem
value='upload_new'
onSelect={() => {
setAddMoreOpen(false)
handleOpenFileDialog({
preventDefault: () => {},
stopPropagation: () => {},
} as React.MouseEvent)
}}
>
Upload New File
</CommandItem>
</CommandGroup>
<CommandEmpty>
{availableWorkspaceFiles.length === 0
? 'No files available.'
: 'No files found.'}
</CommandEmpty>
{availableWorkspaceFiles.length > 0 && (
<CommandGroup heading='Workspace Files'>
{availableWorkspaceFiles.map((file) => (
<CommandItem
key={file.id}
value={file.name}
onSelect={() => {
handleSelectWorkspaceFile(file.id)
setAddMoreOpen(false)
}}
>
<span className='truncate' title={file.name}>
{truncateMiddle(file.name)}
</span>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
placeholder={loadingWorkspaceFiles ? 'Loading files...' : '+ Add More'}
disabled={disabled || loadingWorkspaceFiles}
editable={true}
filterOptions={true}
isLoading={loadingWorkspaceFiles}
/>
</div>
)}
</div>
@@ -715,75 +556,19 @@ export function FileUpload({
{/* Show dropdown selector if no files and not uploading */}
{!hasFiles && !isUploading && (
<div className='flex items-center'>
<Popover
open={pickerOpen}
<Combobox
options={comboboxOptions}
value={inputValue}
onChange={handleComboboxChange}
onOpenChange={(open) => {
setPickerOpen(open)
if (open) void loadWorkspaceFiles()
}}
>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={pickerOpen}
className='relative w-full justify-between'
disabled={disabled || loadingWorkspaceFiles}
>
<span className='truncate font-normal'>
{loadingWorkspaceFiles ? 'Loading files...' : 'Select or upload file'}
</span>
<ChevronDown className='absolute right-3 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[320px] p-0' align='start'>
<Command>
<CommandInput
placeholder='Search files...'
className='text-foreground placeholder:text-muted-foreground'
/>
<CommandList onWheel={(e) => e.stopPropagation()}>
<CommandGroup>
<CommandItem
value='upload_new'
onSelect={() => {
setPickerOpen(false)
handleOpenFileDialog({
preventDefault: () => {},
stopPropagation: () => {},
} as React.MouseEvent)
}}
>
Upload New File
</CommandItem>
</CommandGroup>
<CommandEmpty>
{availableWorkspaceFiles.length === 0
? 'No files available.'
: 'No files found.'}
</CommandEmpty>
{availableWorkspaceFiles.length > 0 && (
<CommandGroup heading='Workspace Files'>
{availableWorkspaceFiles.map((file) => (
<CommandItem
key={file.id}
value={file.name}
onSelect={() => {
handleSelectWorkspaceFile(file.id)
setPickerOpen(false)
}}
>
<span className='truncate' title={file.name}>
{truncateMiddle(file.name)}
</span>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
placeholder={loadingWorkspaceFiles ? 'Loading files...' : 'Select or upload file'}
disabled={disabled || loadingWorkspaceFiles}
editable={true}
filterOptions={true}
isLoading={loadingWorkspaceFiles}
/>
</div>
)}
</div>

View File

@@ -1,14 +1,12 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import {
type FolderInfo,
FolderSelector,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/folder-selector/folder-selector'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-foreign-credential'
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 { resolveSelectorForSubBlock } from '@/hooks/selectors/resolution'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -27,19 +25,19 @@ export function FolderSelectorInput({
isPreview = false,
previewValue,
}: FolderSelectorInputProps) {
const [storeValue, _setStoreValue] = useSubBlockValue(blockId, subBlock.id)
const [storeValue] = useSubBlockValue(blockId, subBlock.id)
const [connectedCredential] = useSubBlockValue(blockId, 'credential')
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
const { activeWorkflowId } = useWorkflowRegistry()
const [selectedFolderId, setSelectedFolderId] = useState<string>('')
const [_folderInfo, setFolderInfo] = useState<FolderInfo | null>(null)
const provider = (subBlock.provider || subBlock.serviceId || 'google-email').toLowerCase()
const providerKey = (subBlock.provider ?? subBlock.serviceId ?? '').toLowerCase()
const credentialProvider = subBlock.serviceId ?? subBlock.provider
const isCopyDestinationSelector =
subBlock.canonicalParamId === 'copyDestinationId' ||
subBlock.id === 'copyDestinationFolder' ||
subBlock.id === 'manualCopyDestinationFolder'
const { isForeignCredential } = useForeignCredential(
subBlock.provider || subBlock.serviceId || 'outlook',
credentialProvider,
(connectedCredential as string) || ''
)
@@ -48,26 +46,22 @@ export function FolderSelectorInput({
// Get the current value from the store or prop value if in preview mode
useEffect(() => {
// When gated/disabled, do not set defaults or write to store
if (finalDisabled) return
if (isPreview && previewValue !== undefined) {
setSelectedFolderId(previewValue)
return
}
const current = storeValue as string | undefined
if (current && typeof current === 'string') {
if (current) {
setSelectedFolderId(current)
return
}
const shouldDefaultInbox = provider !== 'outlook' && !isCopyDestinationSelector
const shouldDefaultInbox = providerKey === 'gmail' && !isCopyDestinationSelector
if (shouldDefaultInbox) {
const defaultValue = 'INBOX'
setSelectedFolderId(defaultValue)
setSelectedFolderId('INBOX')
if (!isPreview) {
collaborativeSetSubblockValue(blockId, subBlock.id, defaultValue)
collaborativeSetSubblockValue(blockId, subBlock.id, 'INBOX')
}
} else {
setSelectedFolderId('')
}
}, [
blockId,
@@ -77,33 +71,46 @@ export function FolderSelectorInput({
isPreview,
previewValue,
finalDisabled,
providerKey,
isCopyDestinationSelector,
])
// Handle folder selection
const handleFolderChange = useCallback(
(folderId: string, info?: FolderInfo) => {
setSelectedFolderId(folderId)
setFolderInfo(info || null)
const credentialId = (connectedCredential as string) || ''
const missingCredential = credentialId.length === 0
const selectorResolution = useMemo(
() =>
resolveSelectorForSubBlock(subBlock, {
credentialId: credentialId || undefined,
workflowId: activeWorkflowId || undefined,
}),
[subBlock, credentialId, activeWorkflowId]
)
const handleChange = useCallback(
(value: string) => {
setSelectedFolderId(value)
if (!isPreview) {
collaborativeSetSubblockValue(blockId, subBlock.id, folderId)
collaborativeSetSubblockValue(blockId, subBlock.id, value)
}
},
[blockId, subBlock.id, collaborativeSetSubblockValue, isPreview]
)
return (
<FolderSelector
value={selectedFolderId}
onChange={handleFolderChange}
provider={provider}
requiredScopes={subBlock.requiredScopes || []}
label={subBlock.placeholder || 'Select folder'}
disabled={finalDisabled}
serviceId={subBlock.serviceId}
onFolderInfoChange={setFolderInfo}
credentialId={(connectedCredential as string) || ''}
workflowId={activeWorkflowId || ''}
isForeignCredential={isForeignCredential}
<SelectorCombobox
blockId={blockId}
subBlock={subBlock}
selectorKey={selectorResolution?.key ?? 'gmail.labels'}
selectorContext={
selectorResolution?.context ?? { credentialId, workflowId: activeWorkflowId || '' }
}
disabled={
finalDisabled || isForeignCredential || missingCredential || !selectorResolution?.key
}
isPreview={isPreview}
previewValue={previewValue ?? null}
placeholder={subBlock.placeholder || 'Select folder'}
onOptionChange={handleChange}
/>
)
}

View File

@@ -1,533 +0,0 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Check, ChevronDown, RefreshCw } from 'lucide-react'
import { GmailIcon, OutlookIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { createLogger } from '@/lib/logs/console/logger'
import { type Credential, getProviderIdFromServiceId, getServiceIdFromScopes } 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 { useDisplayNamesStore } from '@/stores/display-names/store'
const logger = createLogger('FolderSelector')
export interface FolderInfo {
id: string
name: string
type: string
messagesTotal?: number
messagesUnread?: number
}
interface FolderSelectorProps {
value: string
onChange: (value: string, folderInfo?: FolderInfo) => void
provider: string
requiredScopes?: string[]
label?: string
disabled?: boolean
serviceId?: string
onFolderInfoChange?: (folderInfo: FolderInfo | null) => void
isPreview?: boolean
previewValue?: any | null
credentialId?: string
workflowId?: string
isForeignCredential?: boolean
}
export function FolderSelector({
value,
onChange,
provider,
requiredScopes = [],
label = 'Select folder',
disabled = false,
serviceId,
onFolderInfoChange,
isPreview = false,
previewValue,
credentialId,
workflowId,
isForeignCredential = false,
}: FolderSelectorProps) {
const [open, setOpen] = useState(false)
const [credentials, setCredentials] = useState<Credential[]>([])
const [folders, setFolders] = useState<FolderInfo[]>([])
const [selectedCredentialId, setSelectedCredentialId] = useState<Credential['id'] | ''>(
credentialId || ''
)
const [selectedFolderId, setSelectedFolderId] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [isLoadingSelectedFolder, setIsLoadingSelectedFolder] = useState(false)
const [showOAuthModal, setShowOAuthModal] = useState(false)
const initialFetchRef = useRef(false)
// Get cached display name
const cachedFolderName = useDisplayNamesStore(
useCallback(
(state) => {
const effectiveCredentialId = credentialId || selectedCredentialId
const effectiveValue = isPreview && previewValue !== undefined ? previewValue : value
if (!effectiveCredentialId || !effectiveValue) return null
return state.cache.folders[effectiveCredentialId]?.[effectiveValue] || null
},
[credentialId, selectedCredentialId, value, isPreview, previewValue]
)
)
// Initialize selectedFolderId with the effective value
useEffect(() => {
if (isPreview && previewValue !== undefined) {
setSelectedFolderId(previewValue || '')
} else {
setSelectedFolderId(value)
}
}, [value, isPreview, previewValue])
// Keep internal credential in sync with prop
useEffect(() => {
if (credentialId && credentialId !== selectedCredentialId) {
setSelectedCredentialId(credentialId)
}
}, [credentialId, selectedCredentialId])
// Determine the appropriate service ID based on provider and scopes
const getServiceId = (): string => {
if (serviceId) return serviceId
return getServiceIdFromScopes(provider, requiredScopes)
}
// Determine the appropriate provider ID based on service and scopes
const getProviderId = (): string => {
const effectiveServiceId = getServiceId()
return getProviderIdFromServiceId(effectiveServiceId)
}
// Fetch available credentials for this provider
const fetchCredentials = useCallback(async () => {
setIsLoading(true)
try {
const providerId = getProviderId()
const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`)
if (response.ok) {
const data = await response.json()
setCredentials(data.credentials)
// Auto-select logic for credentials
if (data.credentials.length > 0) {
// If we already have a selected credential ID, check if it's valid
if (
selectedCredentialId &&
data.credentials.some((cred: Credential) => cred.id === selectedCredentialId)
) {
// Keep the current selection
} else {
// Otherwise, select the default or first credential
const defaultCred = data.credentials.find((cred: Credential) => cred.isDefault)
if (defaultCred) {
setSelectedCredentialId(defaultCred.id)
} else if (data.credentials.length === 1) {
setSelectedCredentialId(data.credentials[0].id)
}
}
}
}
} catch (error) {
logger.error('Error fetching credentials:', { error })
} finally {
setIsLoading(false)
}
}, [provider, getProviderId, selectedCredentialId])
// Fetch a single folder by ID when we have a selectedFolderId but no metadata
const fetchFolderById = useCallback(
async (folderId: string) => {
if (!selectedCredentialId || !folderId) return null
setIsLoadingSelectedFolder(true)
try {
if (provider === 'outlook') {
// Resolve Outlook folder name with owner-scoped token
const tokenRes = await fetch('/api/auth/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credentialId: selectedCredentialId, workflowId }),
})
if (!tokenRes.ok) return null
const { accessToken } = await tokenRes.json()
if (!accessToken) return null
const resp = await fetch(
`https://graph.microsoft.com/v1.0/me/mailFolders/${encodeURIComponent(folderId)}`,
{
headers: { Authorization: `Bearer ${accessToken}` },
}
)
if (!resp.ok) return null
const folder = await resp.json()
const folderInfo: FolderInfo = {
id: folder.id,
name: folder.displayName,
type: 'folder',
messagesTotal: folder.totalItemCount,
messagesUnread: folder.unreadItemCount,
}
onFolderInfoChange?.(folderInfo)
return folderInfo
}
// Gmail label resolution
const queryParams = new URLSearchParams({
credentialId: selectedCredentialId,
labelId: folderId,
})
const response = await fetch(`/api/tools/gmail/label?${queryParams.toString()}`)
if (response.ok) {
const data = await response.json()
if (data.label) {
onFolderInfoChange?.(data.label)
return data.label
}
} else {
logger.error('Error fetching folder by ID:', {
error: await response.text(),
})
}
return null
} catch (error) {
logger.error('Error fetching folder by ID:', { error })
return null
} finally {
setIsLoadingSelectedFolder(false)
}
},
[selectedCredentialId, onFolderInfoChange, provider, workflowId]
)
// Fetch folders from Gmail or Outlook
const fetchFolders = useCallback(
async (searchQuery?: string) => {
if (!selectedCredentialId) return
setIsLoading(true)
try {
// Construct query parameters
const queryParams = new URLSearchParams({
credentialId: selectedCredentialId,
})
if (searchQuery) {
queryParams.append('query', searchQuery)
}
// Determine the API endpoint based on provider
let apiEndpoint: string
if (provider === 'outlook') {
// Skip list fetch for collaborators; only show selected
if (isForeignCredential) {
setFolders([])
setIsLoading(false)
return
}
apiEndpoint = `/api/tools/outlook/folders?${queryParams.toString()}`
} else {
// Default to Gmail
apiEndpoint = `/api/tools/gmail/labels?${queryParams.toString()}`
}
const response = await fetch(apiEndpoint)
if (response.ok) {
const data = await response.json()
const folderList = provider === 'outlook' ? data.folders : data.labels
setFolders(folderList || [])
// Cache folder names in display names store
if (selectedCredentialId && folderList) {
const folderMap = folderList.reduce(
(acc: Record<string, string>, folder: FolderInfo) => {
acc[folder.id] = folder.name
return acc
},
{}
)
useDisplayNamesStore
.getState()
.setDisplayNames('folders', selectedCredentialId, folderMap)
}
// Only notify parent if callback exists
if (selectedFolderId && onFolderInfoChange) {
const folderInfo = folderList.find(
(folder: FolderInfo) => folder.id === selectedFolderId
)
if (folderInfo) {
onFolderInfoChange(folderInfo)
} else if (!searchQuery && provider !== 'outlook') {
// Only try to fetch by ID for Gmail if this is not a search query
// and we couldn't find the folder in the list
fetchFolderById(selectedFolderId)
}
}
} else {
const text = await response.text()
if (response.status === 401 || response.status === 403) {
logger.info('Folder list fetch unauthorized (expected for collaborator)')
} else {
logger.warn('Error fetching folders', { status: response.status, text })
}
setFolders([])
}
} catch (error) {
logger.error('Error fetching folders:', { error })
setFolders([])
} finally {
setIsLoading(false)
}
},
[
selectedCredentialId,
selectedFolderId,
onFolderInfoChange,
fetchFolderById,
provider,
isForeignCredential,
]
)
// Fetch credentials on initial mount
useEffect(() => {
if (disabled) return
if (!initialFetchRef.current) {
fetchCredentials()
initialFetchRef.current = true
}
}, [fetchCredentials, disabled])
// Fetch folders when credential is selected
useEffect(() => {
if (disabled) return
if (selectedCredentialId) {
fetchFolders()
}
}, [selectedCredentialId, fetchFolders, disabled])
// Keep internal selectedFolderId in sync with the value prop
useEffect(() => {
if (disabled) return
const currentValue = isPreview ? previewValue : value
if (currentValue !== selectedFolderId) {
setSelectedFolderId(currentValue || '')
}
}, [value, isPreview, previewValue, disabled, selectedFolderId])
// Handle folder selection
const handleSelectFolder = (folder: FolderInfo) => {
setSelectedFolderId(folder.id)
if (!isPreview) {
onChange(folder.id, folder)
}
onFolderInfoChange?.(folder)
setOpen(false)
}
// Handle adding a new credential
const handleAddCredential = () => {
// Show the OAuth modal
setShowOAuthModal(true)
setOpen(false)
}
const handleSearch = (value: string) => {
if (value.length > 2) {
fetchFolders(value)
} else if (value.length === 0) {
fetchFolders()
}
}
const getFolderIcon = (size: 'sm' | 'md' = 'sm') => {
const iconSize = size === 'sm' ? 'h-4 w-4' : 'h-5 w-5'
if (provider === 'gmail') {
return <GmailIcon className={iconSize} />
}
if (provider === 'outlook') {
return <OutlookIcon className={iconSize} />
}
return null
}
const getProviderName = () => {
if (provider === 'outlook') return 'Outlook'
return 'Gmail'
}
const getFolderLabel = () => {
if (provider === 'outlook') return 'folders'
return 'labels'
}
return (
<>
<div className='space-y-2'>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className='w-full justify-between'
disabled={disabled || isForeignCredential}
>
{cachedFolderName ? (
<div className='flex items-center gap-2 overflow-hidden'>
{getFolderIcon('sm')}
<span className='truncate font-normal'>{cachedFolderName}</span>
</div>
) : (
<div className='flex items-center gap-2'>
{getFolderIcon('sm')}
<span className='text-muted-foreground'>{label}</span>
</div>
)}
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
{!isForeignCredential && (
<PopoverContent className='w-[300px] p-0' align='start'>
{/* Current account indicator */}
{selectedCredentialId && credentials.length > 0 && (
<div className='flex items-center justify-between border-b px-3 py-2'>
<div className='flex items-center gap-2'>
<span className='text-muted-foreground text-xs'>
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
'Unknown'}
</span>
</div>
{credentials.length > 1 && (
<Button
variant='ghost'
size='sm'
className='h-6 px-2 text-xs'
onClick={() => setOpen(true)}
>
Switch
</Button>
)}
</div>
)}
<Command>
<CommandInput
placeholder={`Search ${getFolderLabel()}...`}
onValueChange={handleSearch}
/>
<CommandList>
<CommandEmpty>
{isLoading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading {getFolderLabel()}...</span>
</div>
) : credentials.length === 0 ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No accounts connected.</p>
<p className='text-muted-foreground text-xs'>
Connect a {getProviderName()} account to continue.
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No {getFolderLabel()} found.</p>
<p className='text-muted-foreground text-xs'>
Try a different search or account.
</p>
</div>
)}
</CommandEmpty>
{/* Account selection - only show if we have multiple accounts */}
{credentials.length > 1 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Switch Account
</div>
{credentials.map((cred) => (
<CommandItem
key={cred.id}
value={`account-${cred.id}`}
onSelect={() => setSelectedCredentialId(cred.id)}
>
<div className='flex items-center gap-2'>
<span className='font-normal'>{cred.name}</span>
</div>
{cred.id === selectedCredentialId && (
<Check className='ml-auto h-4 w-4' />
)}
</CommandItem>
))}
</CommandGroup>
)}
{/* Folders list */}
{folders.length > 0 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
{getFolderLabel().charAt(0).toUpperCase() + getFolderLabel().slice(1)}
</div>
{folders.map((folder) => (
<CommandItem
key={folder.id}
value={`folder-${folder.id}-${folder.name}`}
onSelect={() => handleSelectFolder(folder)}
>
<div className='flex w-full items-center gap-2 overflow-hidden'>
{getFolderIcon('sm')}
<span className='truncate font-normal'>{folder.name}</span>
{folder.id === selectedFolderId && (
<Check className='ml-auto h-4 w-4' />
)}
</div>
</CommandItem>
))}
</CommandGroup>
)}
{/* Connect account option - only show if no credentials */}
{credentials.length === 0 && (
<CommandGroup>
<CommandItem onSelect={handleAddCredential}>
<div className='flex items-center gap-2 text-foreground'>
<span>Connect {getProviderName()} account</span>
</div>
</CommandItem>
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
)}
</Popover>
</div>
{showOAuthModal && (
<OAuthRequiredModal
isOpen={showOAuthModal}
onClose={() => setShowOAuthModal(false)}
provider={provider}
toolName={getProviderName()}
requiredScopes={requiredScopes}
serviceId={getServiceId()}
/>
)}
</>
)
}

View File

@@ -2,6 +2,8 @@
import type { ReactNode } from 'react'
import { splitReferenceSegment } from '@/lib/workflows/references'
import { REFERENCE } from '@/executor/consts'
import { createCombinedPattern } from '@/executor/utils/reference-validation'
import { normalizeBlockName } from '@/stores/workflows/utils'
export interface HighlightContext {
@@ -43,7 +45,9 @@ export function formatDisplayText(text: string, context?: HighlightContext): Rea
}
const nodes: ReactNode[] = []
const regex = /<[^>]+>|\{\{[^}]+\}\}/g
// Match variable references without allowing nested brackets to prevent matching across references
// e.g., "<3. text <real.ref>" should match "<3" and "<real.ref>", not the whole string
const regex = createCombinedPattern()
let lastIndex = 0
let key = 0
@@ -61,7 +65,7 @@ export function formatDisplayText(text: string, context?: HighlightContext): Rea
pushPlainText(text.slice(lastIndex, index))
}
if (matchText.startsWith('{{')) {
if (matchText.startsWith(REFERENCE.ENV_VAR_START)) {
nodes.push(
<span key={key++} className='text-[#34B5FF] dark:text-[#34B5FF]'>
{matchText}

View File

@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Badge } from '@/components/emcn'
import { Input } from '@/components/emcn/components/input/input'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/utils'
@@ -50,12 +51,13 @@ interface InputMappingFieldProps {
value: string
onChange: (value: string) => void
blockId: string
subBlockId: string
disabled: boolean
accessiblePrefixes: Set<string> | undefined
inputController: ReturnType<typeof useSubBlockInput>
inputRefs: React.MutableRefObject<Map<string, HTMLInputElement>>
overlayRefs: React.MutableRefObject<Map<string, HTMLDivElement>>
inputRefs: React.RefObject<Map<string, HTMLInputElement>>
overlayRefs: React.RefObject<Map<string, HTMLDivElement>>
collapsed: boolean
onToggleCollapse: () => void
}
/**
@@ -169,6 +171,7 @@ export function InputMapping({
const [childInputFields, setChildInputFields] = useState<InputFormatField[]>([])
const [isLoading, setIsLoading] = useState(false)
const [collapsedFields, setCollapsedFields] = useState<Record<string, boolean>>({})
useEffect(() => {
let isMounted = true
@@ -245,6 +248,13 @@ export function InputMapping({
setMapping(updated)
}
const toggleCollapse = (fieldName: string) => {
setCollapsedFields((prev) => ({
...prev,
[fieldName]: !prev[fieldName],
}))
}
if (!selectedWorkflowId) {
return (
<div className='flex flex-col items-center justify-center rounded-[4px] border border-[var(--border-strong)] bg-[#1F1F1F] p-8 text-center'>
@@ -278,12 +288,13 @@ export function InputMapping({
value=''
onChange={() => {}}
blockId={blockId}
subBlockId={subBlockId}
disabled={true}
accessiblePrefixes={accessiblePrefixes}
inputController={inputController}
inputRefs={inputRefs}
overlayRefs={overlayRefs}
collapsed={false}
onToggleCollapse={() => {}}
/>
</div>
)
@@ -303,12 +314,13 @@ export function InputMapping({
value={valueObj[field.name] || ''}
onChange={(value) => handleFieldUpdate(field.name, value)}
blockId={blockId}
subBlockId={subBlockId}
disabled={isPreview || disabled}
accessiblePrefixes={accessiblePrefixes}
inputController={inputController}
inputRefs={inputRefs}
overlayRefs={overlayRefs}
collapsed={collapsedFields[field.name] || false}
onToggleCollapse={() => toggleCollapse(field.name)}
/>
))}
</div>
@@ -326,12 +338,13 @@ function InputMappingField({
value,
onChange,
blockId,
subBlockId,
disabled,
accessiblePrefixes,
inputController,
inputRefs,
overlayRefs,
collapsed,
onToggleCollapse,
}: InputMappingFieldProps) {
const fieldId = fieldName
const fieldState = inputController.fieldHelpers.getFieldState(fieldId)
@@ -354,64 +367,91 @@ function InputMappingField({
}
return (
<div className='group relative overflow-visible rounded-[4px] border border-[var(--border-strong)] bg-[#1F1F1F]'>
<div className='flex items-center justify-between bg-transparent px-[10px] py-[5px]'>
<Label className='font-medium text-[14px] text-[var(--text-tertiary)]'>{fieldName}</Label>
{fieldType && (
<span className='rounded-md bg-[#2A2A2A] px-1.5 py-0.5 font-mono text-[10px] text-[var(--text-tertiary)]'>
{fieldType}
<div
className={cn(
'rounded-[4px] border border-[var(--border-strong)] bg-[#1F1F1F]',
collapsed ? 'overflow-hidden' : 'overflow-visible'
)}
>
<div
className='flex cursor-pointer items-center justify-between bg-transparent px-[10px] py-[5px]'
onClick={onToggleCollapse}
>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
{fieldName}
</span>
)}
{fieldType && <Badge className='h-[20px] text-[13px]'>{fieldType}</Badge>}
</div>
</div>
<div className='relative w-full border-[var(--border-strong)] border-t bg-transparent'>
<Input
ref={(el) => {
if (el) inputRefs.current.set(fieldId, el)
}}
className={cn(
'allow-scroll !bg-transparent w-full overflow-auto rounded-none border-0 px-[10px] py-[8px] text-transparent caret-white [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-[var(--text-muted)] focus-visible:ring-0 focus-visible:ring-offset-0 [&::-webkit-scrollbar]:hidden'
)}
type='text'
value={value}
onChange={handlers.onChange}
onKeyDown={handlers.onKeyDown}
onScroll={handleScroll}
onDrop={handlers.onDrop}
onDragOver={handlers.onDragOver}
autoComplete='off'
disabled={disabled}
/>
<div
ref={(el) => {
if (el) overlayRefs.current.set(fieldId, el)
}}
className='pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[10px] py-[8px] font-medium font-sans text-[#eeeeee] text-sm [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
>
<div className='min-w-fit whitespace-pre'>
{formatDisplayText(value, {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
{!collapsed && (
<div className='flex flex-col gap-[6px] border-[var(--border-strong)] border-t px-[10px] pt-[6px] pb-[10px]'>
<div className='space-y-[4px]'>
<Label className='text-[13px]'>Value</Label>
<div className='relative'>
<Input
ref={(el) => {
if (el) inputRefs.current.set(fieldId, el)
}}
name='value'
value={value}
onChange={handlers.onChange}
onKeyDown={handlers.onKeyDown}
onDrop={handlers.onDrop}
onDragOver={handlers.onDragOver}
onScroll={(e) => handleScroll(e)}
onPaste={() =>
setTimeout(() => {
const input = inputRefs.current.get(fieldId)
input && handleScroll({ currentTarget: input } as any)
}, 0)
}
placeholder='Enter value or reference'
disabled={disabled}
autoComplete='off'
className={cn(
'allow-scroll w-full overflow-auto text-transparent caret-foreground'
)}
style={{ overflowX: 'auto' }}
/>
<div
ref={(el) => {
if (el) overlayRefs.current.set(fieldId, el)
}}
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
className='w-full whitespace-pre'
style={{ scrollbarWidth: 'none', minWidth: 'fit-content' }}
>
{formatDisplayText(
value,
accessiblePrefixes ? { accessiblePrefixes } : { highlightAll: true }
)}
</div>
</div>
{fieldState.showTags && (
<TagDropdown
visible={fieldState.showTags}
onSelect={tagSelectHandler}
blockId={blockId}
activeSourceBlockId={fieldState.activeSourceBlockId}
inputValue={value}
cursorPosition={fieldState.cursorPosition}
onClose={() => inputController.fieldHelpers.hideFieldDropdowns(fieldId)}
inputRef={
{
current: inputRefs.current.get(fieldId) || null,
} as React.RefObject<HTMLInputElement>
}
/>
)}
</div>
</div>
</div>
{fieldState.showTags && (
<TagDropdown
visible={fieldState.showTags}
onSelect={tagSelectHandler}
blockId={blockId}
activeSourceBlockId={fieldState.activeSourceBlockId}
inputValue={value}
cursorPosition={fieldState.cursorPosition}
onClose={() => inputController.fieldHelpers.hideFieldDropdowns(fieldId)}
inputRef={
{
current: inputRefs.current.get(fieldId) || null,
} as React.RefObject<HTMLInputElement>
}
/>
)}
</div>
)}
</div>
)
}

View File

@@ -1,17 +1,8 @@
import { useCallback, useRef, useState } from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useParams } from 'next/navigation'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Combobox, Input, Label, Textarea } from '@/components/emcn/components'
import { Slider } from '@/components/ui/slider'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/formatted-text'
@@ -174,7 +165,6 @@ function McpTextareaWithTags({
onChange(newValue)
setCursorPosition(newCursorPosition)
// Check for tag trigger
const tagTrigger = checkTagTrigger(newValue, newCursorPosition)
setShowTags(tagTrigger.show)
}
@@ -308,7 +298,6 @@ export function McpDynamicArgs({
if (disabled) return
const current = currentArgs()
// Store the value as-is, preserving types (number, boolean, etc.)
const updated = { ...current, [paramName]: value }
setToolArgs(updated)
},
@@ -357,29 +346,38 @@ export function McpDynamicArgs({
</div>
)
case 'dropdown':
case 'dropdown': {
const dropdownOptions = useMemo(
() =>
(paramSchema.enum || []).map((option: any) => ({
label: String(option),
value: String(option),
})),
[paramSchema.enum]
)
return (
<div key={`${paramName}-dropdown`}>
<Select
<Combobox
options={dropdownOptions}
value={value || ''}
onValueChange={(selectedValue) => updateParameter(paramName, selectedValue)}
selectedValue={value || ''}
onChange={(selectedValue) => {
const matchedOption = dropdownOptions.find(
(opt: { label: string; value: string }) => opt.value === selectedValue
)
if (matchedOption) {
updateParameter(paramName, selectedValue)
}
}}
placeholder={`Select ${formatParameterLabel(paramName).toLowerCase()}`}
disabled={disabled}
>
<SelectTrigger className='w-full'>
<SelectValue
placeholder={`Select ${formatParameterLabel(paramName).toLowerCase()}`}
/>
</SelectTrigger>
<SelectContent>
{paramSchema.enum?.map((option: any) => (
<SelectItem key={String(option)} value={String(option)}>
{String(option)}
</SelectItem>
))}
</SelectContent>
</Select>
editable={false}
filterOptions={true}
/>
</div>
)
}
case 'slider': {
const minValue = paramSchema.minimum ?? 0

View File

@@ -1,18 +1,8 @@
'use client'
import { useState } from 'react'
import { Check, ChevronDown, RefreshCw } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Combobox } from '@/components/emcn/components'
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 { useMcpServers } from '@/hooks/queries/mcp'
@@ -34,7 +24,7 @@ export function McpServerSelector({
}: McpServerSelectorProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const [open, setOpen] = useState(false)
const [inputValue, setInputValue] = useState('')
const { data: servers = [], isLoading, error } = useMcpServers(workspaceId)
const enabledServers = servers.filter((s) => s.enabled && !s.deletedAt)
@@ -48,87 +38,47 @@ export function McpServerSelector({
const selectedServer = enabledServers.find((server) => server.id === selectedServerId)
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen)
// React Query automatically keeps server list fresh
}
const comboboxOptions = useMemo(
() =>
enabledServers.map((server) => ({
label: server.name,
value: server.id,
})),
[enabledServers]
)
const handleSelect = (serverId: string) => {
if (!isPreview) {
setStoreValue(serverId)
const handleComboboxChange = (value: string) => {
const matchedServer = enabledServers.find((s) => s.id === value)
if (matchedServer) {
setInputValue(matchedServer.name)
if (!isPreview) {
setStoreValue(value)
}
} else {
setInputValue(value)
}
setOpen(false)
}
const getDisplayText = () => {
useEffect(() => {
if (selectedServer) {
return <span className='truncate font-normal'>{selectedServer.name}</span>
setInputValue(selectedServer.name)
} else {
setInputValue('')
}
return <span className='truncate text-muted-foreground'>{label}</span>
}
}, [selectedServer])
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className='relative w-full justify-between'
disabled={disabled}
>
<div className='flex max-w-[calc(100%-20px)] items-center overflow-hidden'>
{getDisplayText()}
</div>
<ChevronDown className='absolute right-3 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[250px] p-0' align='start'>
<Command>
<CommandInput placeholder='Search servers...' />
<CommandList>
<CommandEmpty>
{isLoading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading servers...</span>
</div>
) : error ? (
<div className='p-4 text-center'>
<p className='font-medium text-destructive text-sm'>Error loading servers</p>
<p className='text-muted-foreground text-xs'>
{error instanceof Error ? error.message : 'Unknown error'}
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No MCP servers found</p>
<p className='text-muted-foreground text-xs'>
Configure MCP servers in workspace settings
</p>
</div>
)}
</CommandEmpty>
{enabledServers.length > 0 && (
<CommandGroup>
{enabledServers.map((server) => (
<CommandItem
key={server.id}
value={`server-${server.id}-${server.name}`}
onSelect={() => handleSelect(server.id)}
className='cursor-pointer'
>
<div className='flex items-center gap-2 overflow-hidden'>
<span className='truncate font-normal'>{server.name}</span>
</div>
{server.id === selectedServerId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Combobox
options={comboboxOptions}
value={inputValue}
selectedValue={selectedServerId}
onChange={handleComboboxChange}
placeholder={label}
disabled={disabled}
editable={true}
filterOptions={true}
isLoading={isLoading}
error={error instanceof Error ? error.message : null}
/>
)
}

View File

@@ -1,18 +1,8 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { Check, ChevronDown, RefreshCw } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Combobox } from '@/components/emcn/components'
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 { useMcpTools } from '@/hooks/use-mcp-tools'
@@ -34,7 +24,7 @@ export function McpToolSelector({
}: McpToolSelectorProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const [open, setOpen] = useState(false)
const [inputValue, setInputValue] = useState('')
const { mcpTools, isLoading, error, refreshTools, getToolsByServer } = useMcpTools(workspaceId)
@@ -73,105 +63,59 @@ export function McpToolSelector({
}
}, [serverValue, availableTools, storeValue, setStoreValue, isPreview, disabled])
const comboboxOptions = useMemo(
() =>
availableTools.map((tool) => ({
label: tool.name,
value: tool.id,
})),
[availableTools]
)
const handleComboboxChange = (value: string) => {
const matchedTool = availableTools.find((t) => t.id === value)
if (matchedTool) {
setInputValue(matchedTool.name)
if (!isPreview) {
setStoreValue(value)
if (matchedTool.inputSchema) {
setSchemaCache(matchedTool.inputSchema)
}
}
} else {
setInputValue(value)
}
}
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen)
if (isOpen && serverValue) {
refreshTools()
}
}
const handleSelect = (toolId: string) => {
if (!isPreview) {
setStoreValue(toolId)
const tool = availableTools.find((t) => t.id === toolId)
if (tool?.inputSchema) {
setSchemaCache(tool.inputSchema)
}
}
setOpen(false)
}
const getDisplayText = () => {
useEffect(() => {
if (selectedTool) {
return <span className='truncate font-normal'>{selectedTool.name}</span>
setInputValue(selectedTool.name)
} else {
setInputValue('')
}
return (
<span className='truncate text-muted-foreground'>
{serverValue ? label : 'Select server first'}
</span>
)
}
}, [selectedTool])
const isDisabled = disabled || !serverValue
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className='relative w-full justify-between'
disabled={isDisabled}
>
<div className='flex max-w-[calc(100%-20px)] items-center overflow-hidden'>
{getDisplayText()}
</div>
<ChevronDown className='absolute right-3 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[250px] p-0' align='start'>
<Command>
<CommandInput placeholder='Search tools...' />
<CommandList>
<CommandEmpty>
{isLoading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading tools...</span>
</div>
) : error ? (
<div className='p-4 text-center'>
<p className='font-medium text-destructive text-sm'>Error loading tools</p>
<p className='text-muted-foreground text-xs'>{error}</p>
</div>
) : !serverValue ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No server selected</p>
<p className='text-muted-foreground text-xs'>
Select an MCP server first to see available tools
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No tools found</p>
<p className='text-muted-foreground text-xs'>
The selected server has no available tools
</p>
</div>
)}
</CommandEmpty>
{availableTools.length > 0 && (
<CommandGroup>
{availableTools.map((tool) => (
<CommandItem
key={tool.id}
value={`tool-${tool.id}-${tool.name}`}
onSelect={() => handleSelect(tool.id)}
className='cursor-pointer'
>
<div className='flex items-center gap-2 overflow-hidden'>
<span className='truncate font-normal'>{tool.name}</span>
</div>
{tool.id === selectedToolId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Combobox
options={comboboxOptions}
value={inputValue}
selectedValue={selectedToolId}
onChange={handleComboboxChange}
onOpenChange={handleOpenChange}
placeholder={serverValue ? label : 'Select server first'}
disabled={isDisabled}
editable={true}
filterOptions={true}
isLoading={isLoading}
error={error || null}
/>
)
}

View File

@@ -1,638 +0,0 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Check, ChevronDown, ExternalLink, RefreshCw, X } from 'lucide-react'
import { JiraIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { createLogger } from '@/lib/logs/console/logger'
import {
type Credential,
getProviderIdFromServiceId,
getServiceIdFromScopes,
type OAuthProvider,
} 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 { useDisplayNamesStore } from '@/stores/display-names/store'
const logger = createLogger('JiraProjectSelector')
export interface JiraProjectInfo {
id: string
key: string
name: string
url?: string
avatarUrl?: string
description?: string
projectTypeKey?: string
simplified?: boolean
style?: string
isPrivate?: boolean
}
interface JiraProjectSelectorProps {
value: string
onChange: (value: string, projectInfo?: JiraProjectInfo) => void
provider: OAuthProvider
requiredScopes?: string[]
label?: string
disabled?: boolean
serviceId?: string
domain: string
showPreview?: boolean
onProjectInfoChange?: (projectInfo: JiraProjectInfo | null) => void
credentialId?: string
isForeignCredential?: boolean
workflowId?: string
}
export function JiraProjectSelector({
value,
onChange,
provider,
requiredScopes = [],
label = 'Select Jira project',
disabled = false,
serviceId,
domain,
showPreview = true,
onProjectInfoChange,
credentialId,
isForeignCredential = false,
workflowId,
}: JiraProjectSelectorProps) {
const [open, setOpen] = useState(false)
const [credentials, setCredentials] = useState<Credential[]>([])
const [projects, setProjects] = useState<JiraProjectInfo[]>([])
const [selectedCredentialId, setSelectedCredentialId] = useState<string>(credentialId || '')
const [selectedProjectId, setSelectedProjectId] = useState(value)
const [selectedProject, setSelectedProject] = useState<JiraProjectInfo | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [showOAuthModal, setShowOAuthModal] = useState(false)
const initialFetchRef = useRef(false)
const [error, setError] = useState<string | null>(null)
const [cloudId, setCloudId] = useState<string | null>(null)
// Get cached display name
const cachedProjectName = useDisplayNamesStore(
useCallback(
(state) => {
const effectiveCredentialId = credentialId || selectedCredentialId
if (!effectiveCredentialId || !value) return null
return state.cache.projects[`jira-${effectiveCredentialId}`]?.[value] || null
},
[credentialId, selectedCredentialId, value]
)
)
// Handle search with debounce
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const handleSearch = (value: string) => {
// Clear any existing timeout
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current)
}
// Set a new timeout
searchTimeoutRef.current = setTimeout(() => {
if (value.length >= 1) {
fetchProjects(value)
} else {
fetchProjects() // Fetch all projects if no search term
}
}, 500) // 500ms debounce
}
// Clean up the timeout on unmount
useEffect(() => {
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current)
}
}
}, [])
// Determine the appropriate service ID based on provider and scopes
const getServiceId = (): string => {
if (serviceId) return serviceId
return getServiceIdFromScopes(provider, requiredScopes)
}
// Determine the appropriate provider ID based on service and scopes (stabilized)
const providerId = useMemo(() => {
const effectiveServiceId = getServiceId()
return getProviderIdFromServiceId(effectiveServiceId)
}, [serviceId, provider, requiredScopes])
// Fetch available credentials for this provider
const fetchCredentials = useCallback(async () => {
if (!providerId) return
setIsLoading(true)
try {
const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`)
if (response.ok) {
const data = await response.json()
setCredentials(data.credentials)
// Do not auto-select credentials. Only use the credentialId provided by the parent.
}
} catch (error) {
logger.error('Error fetching credentials:', error)
} finally {
setIsLoading(false)
}
}, [providerId])
// Fetch detailed project information
const fetchProjectInfo = useCallback(
async (projectId: string) => {
if (!selectedCredentialId || !domain || !projectId) return
setIsLoading(true)
setError(null)
try {
// Get the access token from the selected credential
const tokenResponse = await fetch('/api/auth/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
credentialId: selectedCredentialId,
workflowId,
}),
})
if (!tokenResponse.ok) {
const errorData = await tokenResponse.json()
logger.error('Access token error:', errorData)
setError('Authentication failed. Please reconnect your Jira account.')
return
}
const tokenData = await tokenResponse.json()
const accessToken = tokenData.accessToken
if (!accessToken) {
logger.error('No access token returned')
setError('Authentication failed. Please reconnect your Jira account.')
return
}
// Use POST /api/tools/jira/projects to fetch a single project by id
const response = await fetch(`/api/tools/jira/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domain, accessToken, projectId, cloudId }),
})
if (!response.ok) {
const errorData = await response.json()
logger.error('Jira API error:', errorData)
throw new Error(errorData.error || 'Failed to fetch project details')
}
const json = await response.json()
const projectInfo = json?.project
const newCloudId = json?.cloudId
if (newCloudId) {
setCloudId(newCloudId)
}
if (projectInfo) {
setSelectedProject(projectInfo)
onProjectInfoChange?.(projectInfo)
} else {
setSelectedProject(null)
onProjectInfoChange?.(null)
}
} catch (error) {
logger.error('Error fetching project details:', error)
setError((error as Error).message)
} finally {
setIsLoading(false)
}
},
[selectedCredentialId, domain, onProjectInfoChange, cloudId]
)
// Fetch projects from Jira
const fetchProjects = useCallback(
async (searchQuery?: string) => {
if (!selectedCredentialId || !domain) return
// Validate domain format
const trimmedDomain = domain.trim().toLowerCase()
if (!trimmedDomain.includes('.')) {
setError(
'Invalid domain format. Please provide the full domain (e.g., your-site.atlassian.net)'
)
setProjects([])
setIsLoading(false)
return
}
setIsLoading(true)
setError(null)
try {
// Get the access token from the selected credential
const tokenResponse = await fetch('/api/auth/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
credentialId: selectedCredentialId,
workflowId,
}),
})
if (!tokenResponse.ok) {
const errorData = await tokenResponse.json()
logger.error('Access token error:', errorData)
setError('Authentication failed. Please reconnect your Jira account.')
setIsLoading(false)
return
}
const tokenData = await tokenResponse.json()
const accessToken = tokenData.accessToken
if (!accessToken) {
logger.error('No access token returned')
setError('Authentication failed. Please reconnect your Jira account.')
setIsLoading(false)
return
}
// Build query parameters for the projects endpoint
const queryParams = new URLSearchParams({
domain,
accessToken,
...(searchQuery && { query: searchQuery }),
...(cloudId && { cloudId }),
})
// Use the GET endpoint for project search
const response = await fetch(`/api/tools/jira/projects?${queryParams.toString()}`)
if (!response.ok) {
const errorData = await response.json()
logger.error('Jira API error:', errorData)
throw new Error(errorData.error || 'Failed to fetch projects')
}
const data = await response.json()
if (data.cloudId) {
setCloudId(data.cloudId)
}
// Process the projects results
const foundProjects = data.projects || []
logger.info(`Received ${foundProjects.length} projects from API`)
setProjects(foundProjects)
// Cache project names in display names store
if (selectedCredentialId && foundProjects.length > 0) {
const projectMap = foundProjects.reduce(
(acc: Record<string, string>, proj: JiraProjectInfo) => {
acc[proj.id] = proj.name
return acc
},
{}
)
useDisplayNamesStore
.getState()
.setDisplayNames('projects', `jira-${selectedCredentialId}`, projectMap)
}
// If we have a selected project ID, find the project info
if (selectedProjectId) {
const projectInfo = foundProjects.find(
(project: JiraProjectInfo) => project.id === selectedProjectId
)
if (projectInfo) {
setSelectedProject(projectInfo)
onProjectInfoChange?.(projectInfo)
} else if (!searchQuery && selectedProjectId) {
// If we can't find the project in the list, try to fetch it directly
fetchProjectInfo(selectedProjectId)
}
}
} catch (error) {
logger.error('Error fetching projects:', error)
setError((error as Error).message)
setProjects([])
} finally {
setIsLoading(false)
}
},
[
selectedCredentialId,
domain,
selectedProjectId,
onProjectInfoChange,
fetchProjectInfo,
cloudId,
]
)
// Fetch credentials list when dropdown opens (for account switching UI), not on mount
useEffect(() => {
if (open) {
fetchCredentials()
}
}, [open, fetchCredentials])
// Keep local credential state in sync with persisted credential
useEffect(() => {
if (credentialId && credentialId !== selectedCredentialId) {
setSelectedCredentialId(credentialId)
}
}, [credentialId, selectedCredentialId])
// Keep internal selectedProjectId in sync with the value prop
useEffect(() => {
if (value !== selectedProjectId) {
setSelectedProjectId(value)
}
}, [value, selectedProjectId])
// Clear callback when value is cleared
useEffect(() => {
if (!value) {
setSelectedProject(null)
onProjectInfoChange?.(null)
}
}, [value, onProjectInfoChange])
// Fetch project info on mount if we have a value but no selectedProject state
useEffect(() => {
if (value && selectedCredentialId && domain && !selectedProject) {
fetchProjectInfo(value)
}
}, [value, selectedCredentialId, domain, selectedProject, fetchProjectInfo])
// Handle open change
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen)
// Only fetch projects when a credential is present; otherwise, do nothing
if (isOpen && selectedCredentialId && domain && domain.includes('.')) {
fetchProjects('')
}
}
// Handle project selection
const handleSelectProject = (project: JiraProjectInfo) => {
setSelectedProjectId(project.id)
setSelectedProject(project)
onChange(project.id, project)
onProjectInfoChange?.(project)
setOpen(false)
}
// Handle adding a new credential
const handleAddCredential = () => {
// Show the OAuth modal
setShowOAuthModal(true)
setOpen(false)
}
// Clear selection
const handleClearSelection = () => {
setSelectedProjectId('')
setSelectedProject(null)
setError(null)
onChange('', undefined)
onProjectInfoChange?.(null)
}
return (
<>
<div className='space-y-2'>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className='w-full justify-between'
disabled={disabled || !domain || !selectedCredentialId || isForeignCredential}
>
{cachedProjectName ? (
<div className='flex items-center gap-2 overflow-hidden'>
<JiraIcon className='h-4 w-4' />
<span className='truncate font-normal'>{cachedProjectName}</span>
</div>
) : (
<div className='flex items-center gap-2'>
<JiraIcon className='h-4 w-4' />
<span className='text-muted-foreground'>{label}</span>
</div>
)}
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
{!isForeignCredential && (
<PopoverContent className='w-[300px] p-0' align='start'>
{selectedCredentialId && credentials.length > 0 && (
<div className='flex items-center justify-between border-b px-3 py-2'>
<div className='flex items-center gap-2'>
<JiraIcon className='h-4 w-4' />
<span className='text-muted-foreground text-xs'>
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
'Unknown'}
</span>
</div>
{credentials.length > 1 && (
<Button
variant='ghost'
size='sm'
className='h-6 px-2 text-xs'
onClick={() => setOpen(true)}
>
Switch
</Button>
)}
</div>
)}
<Command>
<CommandInput placeholder='Search projects...' onValueChange={handleSearch} />
<CommandList>
<CommandEmpty>
{isLoading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading projects...</span>
</div>
) : error ? (
<div className='p-4 text-center'>
<p className='text-destructive text-sm'>{error}</p>
</div>
) : credentials.length === 0 ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No accounts connected.</p>
<p className='text-muted-foreground text-xs'>
Connect a Jira account to continue.
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No projects found.</p>
<p className='text-muted-foreground text-xs'>
Try a different search or account.
</p>
</div>
)}
</CommandEmpty>
{/* Account selection - only show if we have multiple accounts */}
{credentials.length > 1 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Switch Account
</div>
{credentials.map((cred) => (
<CommandItem
key={cred.id}
value={`account-${cred.id}`}
onSelect={() => setSelectedCredentialId(cred.id)}
>
<div className='flex items-center gap-2'>
<JiraIcon className='h-4 w-4' />
<span className='font-normal'>{cred.name}</span>
</div>
{cred.id === selectedCredentialId && (
<Check className='ml-auto h-4 w-4' />
)}
</CommandItem>
))}
</CommandGroup>
)}
{/* Projects list */}
{projects.length > 0 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Projects
</div>
{projects.map((project) => (
<CommandItem
key={project.id}
value={`project-${project.id}-${project.name}`}
onSelect={() => handleSelectProject(project)}
>
<div className='flex items-center gap-2 overflow-hidden'>
{project.avatarUrl ? (
<img
src={project.avatarUrl}
alt={project.name}
className='h-4 w-4 rounded'
/>
) : (
<JiraIcon className='h-4 w-4' />
)}
<span className='truncate font-normal'>{project.name}</span>
</div>
{project.id === selectedProjectId && (
<Check className='ml-auto h-4 w-4' />
)}
</CommandItem>
))}
</CommandGroup>
)}
{/* Connect account option - only show if no credentials */}
{credentials.length === 0 && (
<CommandGroup>
<CommandItem onSelect={handleAddCredential}>
<div className='flex items-center gap-2 text-foreground'>
<JiraIcon className='h-4 w-4' />
<span>Connect Jira account</span>
</div>
</CommandItem>
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
)}
</Popover>
{/* Project preview */}
{showPreview && selectedProject && (
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
<div className='absolute top-2 right-2'>
<Button
variant='ghost'
size='icon'
className='h-5 w-5 hover:bg-muted'
onClick={handleClearSelection}
>
<X className='h-3 w-3' />
</Button>
</div>
<div className='flex items-center gap-3 pr-4'>
<div className='flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-muted/20'>
{selectedProject.avatarUrl ? (
<img
src={selectedProject.avatarUrl}
alt={selectedProject.name}
className='h-6 w-6 rounded'
/>
) : (
<JiraIcon className='h-4 w-4' />
)}
</div>
<div className='min-w-0 flex-1 overflow-hidden'>
<div className='flex items-center gap-2'>
<h4 className='truncate font-medium text-xs'>{selectedProject.name}</h4>
<span className='whitespace-nowrap text-muted-foreground text-xs'>
{selectedProject.key}
</span>
</div>
{selectedProject.url ? (
<a
href={selectedProject.url}
target='_blank'
rel='noopener noreferrer'
className='flex items-center gap-1 text-foreground text-xs hover:underline'
onClick={(e) => e.stopPropagation()}
>
<span>Open in Jira</span>
<ExternalLink className='h-3 w-3' />
</a>
) : null}
</div>
</div>
</div>
)}
</div>
{showOAuthModal && (
<OAuthRequiredModal
isOpen={showOAuthModal}
onClose={() => setShowOAuthModal(false)}
provider={provider}
toolName='Jira'
requiredScopes={requiredScopes}
serviceId={getServiceId()}
/>
)}
</>
)
}

View File

@@ -1,196 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { Check, ChevronDown, RefreshCw } from 'lucide-react'
import { LinearIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { useDisplayNamesStore } from '@/stores/display-names/store'
export interface LinearProjectInfo {
id: string
name: string
}
interface LinearProjectSelectorProps {
value: string
onChange: (projectId: string, projectInfo?: LinearProjectInfo) => void
credential: string
teamId: string
label?: string
disabled?: boolean
workflowId?: string
}
export function LinearProjectSelector({
value,
onChange,
credential,
teamId,
label = 'Select Linear project',
disabled = false,
workflowId,
}: LinearProjectSelectorProps) {
const [projects, setProjects] = useState<LinearProjectInfo[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [open, setOpen] = useState(false)
// Get cached display name
const cachedProjectName = useDisplayNamesStore(
useCallback(
(state) => {
if (!credential || !value) return null
return state.cache.projects[`linear-${credential}`]?.[value] || null
},
[credential, value]
)
)
useEffect(() => {
if (!credential || !teamId) return
const controller = new AbortController()
setLoading(true)
setError(null)
fetch('/api/tools/linear/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential, teamId, workflowId }),
signal: controller.signal,
})
.then(async (res) => {
if (!res.ok) {
const errorText = await res.text()
throw new Error(`HTTP error! status: ${res.status} - ${errorText}`)
}
return res.json()
})
.then((data) => {
if (data.error) {
setError(data.error)
setProjects([])
} else {
setProjects(data.projects)
// Cache project names in display names store
if (credential && data.projects) {
const projectMap = data.projects.reduce(
(acc: Record<string, string>, proj: LinearProjectInfo) => {
acc[proj.id] = proj.name
return acc
},
{}
)
useDisplayNamesStore
.getState()
.setDisplayNames('projects', `linear-${credential}`, projectMap)
}
}
})
.catch((err) => {
if (err.name === 'AbortError') return
setError(err.message)
setProjects([])
})
.finally(() => setLoading(false))
return () => controller.abort()
}, [credential, teamId, value, workflowId])
const handleSelectProject = (project: LinearProjectInfo) => {
onChange(project.id, project)
setOpen(false)
}
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen)
}
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className='w-full justify-between'
disabled={disabled || !credential || !teamId}
>
{cachedProjectName ? (
<div className='flex items-center gap-2 overflow-hidden'>
<LinearIcon className='h-4 w-4' />
<span className='truncate font-normal'>{cachedProjectName}</span>
</div>
) : (
<div className='flex items-center gap-2'>
<LinearIcon className='h-4 w-4' />
<span className='text-muted-foreground'>{label}</span>
</div>
)}
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[300px] p-0' align='start'>
<Command>
<CommandInput placeholder='Search projects...' />
<CommandList>
<CommandEmpty>
{loading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading projects...</span>
</div>
) : error ? (
<div className='p-4 text-center'>
<p className='text-destructive text-sm'>{error}</p>
</div>
) : !credential || !teamId ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>Missing credentials or team</p>
<p className='text-muted-foreground text-xs'>
Please configure Linear credentials and select a team.
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No projects found</p>
<p className='text-muted-foreground text-xs'>
No projects available for the selected team.
</p>
</div>
)}
</CommandEmpty>
{projects.length > 0 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Projects
</div>
{projects.map((project) => (
<CommandItem
key={project.id}
value={`project-${project.id}-${project.name}`}
onSelect={() => handleSelectProject(project)}
className='cursor-pointer'
>
<div className='flex items-center gap-2 overflow-hidden'>
<LinearIcon className='h-4 w-4' />
<span className='truncate font-normal'>{project.name}</span>
</div>
{project.id === value && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

View File

@@ -1,190 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { Check, ChevronDown, RefreshCw } from 'lucide-react'
import { LinearIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { useDisplayNamesStore } from '@/stores/display-names/store'
export interface LinearTeamInfo {
id: string
name: string
}
interface LinearTeamSelectorProps {
value: string
onChange: (teamId: string, teamInfo?: LinearTeamInfo) => void
credential: string
label?: string
disabled?: boolean
workflowId?: string
showPreview?: boolean
}
export function LinearTeamSelector({
value,
onChange,
credential,
label = 'Select Linear team',
disabled = false,
workflowId,
}: LinearTeamSelectorProps) {
const [teams, setTeams] = useState<LinearTeamInfo[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [open, setOpen] = useState(false)
// Get cached display name
const cachedTeamName = useDisplayNamesStore(
useCallback(
(state) => {
if (!credential || !value) return null
return state.cache.projects[`linear-${credential}`]?.[value] || null
},
[credential, value]
)
)
useEffect(() => {
if (!credential) return
const controller = new AbortController()
setLoading(true)
setError(null)
fetch('/api/tools/linear/teams', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential, workflowId }),
signal: controller.signal,
})
.then((res) => {
if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`)
return res.json()
})
.then((data) => {
if (data.error) {
setError(data.error)
setTeams([])
} else {
setTeams(data.teams)
// Cache team names in display names store
if (credential && data.teams) {
const teamMap = data.teams.reduce(
(acc: Record<string, string>, team: LinearTeamInfo) => {
acc[team.id] = team.name
return acc
},
{}
)
useDisplayNamesStore
.getState()
.setDisplayNames('projects', `linear-${credential}`, teamMap)
}
}
})
.catch((err) => {
if (err.name === 'AbortError') return
setError(err.message)
setTeams([])
})
.finally(() => setLoading(false))
return () => controller.abort()
}, [credential, value, workflowId])
const handleSelectTeam = (team: LinearTeamInfo) => {
onChange(team.id, team)
setOpen(false)
}
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen)
}
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className='w-full justify-between'
disabled={disabled || !credential}
>
{cachedTeamName ? (
<div className='flex items-center gap-2 overflow-hidden'>
<LinearIcon className='h-4 w-4' />
<span className='truncate font-normal'>{cachedTeamName}</span>
</div>
) : (
<div className='flex items-center gap-2'>
<LinearIcon className='h-4 w-4' />
<span className='text-muted-foreground'>{label}</span>
</div>
)}
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[300px] p-0' align='start'>
<Command>
<CommandInput placeholder='Search teams...' />
<CommandList>
<CommandEmpty>
{loading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading teams...</span>
</div>
) : error ? (
<div className='p-4 text-center'>
<p className='text-destructive text-sm'>{error}</p>
</div>
) : !credential ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>Missing credentials</p>
<p className='text-muted-foreground text-xs'>
Please configure Linear credentials.
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No teams found</p>
<p className='text-muted-foreground text-xs'>
No teams available for this Linear account.
</p>
</div>
)}
</CommandEmpty>
{teams.length > 0 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>Teams</div>
{teams.map((team) => (
<CommandItem
key={team.id}
value={`team-${team.id}-${team.name}`}
onSelect={() => handleSelectTeam(team)}
className='cursor-pointer'
>
<div className='flex items-center gap-2 overflow-hidden'>
<LinearIcon className='h-4 w-4' />
<span className='truncate font-normal'>{team.name}</span>
</div>
{team.id === value && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

View File

@@ -1,23 +1,14 @@
'use client'
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useParams } from 'next/navigation'
import { Tooltip } from '@/components/emcn'
import {
type JiraProjectInfo,
JiraProjectSelector,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/project-selector/components/jira-project-selector'
import {
type LinearProjectInfo,
LinearProjectSelector,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/project-selector/components/linear-project-selector'
import {
type LinearTeamInfo,
LinearTeamSelector,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/project-selector/components/linear-team-selector'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-foreign-credential'
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 { resolveSelectorForSubBlock } from '@/hooks/selectors/resolution'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -41,10 +32,10 @@ export function ProjectSelectorInput({
previewContextValues,
}: ProjectSelectorInputProps) {
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
const params = useParams()
const [selectedProjectId, setSelectedProjectId] = useState<string>('')
const [_projectInfo, setProjectInfo] = useState<any | null>(null)
// Use the proper hook to get the current value and setter
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
const [storeValue] = useSubBlockValue(blockId, subBlock.id)
const [connectedCredentialFromStore] = useSubBlockValue(blockId, 'credential')
const [linearTeamIdFromStore] = useSubBlockValue(blockId, 'teamId')
const [jiraDomainFromStore] = useSubBlockValue(blockId, 'domain')
@@ -60,6 +51,7 @@ export function ProjectSelectorInput({
(connectedCredential as string) || ''
)
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) as string | null
const workflowIdFromUrl = (params?.workflowId as string) || activeWorkflowId || ''
const { finalDisabled } = useDependsOnGate(blockId, subBlock, {
disabled,
isPreview,
@@ -87,91 +79,58 @@ export function ProjectSelectorInput({
}
}, [isPreview, previewValue, storeValue])
// Handle project selection
const handleProjectChange = (
projectId: string,
info?: JiraProjectInfo | LinearTeamInfo | LinearProjectInfo
) => {
setSelectedProjectId(projectId)
setProjectInfo(info || null)
setStoreValue(projectId)
const selectorResolution = useMemo(() => {
return resolveSelectorForSubBlock(subBlock, {
workflowId: workflowIdFromUrl || undefined,
credentialId: (isLinear ? linearCredential : jiraCredential) as string | undefined,
domain,
teamId: (linearTeamId as string) || undefined,
})
}, [
subBlock,
workflowIdFromUrl,
isLinear,
linearCredential,
jiraCredential,
domain,
linearTeamId,
])
onProjectSelect?.(projectId)
const missingCredential = !selectorResolution?.context.credentialId
const handleChange = (value: string) => {
setSelectedProjectId(value)
onProjectSelect?.(value)
}
// Discord no longer uses a server selector; fall through to other providers
// Render Linear team/project selector if provider is linear
if (isLinear) {
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='w-full'>
{subBlock.id === 'teamId' ? (
<LinearTeamSelector
value={selectedProjectId}
onChange={(teamId: string, teamInfo?: LinearTeamInfo) => {
handleProjectChange(teamId, teamInfo)
}}
credential={(linearCredential as string) || ''}
label={subBlock.placeholder || 'Select Linear team'}
disabled={finalDisabled}
showPreview={true}
workflowId={activeWorkflowId || ''}
/>
) : (
(() => {
const credential = (linearCredential as string) || ''
const teamId = (linearTeamId as string) || ''
const isDisabled = finalDisabled
return (
<LinearProjectSelector
value={selectedProjectId}
onChange={(projectId: string, projectInfo?: LinearProjectInfo) => {
handleProjectChange(projectId, projectInfo)
}}
credential={credential}
teamId={teamId}
label={subBlock.placeholder || 'Select Linear project'}
disabled={isDisabled}
workflowId={activeWorkflowId || ''}
/>
)
})()
)}
</div>
</Tooltip.Trigger>
{!(linearCredential as string) && (
<Tooltip.Content side='top'>
<p>Please select a Linear account first</p>
</Tooltip.Content>
)}
</Tooltip.Root>
)
}
// Default to Jira project selector
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='w-full'>
<JiraProjectSelector
value={selectedProjectId}
onChange={handleProjectChange}
domain={domain}
provider='jira'
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select Jira project'}
disabled={finalDisabled}
showPreview={true}
onProjectInfoChange={setProjectInfo}
credentialId={(jiraCredential as string) || ''}
isForeignCredential={isForeignCredential}
workflowId={activeWorkflowId || ''}
/>
{selectorResolution?.key ? (
<SelectorCombobox
blockId={blockId}
subBlock={subBlock}
selectorKey={selectorResolution.key}
selectorContext={selectorResolution.context}
disabled={finalDisabled || isForeignCredential || missingCredential}
isPreview={isPreview}
previewValue={previewValue ?? null}
placeholder={subBlock.placeholder || 'Select project'}
onOptionChange={handleChange}
/>
) : (
<div className='w-full rounded border border-dashed p-4 text-center text-muted-foreground text-sm'>
Project selector not supported for provider: {subBlock.provider || 'unknown'}
</div>
)}
</div>
</Tooltip.Trigger>
{missingCredential && (
<Tooltip.Content side='top'>
<p>Please select an account first</p>
</Tooltip.Content>
)}
</Tooltip.Root>
)
}

View File

@@ -0,0 +1,145 @@
import type React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Combobox as EditableCombobox } from '@/components/emcn/components'
import { SubBlockInputController } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/sub-block-input-controller'
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 type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
import {
useSelectorOptionDetail,
useSelectorOptionMap,
useSelectorOptions,
} from '@/hooks/selectors/use-selector-query'
interface SelectorComboboxProps {
blockId: string
subBlock: SubBlockConfig
selectorKey: SelectorKey
selectorContext: SelectorContext
disabled?: boolean
isPreview?: boolean
previewValue?: string | null
placeholder?: string
readOnly?: boolean
onOptionChange?: (value: string) => void
allowSearch?: boolean
}
export function SelectorCombobox({
blockId,
subBlock,
selectorKey,
selectorContext,
disabled,
isPreview,
previewValue,
placeholder,
readOnly,
onOptionChange,
allowSearch = true,
}: SelectorComboboxProps) {
const [storeValueRaw, setStoreValue] = useSubBlockValue<string | null | undefined>(
blockId,
subBlock.id
)
const storeValue = storeValueRaw ?? undefined
const previewedValue = previewValue ?? undefined
const activeValue: string | undefined = isPreview ? previewedValue : storeValue
const [searchTerm, setSearchTerm] = useState('')
const [isEditing, setIsEditing] = useState(false)
const {
data: options = [],
isLoading,
error,
} = useSelectorOptions(selectorKey, {
context: selectorContext,
search: allowSearch ? searchTerm : undefined,
})
const { data: detailOption } = useSelectorOptionDetail(selectorKey, {
context: selectorContext,
detailId: activeValue,
})
const optionMap = useSelectorOptionMap(options, detailOption ?? undefined)
const selectedLabel = activeValue ? (optionMap.get(activeValue)?.label ?? activeValue) : ''
const [inputValue, setInputValue] = useState(selectedLabel)
const previousActiveValue = useRef<string | undefined>(activeValue)
useEffect(() => {
if (previousActiveValue.current !== activeValue) {
previousActiveValue.current = activeValue
setIsEditing(false)
}
}, [activeValue])
useEffect(() => {
if (!allowSearch) return
if (!isEditing) {
setInputValue(selectedLabel)
}
}, [selectedLabel, allowSearch, isEditing])
const comboboxOptions = useMemo(
() =>
Array.from(optionMap.values()).map((option) => ({
label: option.label,
value: option.id,
})),
[optionMap]
)
const handleSelection = useCallback(
(value: string) => {
if (readOnly || disabled) return
setStoreValue(value)
setIsEditing(false)
onOptionChange?.(value)
},
[setStoreValue, onOptionChange, readOnly, disabled]
)
return (
<div className='w-full'>
<SubBlockInputController
blockId={blockId}
subBlockId={subBlock.id}
config={subBlock}
value={activeValue ?? ''}
disabled={disabled || readOnly}
isPreview={isPreview}
>
{({ ref, onDrop, onDragOver }) => (
<EditableCombobox
options={comboboxOptions}
value={allowSearch ? inputValue : selectedLabel}
selectedValue={activeValue ?? ''}
onChange={(newValue) => {
const matched = optionMap.get(newValue)
if (matched) {
setInputValue(matched.label)
setIsEditing(false)
handleSelection(matched.id)
return
}
if (allowSearch) {
setInputValue(newValue)
setIsEditing(true)
setSearchTerm(newValue)
}
}}
placeholder={placeholder || subBlock.placeholder || 'Select an option'}
disabled={disabled || readOnly}
editable={allowSearch}
filterOptions={allowSearch}
inputRef={ref as React.RefObject<HTMLInputElement>}
inputProps={{
onDrop: onDrop as (e: React.DragEvent<HTMLInputElement>) => void,
onDragOver: onDragOver as (e: React.DragEvent<HTMLInputElement>) => void,
}}
isLoading={isLoading}
error={error instanceof Error ? error.message : null}
/>
)}
</SubBlockInputController>
</div>
)
}

View File

@@ -1,588 +0,0 @@
'use client'
import { useCallback, useRef, useState } from 'react'
import { X } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { createLogger } from '@/lib/logs/console/logger'
import type { McpTransport } from '@/lib/mcp/types'
import {
checkEnvVarTrigger,
EnvVarDropdown,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/env-var-dropdown'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/formatted-text'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { useCreateMcpServer } from '@/hooks/queries/mcp'
import { useMcpServerTest } from '@/hooks/use-mcp-server-test'
const logger = createLogger('McpServerModal')
interface McpServerModalProps {
open: boolean
onOpenChange: (open: boolean) => void
onServerCreated?: () => void
blockId: string
}
interface McpServerFormData {
name: string
transport: McpTransport
url?: string
headers?: Record<string, string>
}
export function McpServerModal({
open,
onOpenChange,
onServerCreated,
blockId,
}: McpServerModalProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const [formData, setFormData] = useState<McpServerFormData>({
name: '',
transport: 'streamable-http',
url: '',
headers: { '': '' },
})
const createServerMutation = useCreateMcpServer()
const [localError, setLocalError] = useState<string | null>(null)
// MCP server testing
const { testResult, isTestingConnection, testConnection, clearTestResult } = useMcpServerTest()
// Environment variable dropdown state
const [showEnvVars, setShowEnvVars] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
const [cursorPosition, setCursorPosition] = useState(0)
const [activeInputField, setActiveInputField] = useState<
'url' | 'header-key' | 'header-value' | null
>(null)
const [activeHeaderIndex, setActiveHeaderIndex] = useState<number | null>(null)
const urlInputRef = useRef<HTMLInputElement>(null)
const [urlScrollLeft, setUrlScrollLeft] = useState(0)
const [headerScrollLeft, setHeaderScrollLeft] = useState<Record<string, number>>({})
const error = localError || createServerMutation.error?.message
const resetForm = () => {
setFormData({
name: '',
transport: 'streamable-http',
url: '',
headers: { '': '' },
})
setLocalError(null)
createServerMutation.reset()
setShowEnvVars(false)
setActiveInputField(null)
setActiveHeaderIndex(null)
clearTestResult()
}
// Handle environment variable selection
const handleEnvVarSelect = useCallback(
(newValue: string) => {
if (activeInputField === 'url') {
setFormData((prev) => ({ ...prev, url: newValue }))
} else if (activeInputField === 'header-key' && activeHeaderIndex !== null) {
const headerEntries = Object.entries(formData.headers || {})
const [oldKey, value] = headerEntries[activeHeaderIndex]
const newHeaders = { ...formData.headers }
delete newHeaders[oldKey]
newHeaders[newValue.replace(/[{}]/g, '')] = value
setFormData((prev) => ({ ...prev, headers: newHeaders }))
} else if (activeInputField === 'header-value' && activeHeaderIndex !== null) {
const headerEntries = Object.entries(formData.headers || {})
const [key] = headerEntries[activeHeaderIndex]
setFormData((prev) => ({
...prev,
headers: { ...prev.headers, [key]: newValue },
}))
}
setShowEnvVars(false)
setActiveInputField(null)
setActiveHeaderIndex(null)
},
[activeInputField, activeHeaderIndex, formData.headers]
)
// Handle input change with env var detection
const handleInputChange = useCallback(
(field: 'url' | 'header-key' | 'header-value', value: string, headerIndex?: number) => {
const input = document.activeElement as HTMLInputElement
const pos = input?.selectionStart || 0
setCursorPosition(pos)
// Clear test result when any field changes
if (testResult) {
clearTestResult()
}
// Check if we should show the environment variables dropdown
const envVarTrigger = checkEnvVarTrigger(value, pos)
setShowEnvVars(envVarTrigger.show)
setSearchTerm(envVarTrigger.show ? envVarTrigger.searchTerm : '')
if (envVarTrigger.show) {
setActiveInputField(field)
setActiveHeaderIndex(headerIndex ?? null)
} else {
setActiveInputField(null)
setActiveHeaderIndex(null)
}
// Update form data
if (field === 'url') {
setFormData((prev) => ({ ...prev, url: value }))
} else if (field === 'header-key' && headerIndex !== undefined) {
const headerEntries = Object.entries(formData.headers || {})
const [oldKey, headerValue] = headerEntries[headerIndex]
const newHeaders = { ...formData.headers }
delete newHeaders[oldKey]
newHeaders[value] = headerValue
// Add a new empty header row if this is the last row and both key and value have content
const isLastRow = headerIndex === headerEntries.length - 1
const hasContent = value.trim() !== '' && headerValue.trim() !== ''
if (isLastRow && hasContent) {
newHeaders[''] = ''
}
setFormData((prev) => ({ ...prev, headers: newHeaders }))
} else if (field === 'header-value' && headerIndex !== undefined) {
const headerEntries = Object.entries(formData.headers || {})
const [key] = headerEntries[headerIndex]
const newHeaders = { ...formData.headers, [key]: value }
// Add a new empty header row if this is the last row and both key and value have content
const isLastRow = headerIndex === headerEntries.length - 1
const hasContent = key.trim() !== '' && value.trim() !== ''
if (isLastRow && hasContent) {
newHeaders[''] = ''
}
setFormData((prev) => ({ ...prev, headers: newHeaders }))
}
},
[formData.headers, testResult, clearTestResult]
)
const handleTestConnection = useCallback(async () => {
if (!formData.name.trim() || !formData.url?.trim()) return
await testConnection({
name: formData.name,
transport: formData.transport,
url: formData.url,
headers: formData.headers,
timeout: 30000,
workspaceId,
})
}, [formData, testConnection, workspaceId])
const handleSubmit = useCallback(async () => {
if (!formData.name.trim()) {
setLocalError('Server name is required')
return
}
if (!formData.url?.trim()) {
setLocalError('Server URL is required for HTTP/SSE transport')
return
}
setLocalError(null)
createServerMutation.reset()
try {
// If no test has been done, test first
if (!testResult) {
const result = await testConnection({
name: formData.name,
transport: formData.transport,
url: formData.url,
headers: formData.headers,
timeout: 30000,
workspaceId,
})
// If test fails, don't proceed
if (!result.success) {
return
}
}
// If we have a failed test result, don't proceed
if (testResult && !testResult.success) {
return
}
// Filter out empty headers
const cleanHeaders = Object.fromEntries(
Object.entries(formData.headers || {}).filter(
([key, value]) => key.trim() !== '' && value.trim() !== ''
)
)
await createServerMutation.mutateAsync({
workspaceId,
config: {
name: formData.name.trim(),
transport: formData.transport,
url: formData.url,
timeout: 30000,
headers: cleanHeaders,
enabled: true,
},
})
logger.info(`Added MCP server: ${formData.name}`)
// Close modal and reset form immediately after successful creation
resetForm()
onOpenChange(false)
onServerCreated?.()
} catch (error) {
logger.error('Failed to add MCP server:', error)
setLocalError(error instanceof Error ? error.message : 'Failed to add MCP server')
}
}, [
formData,
testResult,
testConnection,
onOpenChange,
onServerCreated,
createServerMutation,
workspaceId,
])
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-[600px]'>
<DialogHeader>
<DialogTitle>Add MCP Server</DialogTitle>
<DialogDescription>
Configure a new Model Context Protocol server to extend your workflow capabilities.
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
<div className='grid grid-cols-2 gap-4'>
<div>
<Label htmlFor='server-name'>Server Name</Label>
<Input
id='server-name'
placeholder='e.g., My MCP Server'
value={formData.name}
onChange={(e) => {
if (testResult) clearTestResult()
setFormData((prev) => ({ ...prev, name: e.target.value }))
}}
className='h-9'
/>
</div>
<div>
<Label htmlFor='transport'>Transport Type</Label>
<Select
value={formData.transport}
onValueChange={(value: 'http' | 'sse' | 'streamable-http') => {
if (testResult) clearTestResult()
setFormData((prev) => ({
...prev,
transport: value as McpTransport,
}))
}}
>
<SelectTrigger className='h-9'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='streamable-http'>Streamable HTTP</SelectItem>
<SelectItem value='http'>HTTP</SelectItem>
<SelectItem value='sse'>Server-Sent Events</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className='relative'>
<Label htmlFor='server-url'>Server URL</Label>
<div className='relative'>
<Input
ref={urlInputRef}
id='server-url'
placeholder='https://mcp.server.dev/{{YOUR_API_KEY}}/sse'
value={formData.url}
onChange={(e) => handleInputChange('url', e.target.value)}
onScroll={(e) => {
const scrollLeft = e.currentTarget.scrollLeft
setUrlScrollLeft(scrollLeft)
}}
onInput={(e) => {
const scrollLeft = e.currentTarget.scrollLeft
setUrlScrollLeft(scrollLeft)
}}
className='h-9 text-transparent caret-foreground placeholder:text-muted-foreground/50'
/>
{/* Overlay for styled text display */}
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden px-3 text-sm'>
<div
className='whitespace-nowrap'
style={{ transform: `translateX(-${urlScrollLeft}px)` }}
>
{formatDisplayText(formData.url || '', {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
</div>
</div>
</div>
{/* Environment Variables Dropdown */}
{showEnvVars && activeInputField === 'url' && (
<EnvVarDropdown
visible={showEnvVars}
onSelect={handleEnvVarSelect}
searchTerm={searchTerm}
inputValue={formData.url || ''}
cursorPosition={cursorPosition}
workspaceId={workspaceId}
onClose={() => {
setShowEnvVars(false)
setActiveInputField(null)
}}
className='w-full'
maxHeight='250px'
/>
)}
</div>
<div>
<Label>Headers (Optional)</Label>
<div className='space-y-2'>
{Object.entries(formData.headers || {}).map(([key, value], index) => (
<div key={index} className='relative flex gap-2'>
{/* Header Name Input */}
<div className='relative flex-1'>
<Input
placeholder='Name'
value={key}
onChange={(e) => handleInputChange('header-key', e.target.value, index)}
onScroll={(e) => {
const scrollLeft = e.currentTarget.scrollLeft
setHeaderScrollLeft((prev) => ({ ...prev, [`key-${index}`]: scrollLeft }))
}}
onInput={(e) => {
const scrollLeft = e.currentTarget.scrollLeft
setHeaderScrollLeft((prev) => ({ ...prev, [`key-${index}`]: scrollLeft }))
}}
className='h-9 text-transparent caret-foreground placeholder:text-muted-foreground/50'
/>
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden px-3 text-sm'>
<div
className='whitespace-nowrap'
style={{
transform: `translateX(-${headerScrollLeft[`key-${index}`] || 0}px)`,
}}
>
{formatDisplayText(key || '', {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
</div>
</div>
</div>
{/* Header Value Input */}
<div className='relative flex-1'>
<Input
placeholder='Value'
value={value}
onChange={(e) => handleInputChange('header-value', e.target.value, index)}
onScroll={(e) => {
const scrollLeft = e.currentTarget.scrollLeft
setHeaderScrollLeft((prev) => ({ ...prev, [`value-${index}`]: scrollLeft }))
}}
onInput={(e) => {
const scrollLeft = e.currentTarget.scrollLeft
setHeaderScrollLeft((prev) => ({ ...prev, [`value-${index}`]: scrollLeft }))
}}
className='h-9 text-transparent caret-foreground placeholder:text-muted-foreground/50'
/>
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden px-3 text-sm'>
<div
className='whitespace-nowrap'
style={{
transform: `translateX(-${headerScrollLeft[`value-${index}`] || 0}px)`,
}}
>
{formatDisplayText(value || '', {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
</div>
</div>
</div>
<Button
type='button'
variant='ghost'
onClick={() => {
const headerEntries = Object.entries(formData.headers || {})
if (headerEntries.length === 1) {
// If this is the only header, just clear it instead of deleting
setFormData((prev) => ({ ...prev, headers: { '': '' } }))
} else {
// Delete this header
const newHeaders = { ...formData.headers }
delete newHeaders[key]
setFormData((prev) => ({ ...prev, headers: newHeaders }))
}
}}
className='h-9 w-9 p-0 text-muted-foreground hover:text-foreground'
>
<X className='h-3 w-3' />
</Button>
{/* Environment Variables Dropdown for Header Key */}
{showEnvVars &&
activeInputField === 'header-key' &&
activeHeaderIndex === index && (
<EnvVarDropdown
visible={showEnvVars}
onSelect={handleEnvVarSelect}
searchTerm={searchTerm}
inputValue={key}
cursorPosition={cursorPosition}
workspaceId={workspaceId}
onClose={() => {
setShowEnvVars(false)
setActiveInputField(null)
setActiveHeaderIndex(null)
}}
className='w-full'
maxHeight='150px'
style={{
position: 'absolute',
top: '100%',
left: 0,
zIndex: 9999,
}}
/>
)}
{/* Environment Variables Dropdown for Header Value */}
{showEnvVars &&
activeInputField === 'header-value' &&
activeHeaderIndex === index && (
<EnvVarDropdown
visible={showEnvVars}
onSelect={handleEnvVarSelect}
searchTerm={searchTerm}
inputValue={value}
cursorPosition={cursorPosition}
workspaceId={workspaceId}
onClose={() => {
setShowEnvVars(false)
setActiveInputField(null)
setActiveHeaderIndex(null)
}}
className='w-full'
maxHeight='250px'
style={{
position: 'absolute',
top: '100%',
right: 0,
zIndex: 9999,
}}
/>
)}
</div>
))}
</div>
</div>
{error && (
<div className='rounded-md bg-destructive/10 px-3 py-2 text-destructive text-sm'>
{error}
</div>
)}
{/* Test Connection and Actions */}
<div className='border-t pt-4'>
<div className='flex items-center justify-between'>
<div className='space-y-2'>
<div className='flex items-center gap-2'>
<Button
type='button'
variant='ghost'
size='sm'
onClick={handleTestConnection}
disabled={isTestingConnection || !formData.name.trim() || !formData.url?.trim()}
className='text-muted-foreground hover:text-foreground'
>
{isTestingConnection ? 'Testing...' : 'Test Connection'}
</Button>
{testResult?.success && (
<span className='text-green-600 text-xs'> Connected</span>
)}
</div>
{testResult && !testResult.success && (
<div className='rounded border border-red-200 bg-red-50 px-2 py-1.5 text-red-600 text-xs dark:border-red-800 dark:bg-red-950/20'>
<div className='font-medium'>Connection failed</div>
<div className='text-red-500 dark:text-red-400'>
{testResult.error || testResult.message}
</div>
</div>
)}
</div>
<div className='flex gap-2'>
<Button
variant='ghost'
size='sm'
onClick={() => {
resetForm()
onOpenChange(false)
}}
disabled={createServerMutation.isPending}
>
Cancel
</Button>
<Button
size='sm'
onClick={handleSubmit}
disabled={
createServerMutation.isPending || !formData.name.trim() || !formData.url?.trim()
}
>
{createServerMutation.isPending ? 'Adding...' : 'Add Server'}
</Button>
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Check, ChevronDown, ExternalLink, Plus, RefreshCw } from 'lucide-react'
import { Button } from '@/components/emcn/components/button/button'
import {
@@ -11,7 +11,6 @@ import {
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { createLogger } from '@/lib/logs/console/logger'
import {
type Credential,
getCanonicalScopesForProvider,
getProviderIdFromServiceId,
OAUTH_PROVIDERS,
@@ -20,8 +19,8 @@ import {
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 { useOAuthCredentialDetail, useOAuthCredentials } from '@/hooks/queries/oauth-credentials'
import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
import { useDisplayNamesStore } from '@/stores/display-names/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('ToolCredentialSelector')
@@ -70,8 +69,6 @@ export function ToolCredentialSelector({
disabled = false,
}: ToolCredentialSelectorProps) {
const [open, setOpen] = useState(false)
const [credentials, setCredentials] = useState<Credential[]>([])
const [isLoading, setIsLoading] = useState(false)
const [showOAuthModal, setShowOAuthModal] = useState(false)
const [selectedId, setSelectedId] = useState('')
const { activeWorkflowId } = useWorkflowRegistry()
@@ -80,80 +77,43 @@ export function ToolCredentialSelector({
setSelectedId(value)
}, [value])
const fetchCredentials = useCallback(async () => {
setIsLoading(true)
try {
const response = await fetch(`/api/auth/oauth/credentials?provider=${provider}`)
if (response.ok) {
const data = await response.json()
setCredentials(data.credentials || [])
const {
data: fetchedCredentials = [],
isFetching: credentialsLoading,
refetch: refetchCredentials,
} = useOAuthCredentials(provider, true)
// Cache credential names for block previews
if (provider) {
const credentialMap = (data.credentials || []).reduce(
(acc: Record<string, string>, cred: Credential) => {
acc[cred.id] = cred.name
return acc
},
{}
)
useDisplayNamesStore.getState().setDisplayNames('credentials', provider, credentialMap)
}
const shouldFetchDetail =
Boolean(value) &&
!fetchedCredentials.some((cred) => cred.id === value) &&
Boolean(activeWorkflowId)
if (
value &&
!(data.credentials || []).some((cred: Credential) => cred.id === value) &&
activeWorkflowId
) {
try {
const metaResp = await fetch(
`/api/auth/oauth/credentials?credentialId=${value}&workflowId=${activeWorkflowId}`
)
if (metaResp.ok) {
const meta = await metaResp.json()
if (meta.credentials?.length) {
const combinedCredentials = [meta.credentials[0], ...(data.credentials || [])]
setCredentials(combinedCredentials)
const { data: collaboratorCredentials = [], isFetching: collaboratorLoading } =
useOAuthCredentialDetail(
shouldFetchDetail ? value : undefined,
activeWorkflowId || undefined,
shouldFetchDetail
)
const credentialMap = combinedCredentials.reduce(
(acc: Record<string, string>, cred: Credential) => {
acc[cred.id] = cred.name
return acc
},
{}
)
useDisplayNamesStore
.getState()
.setDisplayNames('credentials', provider, credentialMap)
}
}
} catch {
// ignore
}
}
} else {
logger.error('Error fetching credentials:', { error: await response.text() })
setCredentials([])
}
} catch (error) {
logger.error('Error fetching credentials:', { error })
setCredentials([])
} finally {
setIsLoading(false)
const credentials = useMemo(() => {
if (collaboratorCredentials.length === 0) {
return fetchedCredentials
}
}, [provider, value, onChange])
// Fetch credentials on initial mount only
useEffect(() => {
fetchCredentials()
// This effect should only run once on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const collaborator = collaboratorCredentials[0]
if (!collaborator) {
return fetchedCredentials
}
const alreadyIncluded = fetchedCredentials.some((cred) => cred.id === collaborator.id)
if (alreadyIncluded) {
return fetchedCredentials
}
return [collaborator, ...fetchedCredentials]
}, [fetchedCredentials, collaboratorCredentials])
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
fetchCredentials()
void refetchCredentials()
}
}
@@ -162,7 +122,7 @@ export function ToolCredentialSelector({
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
}, [fetchCredentials])
}, [refetchCredentials])
const handleSelect = (credentialId: string) => {
setSelectedId(credentialId)
@@ -172,13 +132,13 @@ export function ToolCredentialSelector({
const handleOAuthClose = () => {
setShowOAuthModal(false)
fetchCredentials()
void refetchCredentials()
}
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen)
if (isOpen) {
fetchCredentials()
void refetchCredentials()
}
}
@@ -190,7 +150,8 @@ export function ToolCredentialSelector({
const missingRequiredScopes = hasSelection
? getMissingRequiredScopes(selectedCredential, requiredScopes || [])
: []
const needsUpdate = hasSelection && missingRequiredScopes.length > 0 && !disabled && !isLoading
const needsUpdate =
hasSelection && missingRequiredScopes.length > 0 && !disabled && !credentialsLoading
return (
<>
@@ -224,7 +185,7 @@ export function ToolCredentialSelector({
<Command>
<CommandList>
<CommandEmpty>
{isLoading ? (
{credentialsLoading || collaboratorLoading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading...</span>

View File

@@ -1642,19 +1642,19 @@ export function ToolInput({
<p className='text-xs'>
{tool.usageControl === 'auto' && (
<span>
<span className='font-medium'>Auto:</span> The model decides when to
use the tool
<span className='font-medium' /> 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 className='font-medium' /> Always use this tool in the
response
</span>
)}
{tool.usageControl === 'none' && (
<span>
<span className='font-medium'>Deny:</span> Never use this tool
<span className='font-medium' /> Never use this tool
</span>
)}
</p>

View File

@@ -1,17 +1,9 @@
import { useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Badge, Button, Combobox, Input } from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { cn } from '@/lib/utils'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/formatted-text'
@@ -68,6 +60,7 @@ export function VariablesInput({
const valueInputRefs = useRef<Record<string, HTMLInputElement | HTMLTextAreaElement>>({})
const overlayRefs = useRef<Record<string, HTMLDivElement>>({})
const [dragHighlight, setDragHighlight] = useState<Record<string, boolean>>({})
const [collapsedAssignments, setCollapsedAssignments] = useState<Record<string, boolean>>({})
const currentWorkflowVariables = Object.values(workflowVariables).filter(
(v: Variable) => v.workflowId === workflowId
@@ -75,6 +68,7 @@ export function VariablesInput({
const value = isPreview ? previewValue : storeValue
const assignments: VariableAssignment[] = value || []
const isReadOnly = isPreview || disabled
const getAvailableVariablesFor = (currentAssignmentId: string) => {
const otherSelectedIds = new Set(
@@ -91,8 +85,41 @@ export function VariablesInput({
const allVariablesAssigned =
!hasNoWorkflowVariables && getAvailableVariablesFor('new').length === 0
// Initialize with one empty assignment if none exist and not in preview/disabled mode
// Also add assignment when first variable is created
useEffect(() => {
if (!isReadOnly && assignments.length === 0 && currentWorkflowVariables.length > 0) {
const initialAssignment: VariableAssignment = {
...DEFAULT_ASSIGNMENT,
id: crypto.randomUUID(),
}
setStoreValue([initialAssignment])
}
}, [currentWorkflowVariables.length, isReadOnly, assignments.length, setStoreValue])
// Clean up assignments when their associated variables are deleted
useEffect(() => {
if (isReadOnly || assignments.length === 0) return
const currentVariableIds = new Set(currentWorkflowVariables.map((v) => v.id))
const validAssignments = assignments.filter((assignment) => {
// Keep assignments that haven't selected a variable yet
if (!assignment.variableId) return true
// Keep assignments whose variable still exists
return currentVariableIds.has(assignment.variableId)
})
// If all variables were deleted, clear all assignments
if (currentWorkflowVariables.length === 0) {
setStoreValue([])
} else if (validAssignments.length !== assignments.length) {
// Some assignments reference deleted variables, remove them
setStoreValue(validAssignments.length > 0 ? validAssignments : [])
}
}, [currentWorkflowVariables, assignments, isReadOnly, setStoreValue])
const addAssignment = () => {
if (isPreview || disabled) return
if (isPreview || disabled || allVariablesAssigned) return
const newAssignment: VariableAssignment = {
...DEFAULT_ASSIGNMENT,
@@ -219,6 +246,13 @@ export function VariablesInput({
setDragHighlight((prev) => ({ ...prev, [assignmentId]: false }))
}
const toggleCollapse = (assignmentId: string) => {
setCollapsedAssignments((prev) => ({
...prev,
[assignmentId]: !prev[assignmentId],
}))
}
if (isPreview && (!assignments || assignments.length === 0)) {
return (
<div className='flex flex-col items-center justify-center rounded-md border border-border/40 border-dashed bg-muted/20 py-8 text-center'>
@@ -244,225 +278,195 @@ export function VariablesInput({
}
if (!isPreview && hasNoWorkflowVariables && assignments.length === 0) {
return (
<div className='flex flex-col items-center justify-center rounded-lg border border-border/50 bg-muted/30 p-8 text-center'>
<svg
className='mb-3 h-10 w-10 text-muted-foreground/60'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={1.5}
d='M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'
/>
</svg>
<p className='font-medium text-muted-foreground text-sm'>No variables found</p>
<p className='mt-1 text-muted-foreground/80 text-xs'>
Add variables in the Variables panel to get started
</p>
</div>
)
return <p className='text-[var(--text-muted)] text-sm'>No variables available</p>
}
return (
<div className='space-y-2'>
{assignments && assignments.length > 0 ? (
<div className='space-y-2'>
{assignments.map((assignment) => {
<div className='space-y-[8px]'>
{assignments && assignments.length > 0 && (
<div className='space-y-[8px]'>
{assignments.map((assignment, index) => {
const collapsed = collapsedAssignments[assignment.id] || false
const availableVars = getAvailableVariablesFor(assignment.id)
return (
<div
key={assignment.id}
className='group relative rounded-lg border border-border/50 bg-background/50 p-3 transition-all hover:border-border hover:bg-background'
>
{!isPreview && !disabled && (
<Button
variant='ghost'
size='icon'
className='absolute top-2 right-2 h-6 w-6 opacity-0 transition-opacity group-hover:opacity-100'
onClick={() => removeAssignment(assignment.id)}
>
<Trash className='h-3.5 w-3.5 text-muted-foreground hover:text-destructive' />
</Button>
data-assignment-id={assignment.id}
className={cn(
'rounded-[4px] border border-[var(--border-strong)] bg-[#1F1F1F]',
collapsed ? 'overflow-hidden' : 'overflow-visible'
)}
<div className='space-y-3'>
<div className='space-y-1.5'>
<div className='flex items-center justify-between pr-8'>
<Label className='text-xs'>Variable</Label>
{assignment.variableName && (
<span className='rounded-md bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground'>
{assignment.type}
</span>
)}
</div>
<Select
value={assignment.variableId || assignment.variableName || ''}
onValueChange={(value) => {
if (value === '__new__') {
return
}
handleVariableSelect(assignment.id, value)
}}
disabled={isPreview || disabled}
>
<SelectTrigger className='h-9 border border-input bg-white dark:border-input/60 dark:bg-background'>
<SelectValue placeholder='Select a variable...' />
</SelectTrigger>
<SelectContent>
{(() => {
const availableVars = getAvailableVariablesFor(assignment.id)
return availableVars.length > 0 ? (
availableVars.map((variable) => (
<SelectItem key={variable.id} value={variable.id}>
{variable.name}
</SelectItem>
))
) : (
<div className='p-2 text-center text-muted-foreground text-sm'>
{currentWorkflowVariables.length > 0
? 'All variables have been assigned.'
: 'No variables defined in this workflow.'}
{currentWorkflowVariables.length === 0 && (
<>
<br />
Add them in the Variables panel.
</>
)}
</div>
)
})()}
</SelectContent>
</Select>
>
<div
className='flex cursor-pointer items-center justify-between bg-transparent px-[10px] py-[5px]'
onClick={() => toggleCollapse(assignment.id)}
>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
{assignment.variableName || `Variable ${index + 1}`}
</span>
{assignment.variableName && (
<Badge className='h-[20px] text-[13px]'>{assignment.type}</Badge>
)}
</div>
<div className='space-y-1.5'>
<Label className='text-xs'>Value</Label>
{assignment.type === 'object' || assignment.type === 'array' ? (
<div className='relative'>
<Textarea
ref={(el) => {
if (el) valueInputRefs.current[assignment.id] = el
}}
value={assignment.value || ''}
onChange={(e) =>
handleValueInputChange(
assignment.id,
e.target.value,
e.target.selectionStart ?? undefined
)
}
placeholder={
assignment.type === 'object'
? '{\n "key": "value"\n}'
: '[\n 1, 2, 3\n]'
}
disabled={isPreview || disabled}
className={cn(
'min-h-[120px] border border-input bg-white font-mono text-sm text-transparent caret-foreground placeholder:text-muted-foreground/50 dark:border-input/60 dark:bg-background',
dragHighlight[assignment.id] && 'ring-2 ring-blue-500 ring-offset-2'
)}
style={{
fontFamily: 'inherit',
lineHeight: 'inherit',
wordBreak: 'break-word',
whiteSpace: 'pre-wrap',
}}
onDrop={(e) => handleDrop(e, assignment.id)}
onDragOver={(e) => handleDragOver(e, assignment.id)}
onDragLeave={(e) => handleDragLeave(e, assignment.id)}
/>
<div
ref={(el) => {
if (el) overlayRefs.current[assignment.id] = el
}}
className='pointer-events-none absolute inset-0 flex items-start overflow-auto bg-transparent px-3 py-2 font-mono text-sm'
style={{
fontFamily: 'inherit',
lineHeight: 'inherit',
}}
>
<div className='w-full whitespace-pre-wrap break-words'>
{formatDisplayText(assignment.value || '', {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
</div>
</div>
</div>
) : (
<div className='relative'>
<Input
ref={(el) => {
if (el) valueInputRefs.current[assignment.id] = el
}}
value={assignment.value || ''}
onChange={(e) =>
handleValueInputChange(
assignment.id,
e.target.value,
e.target.selectionStart ?? undefined
)
}
placeholder={`${assignment.type} value`}
disabled={isPreview || disabled}
autoComplete='off'
className={cn(
'h-9 border border-input bg-white text-transparent caret-foreground placeholder:text-muted-foreground/50 dark:border-input/60 dark:bg-background',
dragHighlight[assignment.id] && 'ring-2 ring-blue-500 ring-offset-2'
)}
onDrop={(e) => handleDrop(e, assignment.id)}
onDragOver={(e) => handleDragOver(e, assignment.id)}
onDragLeave={(e) => handleDragLeave(e, assignment.id)}
/>
<div
ref={(el) => {
if (el) overlayRefs.current[assignment.id] = el
}}
className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-3 text-sm'
>
<div className='w-full whitespace-nowrap'>
{formatDisplayText(assignment.value || '', {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
</div>
</div>
</div>
)}
{showTags && activeFieldId === assignment.id && (
<TagDropdown
visible={showTags}
onSelect={handleTagSelect}
blockId={blockId}
activeSourceBlockId={activeSourceBlockId}
inputValue={assignment.value || ''}
cursorPosition={cursorPosition}
onClose={() => setShowTags(false)}
className='absolute top-full left-0 z-50 mt-1'
/>
)}
<div
className='flex items-center gap-[8px] pl-[8px]'
onClick={(e) => e.stopPropagation()}
>
<Button
variant='ghost'
onClick={addAssignment}
disabled={isPreview || disabled || allVariablesAssigned}
className='h-auto p-0'
>
<Plus className='h-[14px] w-[14px]' />
<span className='sr-only'>Add Variable</span>
</Button>
<Button
variant='ghost'
onClick={() => removeAssignment(assignment.id)}
disabled={isPreview || disabled || assignments.length === 1}
className='h-auto p-0 text-[var(--text-error)] hover:text-[var(--text-error)]'
>
<Trash className='h-[14px] w-[14px]' />
<span className='sr-only'>Delete Variable</span>
</Button>
</div>
</div>
{!collapsed && (
<div className='flex flex-col gap-[6px] border-[var(--border-strong)] border-t px-[10px] pt-[6px] pb-[10px]'>
<div className='flex flex-col gap-[4px]'>
<Label className='text-[13px]'>Variable</Label>
<Combobox
options={availableVars.map((v) => ({ label: v.name, value: v.id }))}
value={assignment.variableId || assignment.variableName || ''}
onChange={(value) => handleVariableSelect(assignment.id, value)}
placeholder='Select a variable...'
disabled={isPreview || disabled}
/>
</div>
<div className='space-y-[4px]'>
<Label className='text-[13px]'>Value</Label>
{assignment.type === 'object' || assignment.type === 'array' ? (
<div className='relative'>
<Textarea
ref={(el) => {
if (el) valueInputRefs.current[assignment.id] = el
}}
value={assignment.value || ''}
onChange={(e) =>
handleValueInputChange(
assignment.id,
e.target.value,
e.target.selectionStart ?? undefined
)
}
placeholder={
assignment.type === 'object'
? '{\n "key": "value"\n}'
: '[\n 1, 2, 3\n]'
}
disabled={isPreview || disabled}
className={cn(
'min-h-[120px] font-mono text-sm text-transparent caret-foreground placeholder:text-muted-foreground/50',
dragHighlight[assignment.id] && 'ring-2 ring-blue-500 ring-offset-2'
)}
style={{
fontFamily: 'inherit',
lineHeight: 'inherit',
wordBreak: 'break-word',
whiteSpace: 'pre-wrap',
}}
onDrop={(e) => handleDrop(e, assignment.id)}
onDragOver={(e) => handleDragOver(e, assignment.id)}
onDragLeave={(e) => handleDragLeave(e, assignment.id)}
/>
<div
ref={(el) => {
if (el) overlayRefs.current[assignment.id] = el
}}
className='pointer-events-none absolute inset-0 flex items-start overflow-auto bg-transparent px-3 py-2 font-mono text-sm'
style={{
fontFamily: 'inherit',
lineHeight: 'inherit',
}}
>
<div className='w-full whitespace-pre-wrap break-words'>
{formatDisplayText(assignment.value || '', {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
</div>
</div>
</div>
) : (
<div className='relative'>
<Input
ref={(el) => {
if (el) valueInputRefs.current[assignment.id] = el
}}
name='value'
value={assignment.value || ''}
onChange={(e) =>
handleValueInputChange(
assignment.id,
e.target.value,
e.target.selectionStart ?? undefined
)
}
placeholder={`${assignment.type} value`}
disabled={isPreview || disabled}
autoComplete='off'
className={cn(
'allow-scroll w-full overflow-auto text-transparent caret-foreground',
dragHighlight[assignment.id] && 'ring-2 ring-blue-500 ring-offset-2'
)}
style={{ overflowX: 'auto' }}
onDrop={(e) => handleDrop(e, assignment.id)}
onDragOver={(e) => handleDragOver(e, assignment.id)}
onDragLeave={(e) => handleDragLeave(e, assignment.id)}
/>
<div
ref={(el) => {
if (el) overlayRefs.current[assignment.id] = el
}}
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
className='w-full whitespace-pre'
style={{ scrollbarWidth: 'none', minWidth: 'fit-content' }}
>
{formatDisplayText(
assignment.value || '',
accessiblePrefixes ? { accessiblePrefixes } : { highlightAll: true }
)}
</div>
</div>
</div>
)}
{showTags && activeFieldId === assignment.id && (
<TagDropdown
visible={showTags}
onSelect={handleTagSelect}
blockId={blockId}
activeSourceBlockId={activeSourceBlockId}
inputValue={assignment.value || ''}
cursorPosition={cursorPosition}
onClose={() => setShowTags(false)}
/>
)}
</div>
</div>
)}
</div>
)
})}
</div>
) : null}
{!isPreview && !disabled && !hasNoWorkflowVariables && (
<Button
onClick={addAssignment}
variant='outline'
className='h-9 w-full border-dashed'
disabled={allVariablesAssigned}
>
<Plus className='mr-2 h-4 w-4' />
{allVariablesAssigned ? 'All Variables Assigned' : 'Add Variable Assignment'}
</Button>
)}
</div>
)

View File

@@ -210,9 +210,14 @@ export function Editor() {
/>
) : (
<h2
className='min-w-0 flex-1 cursor-pointer truncate pr-[8px] font-medium text-[14px] text-[var(--white)] dark:text-[var(--white)]'
className='min-w-0 flex-1 cursor-pointer select-none truncate pr-[8px] font-medium text-[14px] text-[var(--white)] dark:text-[var(--white)]'
title={title}
onDoubleClick={handleStartRename}
onMouseDown={(e) => {
if (e.detail === 2) {
e.preventDefault()
}
}}
>
{title}
</h2>

View File

@@ -7,6 +7,7 @@ import {
} from '@/lib/workflows/references'
import { checkTagTrigger } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { normalizeBlockName } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -133,13 +134,14 @@ export function useSubflowEditor(currentBlock: BlockState | null, currentBlockId
let processedCode = code
processedCode = processedCode.replace(/\{\{([^}]+)\}\}/g, (match) => {
processedCode = processedCode.replace(createEnvVarPattern(), (match) => {
const placeholder = `__ENV_VAR_${placeholders.length}__`
placeholders.push({ placeholder, original: match, type: 'env' })
return placeholder
})
processedCode = processedCode.replace(/<[^>]+>/g, (match) => {
// Use [^<>]+ to prevent matching across nested brackets (e.g., "<3 <real.ref>" should match separately)
processedCode = processedCode.replace(createReferencePattern(), (match) => {
if (shouldHighlightReference(match)) {
const placeholder = `__VAR_REF_${placeholders.length}__`
placeholders.push({ placeholder, original: match, type: 'var' })

View File

@@ -0,0 +1,211 @@
'use client'
import { useMemo } from 'react'
import { useParams } from 'next/navigation'
import { Tooltip } from '@/components/emcn'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-foreign-credential'
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 type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface FileSelectorInputProps {
blockId: string
subBlock: SubBlockConfig
disabled: boolean
isPreview?: boolean
previewValue?: any | null
previewContextValues?: Record<string, any>
}
export function FileSelectorInput({
blockId,
subBlock,
disabled,
isPreview = false,
previewValue,
previewContextValues,
}: FileSelectorInputProps) {
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
const { activeWorkflowId } = useWorkflowRegistry()
const params = useParams()
const workflowIdFromUrl = (params?.workflowId as string) || activeWorkflowId || ''
const { finalDisabled } = useDependsOnGate(blockId, subBlock, {
disabled,
isPreview,
previewContextValues,
})
const [connectedCredentialFromStore] = useSubBlockValue(blockId, 'credential')
const [domainValueFromStore] = useSubBlockValue(blockId, 'domain')
const [projectIdValueFromStore] = useSubBlockValue(blockId, 'projectId')
const [planIdValueFromStore] = useSubBlockValue(blockId, 'planId')
const [teamIdValueFromStore] = useSubBlockValue(blockId, 'teamId')
const connectedCredential = previewContextValues?.credential ?? connectedCredentialFromStore
const domainValue = previewContextValues?.domain ?? domainValueFromStore
const projectIdValue = previewContextValues?.projectId ?? projectIdValueFromStore
const planIdValue = previewContextValues?.planId ?? planIdValueFromStore
const teamIdValue = previewContextValues?.teamId ?? teamIdValueFromStore
const normalizedCredentialId =
typeof connectedCredential === 'string'
? connectedCredential
: typeof connectedCredential === 'object' && connectedCredential !== null
? ((connectedCredential as Record<string, any>).id ?? '')
: ''
const { isForeignCredential } = useForeignCredential(
subBlock.provider || subBlock.serviceId || 'google-drive',
normalizedCredentialId
)
const selectorResolution = useMemo(() => {
return resolveSelector({
provider: subBlock.provider || '',
serviceId: subBlock.serviceId,
mimeType: subBlock.mimeType,
credentialId: normalizedCredentialId,
workflowId: workflowIdFromUrl,
domain: (domainValue as string) || '',
projectId: (projectIdValue as string) || '',
planId: (planIdValue as string) || '',
teamId: (teamIdValue as string) || '',
})
}, [
subBlock.provider,
subBlock.serviceId,
subBlock.mimeType,
normalizedCredentialId,
workflowIdFromUrl,
domainValue,
projectIdValue,
planIdValue,
teamIdValue,
])
const missingCredential = !normalizedCredentialId
const missingDomain =
selectorResolution.key &&
(selectorResolution.key === 'confluence.pages' || selectorResolution.key === 'jira.issues') &&
!selectorResolution.context.domain
const missingProject =
selectorResolution.key === 'jira.issues' &&
subBlock.dependsOn?.includes('projectId') &&
!selectorResolution.context.projectId
const missingPlan =
selectorResolution.key === 'microsoft.planner' && !selectorResolution.context.planId
const disabledReason =
finalDisabled ||
isForeignCredential ||
missingCredential ||
missingDomain ||
missingProject ||
missingPlan ||
selectorResolution.key === null
if (selectorResolution.key === null) {
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='w-full rounded border border-dashed p-4 text-center text-muted-foreground text-sm'>
File selector not supported for provider: {subBlock.provider || subBlock.serviceId}
</div>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>This file selector is not implemented for {subBlock.provider || subBlock.serviceId}</p>
</Tooltip.Content>
</Tooltip.Root>
)
}
return (
<SelectorCombobox
blockId={blockId}
subBlock={subBlock}
selectorKey={selectorResolution.key}
selectorContext={selectorResolution.context}
disabled={disabledReason}
isPreview={isPreview}
previewValue={previewValue ?? null}
placeholder={subBlock.placeholder || 'Select resource'}
allowSearch={selectorResolution.allowSearch}
onOptionChange={(value) => {
if (!isPreview) {
collaborativeSetSubblockValue(blockId, subBlock.id, value)
}
}}
/>
)
}
interface SelectorParams {
provider: string
serviceId?: string
mimeType?: string
credentialId: string
workflowId: string
domain?: string
projectId?: string
planId?: string
teamId?: string
}
function resolveSelector(params: SelectorParams): {
key: SelectorKey | null
context: SelectorContext
allowSearch: boolean
} {
const baseContext: SelectorContext = {
credentialId: params.credentialId,
workflowId: params.workflowId,
domain: params.domain,
projectId: params.projectId,
planId: params.planId,
teamId: params.teamId,
mimeType: params.mimeType,
}
switch (params.provider) {
case 'google-calendar':
return { key: 'google.calendar', context: baseContext, allowSearch: false }
case 'confluence':
return { key: 'confluence.pages', context: baseContext, allowSearch: true }
case 'jira':
return { key: 'jira.issues', context: baseContext, allowSearch: true }
case 'microsoft-teams':
return { key: 'microsoft.teams', context: baseContext, allowSearch: true }
case 'wealthbox':
return { key: 'wealthbox.contacts', context: baseContext, allowSearch: true }
case 'microsoft-planner':
return { key: 'microsoft.planner', context: baseContext, allowSearch: true }
case 'microsoft-excel':
return { key: 'microsoft.excel', context: baseContext, allowSearch: true }
case 'microsoft-word':
return { key: 'microsoft.word', context: baseContext, allowSearch: true }
case 'google-drive':
return { key: 'google.drive', context: baseContext, allowSearch: true }
default:
break
}
if (params.serviceId === 'onedrive') {
const key: SelectorKey = params.mimeType === 'file' ? 'onedrive.files' : 'onedrive.folders'
return { key, context: baseContext, allowSearch: true }
}
if (params.serviceId === 'sharepoint') {
return { key: 'sharepoint.sites', context: baseContext, allowSearch: true }
}
if (params.serviceId === 'google-drive') {
return { key: 'google.drive', context: baseContext, allowSearch: true }
}
return { key: null, context: baseContext, allowSearch: true }
}

View File

@@ -1,7 +1,7 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Braces, Square } from 'lucide-react'
import { ArrowDown, Braces, Square } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import {
BubbleChatPreview,
@@ -22,12 +22,13 @@ import {
PopoverTrigger,
Trash,
} from '@/components/emcn'
import { VariableIcon } from '@/components/icons'
import { createLogger } from '@/lib/logs/console/logger'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { Variables } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables'
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
import { useDeleteWorkflow } from '@/app/workspace/[workspaceId]/w/hooks'
import { useDeleteWorkflow, useImportWorkflow } 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'
@@ -62,6 +63,7 @@ export function Panel() {
const workspaceId = params.workspaceId as string
const panelRef = useRef<HTMLElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const { activeTab, setActiveTab, panelWidth, _hasHydrated, setHasHydrated } = usePanelStore()
const copilotRef = useRef<{
createNewChat: () => void
@@ -77,6 +79,7 @@ export function Panel() {
// Hooks
const userPermissions = useUserPermissionsContext()
const { isImporting, handleFileChange } = useImportWorkflow({ workspaceId })
const {
workflows,
activeWorkflowId,
@@ -262,6 +265,14 @@ export function Panel() {
workspaceId,
])
/**
* Handles triggering file input for workflow import
*/
const handleImportWorkflow = useCallback(() => {
setIsMenuOpen(false)
fileInputRef.current?.click()
}, [])
// Compute run button state
const canRun = userPermissions.canRead // Running only requires read permissions
const isLoadingPermissions = userPermissions.isLoading
@@ -314,7 +325,7 @@ export function Panel() {
</PopoverItem>
{
<PopoverItem onClick={() => setVariablesOpen(!isVariablesOpen)}>
<Braces className='h-3 w-3' />
<VariableIcon className='h-3 w-3' />
<span>Variables</span>
</PopoverItem>
}
@@ -331,7 +342,14 @@ export function Panel() {
disabled={isExporting || !currentWorkflow}
>
<Braces className='h-3 w-3' />
<span>Export JSON</span>
<span>Export workflow</span>
</PopoverItem>
<PopoverItem
onClick={handleImportWorkflow}
disabled={isImporting || !userPermissions.canEdit}
>
<ArrowDown className='h-3 w-3' />
<span>Import workflow</span>
</PopoverItem>
<PopoverItem
onClick={handleDuplicateWorkflow}
@@ -499,6 +517,16 @@ export function Panel() {
{/* Floating Variables Modal */}
<Variables />
{/* Hidden file input for workflow import */}
<input
ref={fileInputRef}
type='file'
accept='.json,.zip'
multiple
style={{ display: 'none' }}
onChange={handleFileChange}
/>
</>
)
}

View File

@@ -74,10 +74,10 @@ export const ActionBar = memo(
return (
<div
className={cn(
'-right-20 absolute top-0',
'flex flex-col items-center',
'-top-[46px] absolute right-0',
'flex flex-row items-center',
'opacity-0 transition-opacity duration-200 group-hover:opacity-100',
'gap-[6px] rounded-[10px] bg-[var(--surface-3)] p-[6px]'
'gap-[5px] rounded-[10px] bg-[var(--surface-3)] p-[5px]'
)}
>
<Tooltip.Root>
@@ -90,17 +90,17 @@ export const ActionBar = memo(
collaborativeToggleBlockEnabled(blockId)
}
}}
className='h-[30px] w-[30px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)] hover:text-[var(--bg)] dark:text-[#868686] dark:hover:bg-[var(--brand-secondary)] dark:hover:text-[var(--bg)]'
className='h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)] hover:text-[var(--bg)] dark:text-[#868686] dark:hover:bg-[var(--brand-secondary)] dark:hover:text-[var(--bg)]'
disabled={disabled}
>
{isEnabled ? (
<Circle className='h-[14px] w-[14px]' />
<Circle className='h-[11px] w-[11px]' />
) : (
<CircleOff className='h-[14px] w-[14px]' />
<CircleOff className='h-[11px] w-[11px]' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='right'>
<Tooltip.Content side='top'>
{getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')}
</Tooltip.Content>
</Tooltip.Root>
@@ -116,13 +116,13 @@ export const ActionBar = memo(
collaborativeDuplicateBlock(blockId)
}
}}
className='h-[30px] w-[30px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)] hover:text-[var(--bg)] dark:text-[#868686] dark:hover:bg-[var(--brand-secondary)] dark:hover:text-[var(--bg)]'
className='h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)] hover:text-[var(--bg)] dark:text-[#868686] dark:hover:bg-[var(--brand-secondary)] dark:hover:text-[var(--bg)]'
disabled={disabled}
>
<Duplicate className='h-[14px] w-[14px]' />
<Duplicate className='h-[11px] w-[11px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='right'>{getTooltipMessage('Duplicate Block')}</Tooltip.Content>
<Tooltip.Content side='top'>{getTooltipMessage('Duplicate Block')}</Tooltip.Content>
</Tooltip.Root>
)}
@@ -139,15 +139,13 @@ export const ActionBar = memo(
)
}
}}
className='h-[30px] w-[30px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)] hover:text-[var(--bg)] dark:text-[#868686] dark:hover:bg-[var(--brand-secondary)] dark:hover:text-[var(--bg)]'
className='h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)] hover:text-[var(--bg)] dark:text-[#868686] dark:hover:bg-[var(--brand-secondary)] dark:hover:text-[var(--bg)]'
disabled={disabled || !userPermissions.canEdit}
>
<LogOut className='h-[14px] w-[14px]' />
<LogOut className='h-[11px] w-[11px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='right'>
{getTooltipMessage('Remove From Subflow')}
</Tooltip.Content>
<Tooltip.Content side='top'>{getTooltipMessage('Remove from Subflow')}</Tooltip.Content>
</Tooltip.Root>
)}
@@ -161,17 +159,17 @@ export const ActionBar = memo(
collaborativeToggleBlockHandles(blockId)
}
}}
className='h-[30px] w-[30px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)] hover:text-[var(--bg)] dark:text-[#868686] dark:hover:bg-[var(--brand-secondary)] dark:hover:text-[var(--bg)]'
className='h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)] hover:text-[var(--bg)] dark:text-[#868686] dark:hover:bg-[var(--brand-secondary)] dark:hover:text-[var(--bg)]'
disabled={disabled}
>
{horizontalHandles ? (
<ArrowLeftRight className='h-[14px] w-[14px]' />
<ArrowLeftRight className='h-[11px] w-[11px]' />
) : (
<ArrowUpDown className='h-[14px] w-[14px]' />
<ArrowUpDown className='h-[11px] w-[11px]' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='right'>
<Tooltip.Content side='top'>
{getTooltipMessage(horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports')}
</Tooltip.Content>
</Tooltip.Root>
@@ -186,13 +184,13 @@ export const ActionBar = memo(
collaborativeRemoveBlock(blockId)
}
}}
className='h-[30px] w-[30px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)] hover:text-[var(--bg)] dark:text-[#868686] dark:hover:bg-[var(--brand-secondary)] dark:hover:text-[var(--bg)] '
className='h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)] hover:text-[var(--bg)] dark:text-[#868686] dark:hover:bg-[var(--brand-secondary)] dark:hover:text-[var(--bg)] '
disabled={disabled}
>
<Trash2 className='h-[14px] w-[14px]' />
<Trash2 className='h-[11px] w-[11px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='right'>{getTooltipMessage('Delete Block')}</Tooltip.Content>
<Tooltip.Content side='top'>{getTooltipMessage('Delete Block')}</Tooltip.Content>
</Tooltip.Root>
</div>
)

View File

@@ -1,84 +1,23 @@
import { RepeatIcon, SplitIcon } from 'lucide-react'
import {
type ConnectedBlock,
useBlockConnections,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/hooks/use-block-connections'
import { getBlock } from '@/blocks'
import { useBlockConnections } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/hooks/use-block-connections'
interface ConnectionsProps {
blockId: string
horizontalHandles: boolean
}
/**
* Retrieves the icon component for a given connection block
* @param connection - The connected block to get the icon for
* @returns The icon component or null if not found
* Displays incoming connections at the bottom left of the workflow block
*/
function getConnectionIcon(connection: ConnectedBlock) {
const blockConfig = getBlock(connection.type)
if (blockConfig?.icon) {
return blockConfig.icon
}
if (connection.type === 'loop') {
return RepeatIcon
}
if (connection.type === 'parallel') {
return SplitIcon
}
return null
}
/**
* Displays incoming connections as compact floating text above the workflow block
*/
export function Connections({ blockId, horizontalHandles }: ConnectionsProps) {
export function Connections({ blockId }: ConnectionsProps) {
const { incomingConnections, hasIncomingConnections } = useBlockConnections(blockId)
if (!hasIncomingConnections) return null
const connectionCount = incomingConnections.length
const maxVisibleIcons = 4
const visibleConnections = incomingConnections.slice(0, maxVisibleIcons)
const remainingCount = connectionCount - maxVisibleIcons
const connectionText = `${connectionCount} ${connectionCount === 1 ? 'connection' : 'connections'}`
const connectionIcons = (
<>
{visibleConnections.map((connection: ConnectedBlock) => {
const Icon = getConnectionIcon(connection)
if (!Icon) return null
return (
<Icon key={connection.id} className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
)
})}
{remainingCount > 0 && (
<span className='text-[14px] text-[var(--text-tertiary)]'>+{remainingCount}</span>
)}
</>
)
if (!horizontalHandles) {
return (
<div className='-translate-x-full -translate-y-1/2 pointer-events-none absolute top-1/2 left-0 flex flex-col items-end gap-[8px] pr-[8px] opacity-0 transition-opacity group-hover:opacity-100'>
<span className='text-[14px] text-[var(--text-tertiary)] leading-[14px]'>
{connectionText}
</span>
<div className='flex items-center justify-end gap-[4px]'>{connectionIcons}</div>
</div>
)
}
return (
<div className='pointer-events-none absolute bottom-full left-0 ml-[8px] flex items-center gap-[8px] pb-[8px] opacity-0 transition-opacity group-hover:opacity-100'>
<span className='text-[14px] text-[var(--text-tertiary)]'>{connectionText}</span>
<div className='h-[14px] w-[1px] bg-[var(--text-tertiary)]' />
<div className='flex items-center gap-[4px]'>{connectionIcons}</div>
<div className='pointer-events-none absolute top-full left-0 ml-[8px] flex items-center gap-[8px] pt-[8px] opacity-0 transition-opacity group-hover:opacity-100'>
<span className='text-[12px] text-[var(--text-tertiary)]'>{connectionText}</span>
</div>
)
}

View File

@@ -13,9 +13,10 @@ import {
useBlockDimensions,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions'
import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types'
import { useCredentialName } from '@/hooks/queries/oauth-credentials'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useCredentialDisplay } from '@/hooks/use-credential-display'
import { useDisplayName } from '@/hooks/use-display-name'
import { useKnowledgeBaseName } from '@/hooks/use-knowledge-base-name'
import { useSelectorDisplayName } from '@/hooks/use-selector-display-name'
import { useVariablesStore } from '@/stores/panel/variables/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
@@ -230,9 +231,12 @@ const SubBlockRow = ({
}, {})
}, [getStringValue, subBlock?.dependsOn])
const { displayName: credentialName } = useCredentialDisplay(
subBlock?.type === 'oauth-input' && typeof rawValue === 'string' ? rawValue : undefined,
subBlock?.provider
const credentialSourceId =
subBlock?.type === 'oauth-input' && typeof rawValue === 'string' ? rawValue : undefined
const { displayName: credentialName } = useCredentialName(
credentialSourceId,
subBlock?.provider,
workflowId
)
const credentialId = dependencyValues.credential
@@ -253,17 +257,35 @@ const SubBlockRow = ({
return typeof option === 'string' ? option : option.label
}, [subBlock, rawValue])
const genericDisplayName = useDisplayName(subBlock, rawValue, {
workspaceId,
provider: subBlock?.provider,
const domainValue = getStringValue('domain')
const teamIdValue = getStringValue('teamId')
const projectIdValue = getStringValue('projectId')
const planIdValue = getStringValue('planId')
const { displayName: selectorDisplayName } = useSelectorDisplayName({
subBlock,
value: rawValue,
workflowId,
credentialId: typeof credentialId === 'string' ? credentialId : undefined,
knowledgeBaseId: typeof knowledgeBaseId === 'string' ? knowledgeBaseId : undefined,
domain: getStringValue('domain'),
teamId: getStringValue('teamId'),
projectId: getStringValue('projectId'),
planId: getStringValue('planId'),
domain: domainValue,
teamId: teamIdValue,
projectId: projectIdValue,
planId: planIdValue,
})
const knowledgeBaseDisplayName = useKnowledgeBaseName(
subBlock?.type === 'knowledge-base-selector' && typeof rawValue === 'string'
? rawValue
: undefined
)
const workflowMap = useWorkflowRegistry((state) => state.workflows)
const workflowSelectionName =
subBlock?.id === 'workflowId' && typeof rawValue === 'string'
? (workflowMap[rawValue]?.name ?? null)
: null
// Subscribe to variables store to reactively update when variables change
const allVariables = useVariablesStore((state) => state.variables)
@@ -300,7 +322,12 @@ const SubBlockRow = ({
const isSelectorType = subBlock?.type && SELECTOR_TYPES_HYDRATION_REQUIRED.includes(subBlock.type)
const hydratedName =
credentialName || dropdownLabel || variablesDisplayValue || genericDisplayName
credentialName ||
dropdownLabel ||
variablesDisplayValue ||
knowledgeBaseDisplayName ||
workflowSelectionName ||
selectorDisplayName
const displayValue = maskedValue || hydratedName || (isSelectorType && value ? '-' : value)
return (
@@ -343,6 +370,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
handleClick,
hasRing,
ringStyles,
runPathStatus,
} = useBlockCore({ blockId: id, data, isPending })
const currentBlock = currentWorkflow.getBlockById(id)
@@ -722,9 +750,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
<ActionBar blockId={id} blockType={type} disabled={!userPermissions.canEdit} />
{shouldShowDefaultHandles && (
<Connections blockId={id} horizontalHandles={horizontalHandles} />
)}
{shouldShowDefaultHandles && <Connections blockId={id} />}
{shouldShowDefaultHandles && (
<Handle
@@ -750,21 +776,26 @@ export const WorkflowBlock = memo(function WorkflowBlock({
e.stopPropagation()
}}
>
<div className='flex min-w-0 flex-1 items-center gap-[10px]'>
<div className='relative z-10 flex min-w-0 flex-1 items-center gap-[10px]'>
<div
className='flex h-[24px] w-[24px] flex-shrink-0 items-center justify-center rounded-[6px]'
style={{ backgroundColor: isEnabled ? config.bgColor : 'gray' }}
style={{
backgroundColor: isEnabled ? config.bgColor : 'gray',
}}
>
<config.icon className='h-[16px] w-[16px] text-white' />
</div>
<span
className={cn('truncate font-medium text-[16px]', !isEnabled && 'text-[#808080]')}
className={cn(
'truncate font-medium text-[16px]',
!isEnabled && runPathStatus !== 'success' && 'text-[#808080]'
)}
title={name}
>
{name}
</span>
</div>
<div className='flex flex-shrink-0 items-center gap-2'>
<div className='relative z-10 flex flex-shrink-0 items-center gap-2'>
{isWorkflowSelector && childWorkflowId && (
<>
{typeof childIsDeployed === 'boolean' ? (
@@ -890,6 +921,14 @@ export const WorkflowBlock = memo(function WorkflowBlock({
</Tooltip.Content>
</Tooltip.Root>
)}
{/* {isActive && (
<div className='mr-[2px] ml-2 flex h-[16px] w-[16px] items-center justify-center'>
<div
className='h-full w-full animate-spin-slow rounded-full border-[2.5px] border-[rgba(255,102,0,0.25)] border-t-[var(--warning)]'
aria-hidden='true'
/>
</div>
)} */}
</div>
</div>

View File

@@ -1,6 +1,7 @@
import { X } from 'lucide-react'
import { BaseEdge, EdgeLabelRenderer, type EdgeProps, getSmoothStepPath } from 'reactflow'
import type { EdgeDiffStatus } from '@/lib/workflows/diff/types'
import { useExecutionStore } from '@/stores/execution/store'
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
interface WorkflowEdgeProps extends EdgeProps {
@@ -43,6 +44,7 @@ export const WorkflowEdge = ({
const diffAnalysis = useWorkflowDiffStore((state) => state.diffAnalysis)
const isShowingDiff = useWorkflowDiffStore((state) => state.isShowingDiff)
const isDiffReady = useWorkflowDiffStore((state) => state.isDiffReady)
const lastRunEdges = useExecutionStore((state) => state.lastRunEdges)
const generateEdgeIdentity = (
sourceId: string,
@@ -78,10 +80,16 @@ export const WorkflowEdge = ({
const dataSourceHandle = (data as { sourceHandle?: string } | undefined)?.sourceHandle
const isErrorEdge = (sourceHandle ?? dataSourceHandle) === 'error'
// Check if this edge was traversed during last execution
const edgeRunStatus = lastRunEdges.get(id)
const getEdgeColor = () => {
if (edgeDiffStatus === 'deleted') return 'var(--text-error)'
if (isErrorEdge) return 'var(--text-error)'
if (edgeDiffStatus === 'new') return 'var(--brand-tertiary)'
// Show run path status if edge was traversed
if (edgeRunStatus === 'success') return 'var(--border-success)'
if (edgeRunStatus === 'error') return 'var(--text-error)'
return 'var(--surface-12)'
}

View File

@@ -30,7 +30,7 @@ export function useAccessibleReferencePrefixes(blockId?: string | null): Set<str
const starterBlock = Object.values(blocks).find(
(block) => block.type === 'starter' || block.type === 'start_trigger'
)
if (starterBlock) {
if (starterBlock && ancestorIds.includes(starterBlock.id)) {
accessibleIds.add(starterBlock.id)
}

View File

@@ -1,10 +1,10 @@
import { useCallback, useMemo } from 'react'
import { cn } from '@/lib/utils'
import { useExecutionStore } from '@/stores/execution/store'
import { usePanelEditorStore } from '@/stores/panel-new/editor/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useBlockState } from '../components/workflow-block/hooks'
import type { WorkflowBlockProps } from '../components/workflow-block/types'
import { getBlockRingStyles } from '../utils/block-ring-utils'
import { useCurrentWorkflow } from './use-current-workflow'
interface UseBlockCoreOptions {
@@ -43,60 +43,19 @@ export function useBlockCore({ blockId, data, isPending = false }: UseBlockCoreO
}, [blockId, setCurrentBlockId])
// Ring styling based on all states
// Priority: active (animated) > pending > focused > deleted > diff > run path
const { hasRing, ringStyles } = useMemo(() => {
const hasRing =
isActive ||
isPending ||
isFocused ||
diffStatus === 'new' ||
diffStatus === 'edited' ||
isDeletedBlock ||
!!runPathStatus
const ringStyles = cn(
// Executing block: animated ring cycling through gray tones (animation handles all styling)
isActive && 'animate-ring-pulse',
// Non-active states use standard ring utilities
!isActive && hasRing && 'ring-[1.75px]',
// Pending state: warning ring
!isActive && isPending && 'ring-[var(--warning)]',
// Focused (selected) state: brand ring
!isActive && !isPending && isFocused && 'ring-[var(--brand-secondary)]',
// Deleted state (highest priority after active/pending/focused)
!isActive && !isPending && !isFocused && isDeletedBlock && 'ring-[var(--text-error)]',
// Diff states
!isActive &&
!isPending &&
!isFocused &&
!isDeletedBlock &&
diffStatus === 'new' &&
'ring-[#22C55E]',
!isActive &&
!isPending &&
!isFocused &&
!isDeletedBlock &&
diffStatus === 'edited' &&
'ring-[var(--warning)]',
// Run path states (lowest priority - only show if no other states active)
!isActive &&
!isPending &&
!isFocused &&
!isDeletedBlock &&
!diffStatus &&
runPathStatus === 'success' &&
'ring-[var(--surface-14)]',
!isActive &&
!isPending &&
!isFocused &&
!isDeletedBlock &&
!diffStatus &&
runPathStatus === 'error' &&
'ring-[var(--text-error)]'
)
return { hasRing, ringStyles }
}, [isActive, isPending, isFocused, diffStatus, isDeletedBlock, runPathStatus])
// Priority: active (executing) > pending > focused > deleted > diff > run path
const { hasRing, ringClassName: ringStyles } = useMemo(
() =>
getBlockRingStyles({
isActive,
isPending,
isFocused,
isDeletedBlock,
diffStatus,
runPathStatus,
}),
[isActive, isPending, isFocused, isDeletedBlock, diffStatus, runPathStatus]
)
return {
// Workflow context
@@ -116,5 +75,6 @@ export function useBlockCore({ blockId, data, isPending = false }: UseBlockCoreO
// Ring styling
hasRing,
ringStyles,
runPathStatus,
}
}

View File

@@ -11,7 +11,7 @@ const DEFAULT_CONTAINER_HEIGHT = 300
* Hook providing utilities for node position, hierarchy, and dimension calculations
*/
export function useNodeUtilities(blocks: Record<string, any>) {
const { getNodes, project } = useReactFlow()
const { getNodes } = useReactFlow()
/**
* Check if a block is a container type (loop, parallel, or subflow)

View File

@@ -100,6 +100,7 @@ export function useWorkflowExecution() {
setDebugContext,
setActiveBlocks,
setBlockRunStatus,
setEdgeRunStatus,
} = useExecutionStore()
const [executionResult, setExecutionResult] = useState<ExecutionResult | null>(null)
const executionStream = useExecutionStream()
@@ -681,10 +682,10 @@ export function useWorkflowExecution() {
const workflowEdges = (executionWorkflowState?.edges ??
latestWorkflowState.edges) as typeof currentWorkflow.edges
// Filter out blocks without type (these are layout-only blocks)
// Filter out blocks without type (these are layout-only blocks) and disabled blocks
const validBlocks = Object.entries(workflowBlocks).reduce(
(acc, [blockId, block]) => {
if (block?.type) {
if (block?.type && block.enabled !== false) {
acc[blockId] = block
}
return acc
@@ -724,13 +725,18 @@ export function useWorkflowExecution() {
}
})
// Do not filter out trigger blocks; executor may need to start from them
// Filter out blocks without type and disabled blocks
const filteredStates = Object.entries(mergedStates).reduce(
(acc, [id, block]) => {
if (!block || !block.type) {
logger.warn(`Skipping block with undefined type: ${id}`, block)
return acc
}
// Skip disabled blocks to prevent them from being passed to executor
if (block.enabled === false) {
logger.warn(`Skipping disabled block: ${id}`)
return acc
}
acc[id] = block
return acc
},
@@ -892,6 +898,12 @@ export function useWorkflowExecution() {
activeBlocksSet.add(data.blockId)
// Create a new Set to trigger React re-render
setActiveBlocks(new Set(activeBlocksSet))
// Track edges that led to this block as soon as execution starts
const incomingEdges = workflowEdges.filter((edge) => edge.target === data.blockId)
incomingEdges.forEach((edge) => {
setEdgeRunStatus(edge.id, 'success')
})
},
onBlockCompleted: (data) => {
@@ -904,6 +916,8 @@ export function useWorkflowExecution() {
// Track successful block execution in run path
setBlockRunStatus(data.blockId, 'success')
// Edges already tracked in onBlockStarted, no need to track again
// Add to console
addConsole({
input: data.input || {},
@@ -938,7 +952,6 @@ export function useWorkflowExecution() {
// Track failed block execution in run path
setBlockRunStatus(data.blockId, 'error')
// Add error to console
addConsole({
input: data.input || {},

View File

@@ -0,0 +1,77 @@
import { cn } from '@/lib/utils'
export type BlockDiffStatus = 'new' | 'edited' | null | undefined
export type BlockRunPathStatus = 'success' | 'error' | undefined
export interface BlockRingOptions {
isActive: boolean
isPending: boolean
isFocused: boolean
isDeletedBlock: boolean
diffStatus: BlockDiffStatus
runPathStatus: BlockRunPathStatus
}
/**
* Derives visual ring visibility and class names for workflow blocks
* based on execution, focus, diff, deletion, and run-path states.
*/
export function getBlockRingStyles(options: BlockRingOptions): {
hasRing: boolean
ringClassName: string
} {
const { isActive, isPending, isFocused, isDeletedBlock, diffStatus, runPathStatus } = options
const hasRing =
isActive ||
isPending ||
isFocused ||
diffStatus === 'new' ||
diffStatus === 'edited' ||
isDeletedBlock ||
!!runPathStatus
const ringClassName = cn(
// Executing block: pulsing success ring with prominent thickness
isActive && 'ring-[3.5px] ring-[var(--border-success)] animate-ring-pulse',
// Non-active states use standard ring utilities
!isActive && hasRing && 'ring-[1.75px]',
// Pending state: warning ring
!isActive && isPending && 'ring-[var(--warning)]',
// Focused (selected) state: brand ring
!isActive && !isPending && isFocused && 'ring-[var(--brand-secondary)]',
// Deleted state (highest priority after active/pending/focused)
!isActive && !isPending && !isFocused && isDeletedBlock && 'ring-[var(--text-error)]',
// Diff states
!isActive &&
!isPending &&
!isFocused &&
!isDeletedBlock &&
diffStatus === 'new' &&
'ring-[#22C55E]',
!isActive &&
!isPending &&
!isFocused &&
!isDeletedBlock &&
diffStatus === 'edited' &&
'ring-[var(--warning)]',
// Run path states (lowest priority - only show if no other states active)
!isActive &&
!isPending &&
!isFocused &&
!isDeletedBlock &&
!diffStatus &&
runPathStatus === 'success' &&
'ring-[var(--border-success)]',
!isActive &&
!isPending &&
!isFocused &&
!isDeletedBlock &&
!diffStatus &&
runPathStatus === 'error' &&
'ring-[var(--text-error)]'
)
return { hasRing, ringClassName }
}

View File

@@ -1,2 +1,3 @@
export * from './auto-layout-utils'
export * from './block-ring-utils'
export * from './workflow-execution-utils'

View File

@@ -110,14 +110,13 @@ const WorkflowContent = React.memo(() => {
// Hooks
const params = useParams()
const router = useRouter()
const { project, getNodes, fitView } = useReactFlow()
const { screenToFlowPosition, getNodes, fitView } = useReactFlow()
const { emitCursorUpdate } = useSocket()
// Get workspace ID from the params
const workspaceId = params.workspaceId as string
const { workflows, activeWorkflowId, isLoading, setActiveWorkflow, createWorkflow } =
useWorkflowRegistry()
const { workflows, activeWorkflowId, isLoading, setActiveWorkflow } = useWorkflowRegistry()
// Use the clean abstraction for current workflow state
const currentWorkflow = useCurrentWorkflow()
@@ -170,7 +169,7 @@ const WorkflowContent = React.memo(() => {
// Get diff analysis for edge reconstruction
const { diffAnalysis, isShowingDiff, isDiffReady } = useWorkflowDiffStore()
// Reconstruct deleted edges when viewing original workflow and filter trigger edges
// Reconstruct deleted edges when viewing original workflow and filter out invalid edges
const edgesForDisplay = useMemo(() => {
let edgesToFilter = edges
@@ -237,7 +236,21 @@ const WorkflowContent = React.memo(() => {
// Combine existing edges with reconstructed deleted edges
edgesToFilter = [...edges, ...reconstructedEdges]
}
return edgesToFilter
// Filter out edges that connect to/from annotation-only blocks (note blocks)
// These blocks don't have handles and shouldn't have connections
return edgesToFilter.filter((edge) => {
const sourceBlock = blocks[edge.source]
const targetBlock = blocks[edge.target]
// Remove edge if either source or target is an annotation-only block
if (!sourceBlock || !targetBlock) return false
if (isAnnotationOnlyBlock(sourceBlock.type) || isAnnotationOnlyBlock(targetBlock.type)) {
return false
}
return true
})
}, [edges, isShowingDiff, isDiffReady, diffAnalysis, blocks])
// User permissions - get current user's specific permissions from context
@@ -421,6 +434,7 @@ const WorkflowContent = React.memo(() => {
activeElement?.hasAttribute('contenteditable')
if (isEditableElement) {
event.stopPropagation()
return
}
@@ -680,7 +694,11 @@ const WorkflowContent = React.memo(() => {
// Auto-connect logic for blocks inside containers
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
let autoConnectEdge
if (isAutoConnectEnabled && data.type !== 'starter') {
if (
isAutoConnectEnabled &&
data.type !== 'starter' &&
!isAnnotationOnlyBlock(data.type)
) {
if (existingChildBlocks.length > 0) {
// Connect to the nearest existing child block within the container
const closestBlock = existingChildBlocks
@@ -694,7 +712,7 @@ const WorkflowContent = React.memo(() => {
.sort((a, b) => a.distance - b.distance)[0]?.block
if (closestBlock) {
// Don't create edges into trigger blocks
// Don't create edges into trigger blocks or annotation blocks
const targetBlockConfig = getBlock(data.type)
const isTargetTrigger =
data.enableTriggerMode === true || targetBlockConfig?.category === 'triggers'
@@ -769,10 +787,14 @@ const WorkflowContent = React.memo(() => {
// Regular auto-connect logic
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
let autoConnectEdge
if (isAutoConnectEnabled && data.type !== 'starter') {
if (
isAutoConnectEnabled &&
data.type !== 'starter' &&
!isAnnotationOnlyBlock(data.type)
) {
const closestBlock = findClosestOutput(position)
if (closestBlock) {
// Don't create edges into trigger blocks
// Don't create edges into trigger blocks or annotation blocks
const targetBlockConfig = getBlock(data.type)
const isTargetTrigger =
data.enableTriggerMode === true || targetBlockConfig?.category === 'triggers'
@@ -842,7 +864,7 @@ const WorkflowContent = React.memo(() => {
const baseName = type === 'loop' ? 'Loop' : 'Parallel'
const name = getUniqueBlockName(baseName, blocks)
const centerPosition = project({
const centerPosition = screenToFlowPosition({
x: window.innerWidth / 2,
y: window.innerHeight / 2,
})
@@ -891,7 +913,7 @@ const WorkflowContent = React.memo(() => {
}
// Calculate the center position of the viewport
const centerPosition = project({
const centerPosition = screenToFlowPosition({
x: window.innerWidth / 2,
y: window.innerHeight / 2,
})
@@ -906,11 +928,11 @@ const WorkflowContent = React.memo(() => {
// Auto-connect logic
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
let autoConnectEdge
if (isAutoConnectEnabled && type !== 'starter') {
if (isAutoConnectEnabled && type !== 'starter' && !isAnnotationOnlyBlock(type)) {
const closestBlock = findClosestOutput(centerPosition)
logger.info('Closest block found:', closestBlock)
if (closestBlock) {
// Don't create edges into trigger blocks
// Don't create edges into trigger blocks or annotation blocks
const targetBlockConfig = blockConfig
const isTargetTrigger = enableTriggerMode || targetBlockConfig?.category === 'triggers'
@@ -977,7 +999,7 @@ const WorkflowContent = React.memo(() => {
)
}
}, [
project,
screenToFlowPosition,
blocks,
addBlock,
addEdge,
@@ -1014,7 +1036,7 @@ const WorkflowContent = React.memo(() => {
}
const bounds = canvasElement.getBoundingClientRect()
const position = project({
const position = screenToFlowPosition({
x: detail.clientX - bounds.left,
y: detail.clientY - bounds.top,
})
@@ -1041,7 +1063,7 @@ const WorkflowContent = React.memo(() => {
'toolbar-drop-on-empty-workflow-overlay',
handleOverlayToolbarDrop as EventListener
)
}, [project, handleToolbarDrop])
}, [screenToFlowPosition, handleToolbarDrop])
/**
* Recenter canvas when diff appears
@@ -1090,7 +1112,7 @@ const WorkflowContent = React.memo(() => {
if (!data?.type) return
const reactFlowBounds = event.currentTarget.getBoundingClientRect()
const position = project({
const position = screenToFlowPosition({
x: event.clientX - reactFlowBounds.left,
y: event.clientY - reactFlowBounds.top,
})
@@ -1106,7 +1128,7 @@ const WorkflowContent = React.memo(() => {
logger.error('Error dropping block on ReactFlow canvas:', { err })
}
},
[project, handleToolbarDrop]
[screenToFlowPosition, handleToolbarDrop]
)
const handleCanvasPointerMove = useCallback(
@@ -1114,14 +1136,14 @@ const WorkflowContent = React.memo(() => {
const target = event.currentTarget as HTMLElement
const bounds = target.getBoundingClientRect()
const position = project({
const position = screenToFlowPosition({
x: event.clientX - bounds.left,
y: event.clientY - bounds.top,
})
emitCursorUpdate(position)
},
[project, emitCursorUpdate]
[screenToFlowPosition, emitCursorUpdate]
)
const handleCanvasPointerLeave = useCallback(() => {
@@ -1144,7 +1166,7 @@ const WorkflowContent = React.memo(() => {
try {
const reactFlowBounds = event.currentTarget.getBoundingClientRect()
const position = project({
const position = screenToFlowPosition({
x: event.clientX - reactFlowBounds.left,
y: event.clientY - reactFlowBounds.top,
})
@@ -1188,21 +1210,34 @@ const WorkflowContent = React.memo(() => {
logger.error('Error in onDragOver', { err })
}
},
[project, isPointInLoopNode, getNodes]
[screenToFlowPosition, isPointInLoopNode, getNodes]
)
// Initialize workflow when it exists in registry and isn't active
useEffect(() => {
let cancelled = false
const currentId = params.workflowId as string
if (!currentId || !workflows[currentId]) return
// Wait for registry to be ready to prevent race conditions
// Don't proceed if: no workflowId, registry is loading, or workflow not in registry
if (!currentId || isLoading || !workflows[currentId]) return
if (activeWorkflowId !== currentId) {
// Clear diff and set as active
const { clearDiff } = useWorkflowDiffStore.getState()
clearDiff()
setActiveWorkflow(currentId)
setActiveWorkflow(currentId).catch((error) => {
if (!cancelled) {
logger.error(`Failed to set active workflow ${currentId}:`, error)
}
})
}
}, [params.workflowId, workflows, activeWorkflowId, setActiveWorkflow])
return () => {
cancelled = true
}
}, [params.workflowId, workflows, activeWorkflowId, setActiveWorkflow, isLoading])
// Track when workflow is ready for rendering
useEffect(() => {
@@ -1212,11 +1247,15 @@ const WorkflowContent = React.memo(() => {
// 1. We have an active workflow that matches the URL
// 2. The workflow exists in the registry
// 3. Workflows are not currently loading
// 4. The workflow store has been initialized (lastSaved exists means state was loaded)
const shouldBeReady =
activeWorkflowId === currentId && Boolean(workflows[currentId]) && !isLoading
activeWorkflowId === currentId &&
Boolean(workflows[currentId]) &&
!isLoading &&
lastSaved !== undefined
setIsWorkflowReady(shouldBeReady)
}, [activeWorkflowId, params.workflowId, workflows, isLoading])
}, [activeWorkflowId, params.workflowId, workflows, isLoading, lastSaved])
// Preload workspace environment - React Query handles caching automatically
useWorkspaceEnvironment(workspaceId)
@@ -1584,8 +1623,8 @@ const WorkflowContent = React.memo(() => {
// Store currently dragged node ID
setDraggedNodeId(node.id)
// Emit collaborative position update during drag for smooth real-time movement
collaborativeUpdateBlockPosition(node.id, node.position, false)
// Note: We don't emit position updates during drag to avoid flooding socket events.
// The final position is sent in onNodeDragStop for collaborative updates.
// Get the current parent ID of the node being dragged
const currentParentId = blocks[node.id]?.data?.parentId || null
@@ -1721,14 +1760,7 @@ const WorkflowContent = React.memo(() => {
}
}
},
[
getNodes,
potentialParentId,
blocks,
getNodeAbsolutePosition,
getNodeDepth,
collaborativeUpdateBlockPosition,
]
[getNodes, potentialParentId, blocks, getNodeAbsolutePosition, getNodeDepth]
)
// Add in a nodeDrag start event to set the dragStartParentId
@@ -1855,7 +1887,8 @@ const WorkflowContent = React.memo(() => {
// Auto-connect when moving an existing block into a container
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
if (isAutoConnectEnabled) {
// Don't auto-connect annotation blocks (like note blocks)
if (isAutoConnectEnabled && !isAnnotationOnlyBlock(node.data?.type)) {
// Existing children in the target container (excluding the moved node)
const existingChildBlocks = Object.values(blocks).filter(
(b) => b.data?.parentId === potentialParentId && b.id !== node.id

View File

@@ -26,7 +26,7 @@ export function Account(_props: AccountProps) {
const router = useRouter()
const brandConfig = useBrandConfig()
// React Query hooks - with placeholderData to show cached data immediately (no skeleton loading!)
// React Query hooks - with placeholderData to show cached data immediately
const { data: profile } = useUserProfile()
const updateProfile = useUpdateUserProfile()

View File

@@ -41,7 +41,7 @@ export function CreatorProfile() {
const { data: session } = useSession()
const userId = session?.user?.id || ''
// React Query hooks - with placeholderData to show cached data immediately (no skeleton loading!)
// React Query hooks - with placeholderData to show cached data immediately
const { data: organizations = [] } = useOrganizations()
const { data: existingProfile } = useCreatorProfile(userId)
const saveProfile = useSaveCreatorProfile()

View File

@@ -5,7 +5,6 @@ import { Check, ChevronDown, ExternalLink, Search } from 'lucide-react'
import { useRouter, useSearchParams } from 'next/navigation'
import { Button } from '@/components/emcn'
import { Input, Label } from '@/components/ui'
import { useSession } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth'
import { cn } from '@/lib/utils'
@@ -26,11 +25,9 @@ interface CredentialsProps {
export function Credentials({ onOpenChange, registerCloseHandler }: CredentialsProps) {
const router = useRouter()
const searchParams = useSearchParams()
const { data: session } = useSession()
const userId = session?.user?.id
const pendingServiceRef = useRef<HTMLDivElement>(null)
// React Query hooks - with placeholderData to show cached data immediately (no skeleton loading!)
// React Query hooks - with placeholderData to show cached data immediately
const { data: services = [] } = useOAuthConnections()
const connectService = useConnectOAuthService()
const disconnectService = useDisconnectOAuthService()
@@ -38,51 +35,28 @@ export function Credentials({ onOpenChange, registerCloseHandler }: CredentialsP
// Local UI state
const [searchTerm, setSearchTerm] = useState('')
const [pendingService, setPendingService] = useState<string | null>(null)
const [_pendingScopes, setPendingScopes] = useState<string[]>([])
const [authSuccess, setAuthSuccess] = useState(false)
const [showActionRequired, setShowActionRequired] = useState(false)
const prevConnectedIdsRef = useRef<Set<string>>(new Set())
const connectionAddedRef = useRef<boolean>(false)
// Check for OAuth callback
// Check for OAuth callback - just show success message
useEffect(() => {
const code = searchParams.get('code')
const state = searchParams.get('state')
const error = searchParams.get('error')
// Handle OAuth callback
if (code && state) {
// This is an OAuth callback - try to restore state from localStorage
try {
const stored = localStorage.getItem('pending_oauth_state')
if (stored) {
const oauthState = JSON.parse(stored)
logger.info('OAuth callback with restored state:', oauthState)
// Mark as pending if we have context about what service was being connected
if (oauthState.serviceId) {
setPendingService(oauthState.serviceId)
setShowActionRequired(true)
}
// Clean up the state (one-time use)
localStorage.removeItem('pending_oauth_state')
} else {
logger.warn('OAuth callback but no state found in localStorage')
}
} catch (error) {
logger.error('Error loading OAuth state from localStorage:', error)
localStorage.removeItem('pending_oauth_state') // Clean up corrupted state
}
// Set success flag
logger.info('OAuth callback successful')
setAuthSuccess(true)
// Clear the URL parameters
router.replace('/workspace')
// Clear URL parameters without changing the page
const url = new URL(window.location.href)
url.searchParams.delete('code')
url.searchParams.delete('state')
router.replace(url.pathname + url.search)
} else if (error) {
logger.error('OAuth error:', { error })
router.replace('/workspace')
}
}, [searchParams, router])
@@ -132,6 +106,7 @@ export function Credentials({ onOpenChange, registerCloseHandler }: CredentialsP
scopes: service.scopes,
})
// better-auth will automatically redirect back to this URL after OAuth
await connectService.mutateAsync({
providerId: service.providerId,
callbackURL: window.location.href,

View File

@@ -55,7 +55,7 @@ export function Files() {
const params = useParams()
const workspaceId = params?.workspaceId as string
// React Query hooks - with placeholderData to show cached data immediately (no skeleton loading!)
// React Query hooks - with placeholderData to show cached data immediately
const { data: files = [] } = useWorkspaceFiles(workspaceId)
const { data: storageInfo } = useStorageInfo(isBillingEnabled)
const uploadFile = useUploadWorkspaceFile()

View File

@@ -34,7 +34,7 @@ export function General() {
const [isSuperUser, setIsSuperUser] = useState(false)
const [loadingSuperUser, setLoadingSuperUser] = useState(true)
// React Query hooks - with placeholderData to show cached data immediately (no skeleton loading!)
// React Query hooks - with placeholderData to show cached data immediately
const { data: settings, isLoading } = useGeneralSettings()
const updateSetting = useUpdateGeneralSetting()

View File

@@ -13,7 +13,7 @@ const TOOLTIPS = {
}
export function Privacy() {
// React Query hooks - with placeholderData to show cached data immediately (no skeleton loading!)
// React Query hooks - with placeholderData to show cached data immediately
const { data: settings } = useGeneralSettings()
const updateSetting = useUpdateGeneralSetting()

View File

@@ -227,12 +227,8 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
onClick={() => setIsDialogOpen(true)}
disabled={isLoading}
className={cn(
'h-8 rounded-[8px] font-medium text-xs transition-all duration-200',
error
? 'border-red-500 text-red-500 dark:border-red-500 dark:text-red-500'
: isCancelAtPeriodEnd
? 'text-muted-foreground hover:border-green-500 hover:bg-green-500 hover:text-white dark:hover:border-green-500 dark:hover:bg-green-500'
: 'text-muted-foreground hover:border-red-500 hover:bg-red-500 hover:text-white dark:hover:border-red-500 dark:hover:bg-red-500'
'h-8 rounded-[8px] font-medium text-xs',
error && 'border-red-500 text-red-500 dark:border-red-500 dark:text-red-500'
)}
>
{error ? 'Error' : isCancelAtPeriodEnd ? 'Restore' : 'Manage'}

View File

@@ -107,12 +107,11 @@ export function PlanCard({
<Button
onClick={onButtonClick}
className={cn(
'h-9 rounded-[8px] text-xs transition-colors',
'h-9 rounded-[8px] text-xs',
isHorizontal ? 'px-4' : 'w-full',
isError &&
'border-red-500 bg-transparent text-red-500 hover:bg-red-500 hover:text-white dark:border-red-500 dark:text-red-500 dark:hover:bg-red-500'
isError && 'border-red-500 text-red-500 dark:border-red-500 dark:text-red-500'
)}
variant={isError ? 'outline' : 'default'}
variant='outline'
aria-label={`${buttonText} ${name} plan`}
>
{isError ? 'Error' : buttonText}

View File

@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { useUsageLimits } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/hooks'
import { useUpdateUsageLimit } from '@/hooks/queries/subscription'
const logger = createLogger('UsageLimit')
@@ -42,20 +43,22 @@ export const UsageLimit = forwardRef<UsageLimitRef, UsageLimitProps>(
const [isEditing, setIsEditing] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
// Use centralized usage limits hook
const { updateLimit, isUpdating } = useUsageLimits({
const { updateLimit, isUpdating: isOrgUpdating } = useUsageLimits({
context,
organizationId,
autoRefresh: false, // Don't auto-refresh, we receive values via props
})
const updateUsageLimitMutation = useUpdateUsageLimit()
const isUpdating =
context === 'organization' ? isOrgUpdating : updateUsageLimitMutation.isPending
const handleStartEdit = () => {
if (!canEdit) return
setIsEditing(true)
setInputValue(currentLimit.toString())
}
// Expose startEdit method through ref
useImperativeHandle(
ref,
() => ({
@@ -68,7 +71,6 @@ export const UsageLimit = forwardRef<UsageLimitRef, UsageLimitProps>(
setInputValue(currentLimit.toString())
}, [currentLimit])
// Focus input when entering edit mode
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus()
@@ -76,7 +78,6 @@ export const UsageLimit = forwardRef<UsageLimitRef, UsageLimitProps>(
}
}, [isEditing])
// Clear error after 2 seconds
useEffect(() => {
if (hasError) {
const timer = setTimeout(() => {
@@ -96,11 +97,9 @@ export const UsageLimit = forwardRef<UsageLimitRef, UsageLimitProps>(
return
}
// Check if new limit is below current usage
if (newLimit < currentUsage) {
setHasError(true)
setErrorType('belowUsage')
// Don't reset input value - let user see what they typed
return
}
@@ -109,20 +108,43 @@ export const UsageLimit = forwardRef<UsageLimitRef, UsageLimitProps>(
return
}
// Use the centralized hook to update the limit
const result = await updateLimit(newLimit)
try {
if (context === 'organization') {
const result = await updateLimit(newLimit)
if (result.success) {
setInputValue(newLimit.toString())
onLimitUpdated?.(newLimit)
setIsEditing(false)
setErrorType(null)
setHasError(false)
} else {
logger.error('Failed to update usage limit', { error: result.error })
if (result.error?.includes('below current usage')) {
setErrorType('belowUsage')
} else {
setErrorType('general')
}
setHasError(true)
}
return
}
await updateUsageLimitMutation.mutateAsync({ limit: newLimit })
if (result.success) {
setInputValue(newLimit.toString())
onLimitUpdated?.(newLimit)
setIsEditing(false)
setErrorType(null)
setHasError(false)
} else {
logger.error('Failed to update usage limit', { error: result.error })
} catch (err) {
logger.error('Failed to update usage limit', { error: err })
// Check if the error is about being below current usage
if (result.error?.includes('below current usage')) {
const message = err instanceof Error ? err.message : String(err)
if (message.includes('below current usage')) {
setErrorType('belowUsage')
} else {
setErrorType('general')
@@ -161,7 +183,6 @@ export const UsageLimit = forwardRef<UsageLimitRef, UsageLimitProps>(
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={(e) => {
// Don't submit if clicking on the button (it will handle submission)
const relatedTarget = e.relatedTarget as HTMLElement
if (relatedTarget?.closest('button')) {
return

View File

@@ -169,7 +169,6 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
const canManageWorkspaceKeys = userPermissions.canAdmin
const logger = createLogger('Subscription')
// React Query hooks for data fetching
const { data: subscriptionData, isLoading: isSubscriptionLoading } = useSubscriptionData()
const { data: usageLimitResponse, isLoading: isUsageLimitLoading } = useUsageLimitData()
const { data: workspaceData, isLoading: isWorkspaceLoading } = useWorkspaceSettings(workspaceId)
@@ -179,7 +178,6 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
const activeOrganization = orgsData?.activeOrganization
const activeOrgId = activeOrganization?.id
// Fetch organization billing data with React Query
const { data: organizationBillingData, isLoading: isOrgBillingLoading } = useOrganizationBilling(
activeOrgId || ''
)
@@ -187,10 +185,8 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
const [upgradeError, setUpgradeError] = useState<'pro' | 'team' | null>(null)
const usageLimitRef = useRef<UsageLimitRef | null>(null)
// Combine all loading states
const isLoading = isSubscriptionLoading || isUsageLimitLoading || isWorkspaceLoading
// Extract subscription status from subscriptionData.data
const subscription = {
isFree: subscriptionData?.data?.plan === 'free' || !subscriptionData?.data?.plan,
isPro: subscriptionData?.data?.plan === 'pro',
@@ -205,28 +201,23 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
seats: subscriptionData?.data?.seats || 1,
}
// Extract usage data from subscriptionData.data.usage (same source as panel usage indicator)
const usage = {
current: subscriptionData?.data?.usage?.current || 0,
limit: subscriptionData?.data?.usage?.limit || 0,
percentUsed: subscriptionData?.data?.usage?.percentUsed || 0,
}
// Extract usage limit metadata from usageLimitResponse.data
const usageLimitData = {
currentLimit: usageLimitResponse?.data?.currentLimit || 0,
minimumLimit: usageLimitResponse?.data?.minimumLimit || (subscription.isPro ? 20 : 40),
}
// Extract billing status
const billingStatus = subscriptionData?.data?.billingBlocked ? 'blocked' : 'ok'
// Extract workspace settings
const billedAccountUserId = workspaceData?.settings?.workspace?.billedAccountUserId ?? null
const workspaceAdmins =
workspaceData?.permissions?.users?.filter((user: any) => user.permissionType === 'admin') || []
// Update workspace settings handler
const updateWorkspaceSettings = async (updates: { billedAccountUserId?: string }) => {
if (!workspaceId) return
try {
@@ -240,7 +231,6 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
}
}
// Auto-clear upgrade error
useEffect(() => {
if (upgradeError) {
const timer = setTimeout(() => {
@@ -250,11 +240,9 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
}
}, [upgradeError])
// User role and permissions
const userRole = getUserRole(activeOrganization, session?.user?.email)
const isTeamAdmin = ['owner', 'admin'].includes(userRole)
// Get permissions based on subscription state and user role
const permissions = getSubscriptionPermissions(
{
isFree: subscription.isFree,
@@ -271,7 +259,6 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
}
)
// Get visible plans based on current subscription
const visiblePlans = getVisiblePlans(
{
isFree: subscription.isFree,
@@ -459,8 +446,8 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
}
context={subscription.isTeam && isTeamAdmin ? 'organization' : 'user'}
organizationId={subscription.isTeam && isTeamAdmin ? activeOrgId : undefined}
onLimitUpdated={async () => {
// React Query will automatically refetch when the mutation completes
onLimitUpdated={() => {
logger.info('Usage limit updated')
}}
/>
) : undefined
@@ -469,6 +456,15 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
/>
</div>
{/* Enterprise Usage Limit Notice */}
{subscription.isEnterprise && (
<div className='text-center'>
<p className='text-muted-foreground text-xs'>
Contact enterprise for support usage limit changes
</p>
</div>
)}
{/* Cost Breakdown */}
{/* TODO: Re-enable CostBreakdown component in the next billing period
once sufficient copilot cost data has been collected for accurate display.
@@ -554,14 +550,6 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
{/* Billing usage notifications toggle */}
{subscription.isPaid && <BillingUsageNotificationsToggle />}
{subscription.isEnterprise && (
<div className='text-center'>
<p className='text-muted-foreground text-xs'>
Contact enterprise for support usage limit changes
</p>
</div>
)}
{/* Cancel Subscription */}
{permissions.canCancelSubscription && (
<div className='mt-2'>
@@ -631,9 +619,6 @@ function BillingUsageNotificationsToggle() {
const updateSetting = useUpdateGeneralSetting()
const isLoading = updateSetting.isPending
// Settings are automatically loaded by SettingsLoader provider
// No need to load here - Zustand is synced from React Query
return (
<div className='mt-4 flex items-center justify-between'>
<div className='flex flex-col'>

View File

@@ -10,36 +10,46 @@ import {
getSubscriptionStatus,
getUsage,
} from '@/lib/subscription/helpers'
import { isUsageAtLimit, USAGE_PILL_COLORS } from '@/lib/subscription/usage-visualization'
import { useSubscriptionData } from '@/hooks/queries/subscription'
import { MIN_SIDEBAR_WIDTH, useSidebarStore } from '@/stores/sidebar/store'
const logger = createLogger('UsageIndicator')
/**
* Minimum number of pills to display (at minimum sidebar width)
* Minimum number of pills to display (at minimum sidebar width).
*/
const MIN_PILL_COUNT = 6
/**
* Maximum number of pills to display
* Maximum number of pills to display.
*/
const MAX_PILL_COUNT = 8
/**
* Width increase (in pixels) required to add one additional pill
* Width increase (in pixels) required to add one additional pill.
*/
const WIDTH_PER_PILL = 50
/**
* Animation configuration for usage pills
* Controls how smoothly and quickly the highlight progresses across pills
* Animation tick interval in milliseconds.
* Controls the update frequency of the wave animation.
*/
const PILL_ANIMATION_TICK_MS = 30
/**
* Speed of the wave animation in pills per second.
*/
const PILLS_PER_SECOND = 1.8
/**
* Distance (in pill units) the wave advances per animation tick.
* Derived from {@link PILLS_PER_SECOND} and {@link PILL_ANIMATION_TICK_MS}.
*/
const PILL_STEP_PER_TICK = (PILLS_PER_SECOND * PILL_ANIMATION_TICK_MS) / 1000
/**
* Plan name mapping
* Human-readable plan name labels.
*/
const PLAN_NAMES = {
enterprise: 'Enterprise',
@@ -48,17 +58,37 @@ const PLAN_NAMES = {
free: 'Free',
} as const
/**
* Props for the {@link UsageIndicator} component.
*/
interface UsageIndicatorProps {
/**
* Optional click handler. If provided, overrides the default behavior
* of opening the settings modal to the subscription tab.
*/
onClick?: () => void
}
/**
* Displays a visual usage indicator showing current subscription usage
* with an animated pill bar that responds to hover interactions.
*
* The component shows:
* - Current plan type (Free, Pro, Team, Enterprise)
* - Current usage vs. limit (e.g., $7.00 / $10.00)
* - Visual pill bar representing usage percentage
* - Upgrade button for free plans or when blocked
*
* @param props - Component props
* @returns A usage indicator component with responsive pill visualization
*/
export function UsageIndicator({ onClick }: UsageIndicatorProps) {
const { data: subscriptionData, isLoading } = useSubscriptionData()
const sidebarWidth = useSidebarStore((state) => state.sidebarWidth)
/**
* Calculate pill count based on sidebar width (6-8 pills dynamically)
* This provides responsive feedback as the sidebar width changes
* Calculate pill count based on sidebar width (6-8 pills dynamically).
* This provides responsive feedback as the sidebar width changes.
*/
const pillCount = useMemo(() => {
const widthDelta = sidebarWidth - MIN_SIDEBAR_WIDTH
@@ -82,54 +112,57 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
const billingStatus = getBillingStatus(subscriptionData?.data)
const isBlocked = billingStatus === 'blocked'
const showUpgradeButton = planType === 'free' || isBlocked
const showUpgradeButton =
(planType === 'free' || isBlocked || progressPercentage >= 80) && planType !== 'enterprise'
/**
* Calculate which pills should be filled based on usage percentage
* Uses shared Math.ceil heuristic but with dynamic pill count (6-8)
* This ensures consistent calculation logic while maintaining responsive pill count
* Calculate which pills should be filled based on usage percentage.
* Uses a percentage-based heuristic with dynamic pill count (6-8).
* The warning/limit (red) state is derived from shared usage visualization utilities
* so it is consistent with other parts of the app (e.g. UsageHeader).
*/
const filledPillsCount = Math.ceil((progressPercentage / 100) * pillCount)
const isAlmostOut = filledPillsCount === pillCount
const isAtLimit = isUsageAtLimit(progressPercentage)
const [isHovered, setIsHovered] = useState(false)
const [wavePosition, setWavePosition] = useState<number | null>(null)
const [hasWrapped, setHasWrapped] = useState(false)
const startAnimationIndex = pillCount === 0 ? 0 : Math.min(filledPillsCount, pillCount - 1)
useEffect(() => {
if (!isHovered || pillCount <= 0) {
const isFreePlan = subscription.isFree
if (!isHovered || pillCount <= 0 || !isFreePlan) {
setWavePosition(null)
setHasWrapped(false)
return
}
const totalSpan = pillCount
let wrapped = false
setHasWrapped(false)
/**
* Maximum distance (in pill units) the wave should travel from
* {@link startAnimationIndex} to the end of the row. The wave stops
* once it reaches the final pill and does not wrap.
*/
const maxDistance = pillCount <= 0 ? 0 : Math.max(0, pillCount - startAnimationIndex)
setWavePosition(0)
const interval = window.setInterval(() => {
setWavePosition((prev) => {
const current = prev ?? 0
const next = current + PILL_STEP_PER_TICK
// Mark as wrapped after first complete cycle
if (next >= totalSpan && !wrapped) {
wrapped = true
setHasWrapped(true)
if (current >= maxDistance) {
return current
}
// Return continuous value, never reset (seamless loop)
return next
const next = current + PILL_STEP_PER_TICK
return next >= maxDistance ? maxDistance : next
})
}, PILL_ANIMATION_TICK_MS)
return () => {
window.clearInterval(interval)
}
}, [isHovered, pillCount, startAnimationIndex])
}, [isHovered, pillCount, startAnimationIndex, subscription.isFree])
if (isLoading) {
return (
@@ -153,7 +186,7 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
)
}
const handleClick = () => {
const handleClick = async () => {
try {
if (onClick) {
onClick()
@@ -163,7 +196,35 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
const blocked = getBillingStatus(subscriptionData?.data) === 'blocked'
const canUpg = canUpgrade(subscriptionData?.data)
// Open Settings modal to the subscription tab (upgrade UI lives there)
// If blocked, try to open billing portal directly for faster recovery
if (blocked) {
try {
const context = subscription.isTeam || subscription.isEnterprise ? 'organization' : 'user'
const organizationId =
subscription.isTeam || subscription.isEnterprise
? subscriptionData?.data?.organization?.id
: undefined
const response = await fetch('/api/billing/portal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ context, organizationId }),
})
if (response.ok) {
const { url } = await response.json()
window.open(url, '_blank')
logger.info('Opened billing portal for blocked account', { context, organizationId })
return
}
} catch (portalError) {
logger.warn('Failed to open billing portal, falling back to settings', {
error: portalError,
})
}
}
// Fallback: Open Settings modal to the subscription tab
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'subscription' } }))
logger.info('Opened settings to subscription tab', { blocked, canUpgrade: canUpg })
@@ -175,7 +236,9 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
return (
<div
className='group flex flex-shrink-0 cursor-pointer flex-col gap-[8px] border-t px-[13.5px] pt-[8px] pb-[10px]'
className={`group flex flex-shrink-0 cursor-pointer flex-col gap-[8px] border-t px-[13.5px] pt-[8px] pb-[10px] ${
isBlocked ? 'border-red-500/50 bg-red-950/20' : ''
}`}
onClick={handleClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
@@ -188,8 +251,8 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
<div className='flex items-center gap-[4px]'>
{isBlocked ? (
<>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>Over</span>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>limit</span>
<span className='font-medium text-[12px] text-red-400'>Payment</span>
<span className='font-medium text-[12px] text-red-400'>Required</span>
</>
) : (
<>
@@ -207,10 +270,14 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
{showUpgradeButton && (
<Button
variant='ghost'
className='-mx-1 !h-auto !px-1 !py-0 !text-[#F473B7] group-hover:!text-[#F789C4] mt-[-2px] transition-colors duration-100'
className={`-mx-1 !h-auto !px-1 !py-0 mt-[-2px] transition-colors duration-100 ${
isBlocked
? '!text-red-400 group-hover:!text-red-300'
: '!text-[#F473B7] group-hover:!text-[#F789C4]'
}`}
onClick={handleClick}
>
<span className='font-medium text-[12px]'>Upgrade</span>
<span className='font-medium text-[12px]'>{isBlocked ? 'Fix Now' : 'Upgrade'}</span>
</Button>
)}
</div>
@@ -220,63 +287,42 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
{Array.from({ length: pillCount }).map((_, i) => {
const isFilled = i < filledPillsCount
const baseColor = isFilled ? (isAlmostOut ? '#ef4444' : '#34B5FF') : '#414141'
const baseColor = isFilled
? isBlocked || isAtLimit
? USAGE_PILL_COLORS.AT_LIMIT
: USAGE_PILL_COLORS.FILLED
: USAGE_PILL_COLORS.UNFILLED
let backgroundColor = baseColor
let backgroundImage: string | undefined
if (isHovered && wavePosition !== null && pillCount > 0) {
const totalSpan = pillCount
const grayColor = '#414141'
const activeColor = isAlmostOut ? '#ef4444' : '#34B5FF'
if (isHovered && wavePosition !== null && pillCount > 0 && subscription.isFree) {
const grayColor = USAGE_PILL_COLORS.UNFILLED
const activeColor = isAtLimit ? USAGE_PILL_COLORS.AT_LIMIT : USAGE_PILL_COLORS.FILLED
if (!hasWrapped) {
// First pass: respect original fill state, start from startAnimationIndex
const headIndex = Math.floor(wavePosition)
const progress = wavePosition - headIndex
/**
* Single-pass wave: travel from {@link startAnimationIndex} to the end
* of the row without wrapping. Previously highlighted pills remain
* filled; the wave only affects pills at or after the start index.
*/
const headIndex = Math.floor(wavePosition)
const progress = wavePosition - headIndex
const pillOffsetFromStart =
i >= startAnimationIndex
? i - startAnimationIndex
: totalSpan - startAnimationIndex + i
const pillOffsetFromStart = i - startAnimationIndex
if (pillOffsetFromStart < headIndex) {
backgroundColor = baseColor
backgroundImage = `linear-gradient(to right, ${activeColor} 0%, ${activeColor} 100%)`
} else if (pillOffsetFromStart === headIndex) {
const fillPercent = Math.max(0, Math.min(1, progress)) * 100
backgroundColor = baseColor
backgroundImage = `linear-gradient(to right, ${activeColor} 0%, ${activeColor} ${fillPercent}%, ${baseColor} ${fillPercent}%, ${baseColor} 100%)`
}
if (pillOffsetFromStart < 0) {
// Before the wave start; keep original baseColor.
} else if (pillOffsetFromStart < headIndex) {
backgroundColor = isFilled ? baseColor : grayColor
backgroundImage = `linear-gradient(to right, ${activeColor} 0%, ${activeColor} 100%)`
} else if (pillOffsetFromStart === headIndex) {
const fillPercent = Math.max(0, Math.min(1, progress)) * 100
backgroundColor = isFilled ? baseColor : grayColor
backgroundImage = `linear-gradient(to right, ${activeColor} 0%, ${activeColor} ${fillPercent}%, ${
isFilled ? baseColor : grayColor
} ${fillPercent}%, ${isFilled ? baseColor : grayColor} 100%)`
} else {
// Subsequent passes: render wave at BOTH current and next-cycle positions for seamless wrap
const wrappedPosition = wavePosition % totalSpan
const currentHead = Math.floor(wrappedPosition)
const progress = wrappedPosition - currentHead
// Primary wave position
const primaryFilled = i < currentHead
const primaryActive = i === currentHead
// Secondary wave position (one full cycle ahead, wraps to beginning)
const secondaryHead = Math.floor(wavePosition + totalSpan) % totalSpan
const secondaryProgress =
wavePosition + totalSpan - Math.floor(wavePosition + totalSpan)
const secondaryFilled = i < secondaryHead
const secondaryActive = i === secondaryHead
// Render: pill is filled if either wave position has filled it
if (primaryFilled || secondaryFilled) {
backgroundColor = grayColor
backgroundImage = `linear-gradient(to right, ${activeColor} 0%, ${activeColor} 100%)`
} else if (primaryActive || secondaryActive) {
const activeProgress = primaryActive ? progress : secondaryProgress
const fillPercent = Math.max(0, Math.min(1, activeProgress)) * 100
backgroundColor = grayColor
backgroundImage = `linear-gradient(to right, ${activeColor} 0%, ${activeColor} ${fillPercent}%, ${grayColor} ${fillPercent}%, ${grayColor} 100%)`
} else {
backgroundColor = grayColor
}
backgroundColor = isFilled ? baseColor : grayColor
}
}

View File

@@ -14,8 +14,8 @@ import {
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { useDeleteFolder, useDuplicateFolder } from '@/app/workspace/[workspaceId]/w/hooks'
import { useUpdateFolder } from '@/hooks/queries/folders'
import { useCreateWorkflow } from '@/hooks/queries/workflows'
import type { FolderTreeNode } from '@/stores/folders/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface FolderItemProps {
folder: FolderTreeNode
@@ -39,7 +39,7 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
const router = useRouter()
const workspaceId = params.workspaceId as string
const updateFolderMutation = useUpdateFolder()
const { createWorkflow } = useWorkflowRegistry()
const createWorkflowMutation = useCreateWorkflow()
// Delete modal state
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
@@ -58,18 +58,18 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
})
/**
* Handle create workflow in folder
* Handle create workflow in folder using React Query mutation
*/
const handleCreateWorkflowInFolder = useCallback(async () => {
const workflowId = await createWorkflow({
const result = await createWorkflowMutation.mutateAsync({
workspaceId,
folderId: folder.id,
})
if (workflowId) {
router.push(`/workspace/${workspaceId}/w/${workflowId}`)
if (result.id) {
router.push(`/workspace/${workspaceId}/w/${result.id}`)
}
}, [createWorkflow, workspaceId, folder.id, router])
}, [createWorkflowMutation, workspaceId, folder.id, router])
// Folder expand hook
const {

View File

@@ -6,18 +6,18 @@ import { useParams, useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { createLogger } from '@/lib/logs/console/logger'
import { generateFolderName } from '@/lib/naming'
import { cn } from '@/lib/utils'
import {
extractWorkflowName,
extractWorkflowsFromFiles,
extractWorkflowsFromZip,
} from '@/lib/workflows/import-export'
import { generateFolderName } from '@/lib/workspaces/naming'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useCreateFolder } from '@/hooks/queries/folders'
import { useCreateWorkflow } from '@/hooks/queries/workflows'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { parseWorkflowJson } from '@/stores/workflows/json/importer'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('CreateMenu')
@@ -44,7 +44,7 @@ export function CreateMenu({ onCreateWorkflow, isCreatingWorkflow = false }: Cre
const router = useRouter()
const workspaceId = params.workspaceId as string
const createFolderMutation = useCreateFolder()
const { createWorkflow } = useWorkflowRegistry()
const createWorkflowMutation = useCreateWorkflow()
const userPermissions = useUserPermissionsContext()
const fileInputRef = useRef<HTMLInputElement>(null)
@@ -194,12 +194,13 @@ export function CreateMenu({ onCreateWorkflow, isCreatingWorkflow = false }: Cre
const { clearDiff } = useWorkflowDiffStore.getState()
clearDiff()
const newWorkflowId = await createWorkflow({
const result = await createWorkflowMutation.mutateAsync({
name: workflowName,
description: 'Imported from workspace export',
workspaceId,
folderId: targetFolderId,
})
const newWorkflowId = result.id
const response = await fetch(`/api/workflows/${newWorkflowId}/state`, {
method: 'PUT',
@@ -255,11 +256,12 @@ export function CreateMenu({ onCreateWorkflow, isCreatingWorkflow = false }: Cre
const { clearDiff } = useWorkflowDiffStore.getState()
clearDiff()
const newWorkflowId = await createWorkflow({
const result = await createWorkflowMutation.mutateAsync({
name: workflowName,
description: 'Imported from JSON',
workspaceId,
})
const newWorkflowId = result.id
const response = await fetch(`/api/workflows/${newWorkflowId}/state`, {
method: 'PUT',
@@ -299,8 +301,8 @@ export function CreateMenu({ onCreateWorkflow, isCreatingWorkflow = false }: Cre
}
}
const { loadWorkflows } = useWorkflowRegistry.getState()
await loadWorkflows(workspaceId)
// Invalidate workflow queries to reload the list
// The useWorkflows hook in the sidebar will automatically refetch
} catch (error) {
logger.error('Failed to import workflows:', error)
} finally {
@@ -310,7 +312,7 @@ export function CreateMenu({ onCreateWorkflow, isCreatingWorkflow = false }: Cre
}
}
},
[workspaceId, createWorkflow, createFolderMutation]
[workspaceId, createWorkflowMutation, createFolderMutation]
)
// Button event handlers

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