Compare commits

..

16 Commits

Author SHA1 Message Date
Waleed
a54dcbe949 v0.6.24: copilot feedback wiring, captcha fixes 2026-04-04 12:52:05 -07:00
Waleed
c2b12cf21f fix(captcha): use getResponsePromise for Turnstile execute-on-submit flow (#3943) 2026-04-04 12:34:53 -07:00
Waleed
4a9439e952 improvement(models): tighten model metadata and crawl discovery (#3942)
* improvement(models): tighten model metadata and crawl discovery

Made-with: Cursor

* revert hardcoded FF

* fix(models): narrow structured output ranking signal

Made-with: Cursor

* fix(models): remove generic best-for copy

Made-with: Cursor

* fix(models): restore best-for with stricter criteria

Made-with: Cursor

* fix

* models
2026-04-04 11:53:54 -07:00
Waleed
893e322a49 fix(envvars): restore workflowUserId fallback for scheduled execution env var resolution (#3941)
* fix(envvars): restore workflowUserId fallback for scheduled execution env var resolution

* test(envvars): add coverage for env var user resolution branches
2026-04-04 11:22:52 -07:00
Emir Karabeg
b0cb95be2f feat: mothership/copilot feedback (#3940)
* feat: mothership/copilot feedback

* fix(feedback): remove mutation object from useCallback deps
2026-04-04 10:46:49 -07:00
Waleed
0b9019d9a2 v0.6.23: MCP fixes, remove local state in favor of server state, mothership workflow edits via sockets, ui improvements 2026-04-03 23:30:26 -07:00
Waleed
6d00d6bf2c fix(modals): center modals in visible content area and remove open/close animation (#3937)
* fix(modals): center modals in visible content area accounting for sidebar and panel

* fix(modals): address pr feedback — comment clarity and document panel assumption

* fix(modals): remove open/close animation from modal content
2026-04-03 20:06:10 -07:00
Waleed
3267d8cc24 fix(modals): center modals in visible content area accounting for sidebar and panel (#3934)
* fix(modals): center modals in visible content area accounting for sidebar and panel

* fix(modals): address pr feedback — comment clarity and document panel assumption
2026-04-03 19:19:36 -07:00
Theodore Li
2e69f85364 Fix "fix in copilot" button (#3931)
* Fix "fix in copilot" button

* Auto send message to copilot for fix in copilot

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-04-03 22:11:45 -04:00
Waleed
57e5bac121 fix(mcp): resolve userId before JWT generation for agent block auth (#3932)
* fix(mcp): resolve userId before JWT generation for agent block auth

* test(mcp): add regression test for agent block JWT userId resolution
2026-04-03 19:05:10 -07:00
Theodore Li
8ce0299400 fix(ui) Fix oauth redirect on connector modal (#3926)
* Fix oauth redirect on connector modal

* Fix lint

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-04-03 21:58:42 -04:00
Vikhyath Mondreti
a0796f088b improvement(mothership): workflow edits via sockets (#3927)
* improvement(mothership): workflow edits via sockets

* make embedded view join room

* fix cursor positioning bug
2026-04-03 18:44:14 -07:00
Waleed
98fe4cd40b refactor(stores): consolidate variables stores into stores/variables/ (#3930)
* refactor(stores): consolidate variables stores into stores/variables/

Move variable data store from stores/panel/variables/ to stores/variables/
since the panel variables tab no longer exists. Rename the modal UI store
to useVariablesModalStore to eliminate naming collision with the data store.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: remove unused workflowId variable in deleteVariable

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 18:43:47 -07:00
Waleed
34d210c66c chore(stores): remove Zustand environment store and dead init scaffolding (#3929) 2026-04-03 17:54:49 -07:00
Waleed
2334f2dca4 fix(loading): remove jarring workflow loading spinners (#3928)
* fix(loading): remove jarring workflow loading spinners

* fix(loading): remove home page skeleton loading state

* fix(loading): remove plain spinner loading states from task and file view
2026-04-03 17:45:30 -07:00
Waleed
65fc138bfc improvement(stores): remove deployment state from Zustand in favor of React Query (#3923) 2026-04-03 17:44:10 -07:00
93 changed files with 1666 additions and 2140 deletions

View File

@@ -99,8 +99,6 @@ function SignupFormContent({
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
const [formError, setFormError] = useState<string | null>(null)
const turnstileRef = useRef<TurnstileInstance>(null)
const captchaResolveRef = useRef<((token: string) => void) | null>(null)
const captchaRejectRef = useRef<((reason: Error) => void) | null>(null)
const turnstileSiteKey = useMemo(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'), [])
const redirectUrl = useMemo(
() => searchParams.get('redirect') || searchParams.get('callbackUrl') || '',
@@ -258,27 +256,14 @@ function SignupFormContent({
let token: string | undefined
const widget = turnstileRef.current
if (turnstileSiteKey && widget) {
let timeoutId: ReturnType<typeof setTimeout> | undefined
try {
widget.reset()
token = await Promise.race([
new Promise<string>((resolve, reject) => {
captchaResolveRef.current = resolve
captchaRejectRef.current = reject
widget.execute()
}),
new Promise<string>((_, reject) => {
timeoutId = setTimeout(() => reject(new Error('Captcha timed out')), 15_000)
}),
])
widget.execute()
token = await widget.getResponsePromise()
} catch {
setFormError('Captcha verification failed. Please try again.')
setIsLoading(false)
return
} finally {
clearTimeout(timeoutId)
captchaResolveRef.current = null
captchaRejectRef.current = null
}
}
@@ -535,10 +520,7 @@ function SignupFormContent({
<Turnstile
ref={turnstileRef}
siteKey={turnstileSiteKey}
onSuccess={(token) => captchaResolveRef.current?.(token)}
onError={() => captchaRejectRef.current?.(new Error('Captcha verification failed'))}
onExpire={() => captchaRejectRef.current?.(new Error('Captcha token expired'))}
options={{ execution: 'execute' }}
options={{ execution: 'execute', appearance: 'execute' }}
/>
)}

View File

@@ -18,6 +18,7 @@ import {
formatPrice,
formatTokenCount,
formatUpdatedAt,
getEffectiveMaxOutputTokens,
getModelBySlug,
getPricingBounds,
getProviderBySlug,
@@ -198,7 +199,8 @@ export default async function ModelPage({
</div>
<p className='max-w-[820px] text-[17px] text-[var(--landing-text-muted)] leading-relaxed'>
{model.summary} {model.bestFor}
{model.summary}
{model.bestFor ? ` ${model.bestFor}` : ''}
</p>
<div className='mt-8 flex flex-wrap gap-3'>
@@ -229,13 +231,11 @@ export default async function ModelPage({
? `${formatPrice(model.pricing.cachedInput)}/1M`
: 'N/A'
}
compact
/>
<StatCard label='Output price' value={`${formatPrice(model.pricing.output)}/1M`} />
<StatCard
label='Context window'
value={model.contextWindow ? formatTokenCount(model.contextWindow) : 'Unknown'}
compact
/>
</section>
@@ -280,12 +280,12 @@ export default async function ModelPage({
label='Max output'
value={
model.capabilities.maxOutputTokens
? `${formatTokenCount(model.capabilities.maxOutputTokens)} tokens`
: 'Standard defaults'
? `${formatTokenCount(getEffectiveMaxOutputTokens(model.capabilities))} tokens`
: 'Not published'
}
/>
<DetailItem label='Provider' value={provider.name} />
<DetailItem label='Best for' value={model.bestFor} />
{model.bestFor ? <DetailItem label='Best for' value={model.bestFor} /> : null}
</div>
</section>

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { buildModelCapabilityFacts, getEffectiveMaxOutputTokens, getModelBySlug } from './utils'
describe('model catalog capability facts', () => {
it.concurrent(
'shows structured outputs support and published max output tokens for gpt-4o',
() => {
const model = getModelBySlug('openai', 'gpt-4o')
expect(model).not.toBeNull()
expect(model).toBeDefined()
const capabilityFacts = buildModelCapabilityFacts(model!)
const structuredOutputs = capabilityFacts.find((fact) => fact.label === 'Structured outputs')
const maxOutputTokens = capabilityFacts.find((fact) => fact.label === 'Max output tokens')
expect(getEffectiveMaxOutputTokens(model!.capabilities)).toBe(16384)
expect(structuredOutputs?.value).toBe('Supported')
expect(maxOutputTokens?.value).toBe('16k')
}
)
it.concurrent('preserves native structured outputs labeling for claude models', () => {
const model = getModelBySlug('anthropic', 'claude-sonnet-4-6')
expect(model).not.toBeNull()
expect(model).toBeDefined()
const capabilityFacts = buildModelCapabilityFacts(model!)
const structuredOutputs = capabilityFacts.find((fact) => fact.label === 'Structured outputs')
expect(structuredOutputs?.value).toBe('Supported (native)')
})
it.concurrent('does not invent a max output token limit when one is not published', () => {
expect(getEffectiveMaxOutputTokens({})).toBeNull()
})
it.concurrent('keeps best-for copy for clearly differentiated models only', () => {
const researchModel = getModelBySlug('google', 'deep-research-pro-preview-12-2025')
const generalModel = getModelBySlug('xai', 'grok-4-latest')
expect(researchModel).not.toBeNull()
expect(generalModel).not.toBeNull()
expect(researchModel?.bestFor).toContain('research workflows')
expect(generalModel?.bestFor).toBeUndefined()
})
})

View File

@@ -112,7 +112,7 @@ export interface CatalogModel {
capabilities: ModelCapabilities
capabilityTags: string[]
summary: string
bestFor: string
bestFor?: string
searchText: string
}
@@ -190,6 +190,14 @@ export function formatCapabilityBoolean(
return value ? positive : negative
}
function supportsCatalogStructuredOutputs(capabilities: ModelCapabilities): boolean {
return !capabilities.deepResearch
}
export function getEffectiveMaxOutputTokens(capabilities: ModelCapabilities): number | null {
return capabilities.maxOutputTokens ?? null
}
function trimTrailingZeros(value: string): string {
return value.replace(/\.0+$/, '').replace(/(\.\d*?)0+$/, '$1')
}
@@ -326,7 +334,7 @@ function buildCapabilityTags(capabilities: ModelCapabilities): string[] {
tags.push('Tool choice')
}
if (capabilities.nativeStructuredOutputs) {
if (supportsCatalogStructuredOutputs(capabilities)) {
tags.push('Structured outputs')
}
@@ -365,7 +373,7 @@ function buildBestForLine(model: {
pricing: PricingInfo
capabilities: ModelCapabilities
contextWindow: number | null
}): string {
}): string | null {
const { pricing, capabilities, contextWindow } = model
if (capabilities.deepResearch) {
@@ -376,10 +384,6 @@ function buildBestForLine(model: {
return 'Best for reasoning-heavy tasks that need more deliberate model control.'
}
if (pricing.input <= 0.2 && pricing.output <= 1.25) {
return 'Best for cost-sensitive automations, background tasks, and high-volume workloads.'
}
if (contextWindow && contextWindow >= 1000000) {
return 'Best for long-context retrieval, large documents, and high-memory workflows.'
}
@@ -388,7 +392,11 @@ function buildBestForLine(model: {
return 'Best for production workflows that need reliable typed outputs.'
}
return 'Best for general-purpose AI workflows inside Sim.'
if (pricing.input <= 0.2 && pricing.output <= 1.25) {
return 'Best for cost-sensitive automations, background tasks, and high-volume workloads.'
}
return null
}
function buildModelSummary(
@@ -437,6 +445,11 @@ const rawProviders = Object.values(PROVIDER_DEFINITIONS).map((provider) => {
const shortId = stripProviderPrefix(provider.id, model.id)
const mergedCapabilities = { ...provider.capabilities, ...model.capabilities }
const capabilityTags = buildCapabilityTags(mergedCapabilities)
const bestFor = buildBestForLine({
pricing: model.pricing,
capabilities: mergedCapabilities,
contextWindow: model.contextWindow ?? null,
})
const displayName = formatModelDisplayName(provider.id, model.id)
const modelSlug = slugify(shortId)
const href = `/models/${providerSlug}/${modelSlug}`
@@ -461,11 +474,7 @@ const rawProviders = Object.values(PROVIDER_DEFINITIONS).map((provider) => {
model.contextWindow ?? null,
capabilityTags
),
bestFor: buildBestForLine({
pricing: model.pricing,
capabilities: mergedCapabilities,
contextWindow: model.contextWindow ?? null,
}),
...(bestFor ? { bestFor } : {}),
searchText: [
provider.name,
providerDisplayName,
@@ -683,6 +692,7 @@ export function buildModelFaqs(provider: CatalogProvider, model: CatalogModel):
export function buildModelCapabilityFacts(model: CatalogModel): CapabilityFact[] {
const { capabilities } = model
const supportsStructuredOutputs = supportsCatalogStructuredOutputs(capabilities)
return [
{
@@ -711,7 +721,11 @@ export function buildModelCapabilityFacts(model: CatalogModel): CapabilityFact[]
},
{
label: 'Structured outputs',
value: formatCapabilityBoolean(capabilities.nativeStructuredOutputs),
value: supportsStructuredOutputs
? capabilities.nativeStructuredOutputs
? 'Supported (native)'
: 'Supported'
: 'Not supported',
},
{
label: 'Tool choice',
@@ -732,8 +746,8 @@ export function buildModelCapabilityFacts(model: CatalogModel): CapabilityFact[]
{
label: 'Max output tokens',
value: capabilities.maxOutputTokens
? formatTokenCount(capabilities.maxOutputTokens)
: 'Standard defaults',
? formatTokenCount(getEffectiveMaxOutputTokens(capabilities))
: 'Not published',
},
]
}
@@ -752,8 +766,8 @@ export function getProviderCapabilitySummary(provider: CatalogProvider): Capabil
const reasoningCount = provider.models.filter(
(model) => model.capabilities.reasoningEffort || model.capabilities.thinking
).length
const structuredCount = provider.models.filter(
(model) => model.capabilities.nativeStructuredOutputs
const structuredCount = provider.models.filter((model) =>
supportsCatalogStructuredOutputs(model.capabilities)
).length
const deepResearchCount = provider.models.filter(
(model) => model.capabilities.deepResearch

View File

@@ -10,7 +10,7 @@
* @see stores/constants.ts for the source of truth
*/
:root {
--sidebar-width: 248px; /* SIDEBAR_WIDTH.DEFAULT */
--sidebar-width: 0px; /* 0 outside workspace; blocking script always sets actual value on workspace pages */
--panel-width: 320px; /* PANEL_WIDTH.DEFAULT */
--toolbar-triggers-height: 300px; /* TOOLBAR_TRIGGERS_HEIGHT.DEFAULT */
--editor-connections-height: 172px; /* EDITOR_CONNECTIONS_HEIGHT.DEFAULT */

View File

@@ -304,7 +304,6 @@ describe('Copilot Checkpoints Revert API Route', () => {
loops: {},
parallels: {},
isDeployed: true,
deploymentStatuses: { production: 'deployed' },
},
}
@@ -349,7 +348,6 @@ describe('Copilot Checkpoints Revert API Route', () => {
loops: {},
parallels: {},
isDeployed: true,
deploymentStatuses: { production: 'deployed' },
lastSaved: 1640995200000,
},
},
@@ -370,7 +368,6 @@ describe('Copilot Checkpoints Revert API Route', () => {
loops: {},
parallels: {},
isDeployed: true,
deploymentStatuses: { production: 'deployed' },
lastSaved: 1640995200000,
}),
}
@@ -473,7 +470,6 @@ describe('Copilot Checkpoints Revert API Route', () => {
edges: undefined,
loops: null,
parallels: undefined,
deploymentStatuses: null,
},
}
@@ -508,7 +504,6 @@ describe('Copilot Checkpoints Revert API Route', () => {
loops: {},
parallels: {},
isDeployed: false,
deploymentStatuses: {},
lastSaved: 1640995200000,
})
})
@@ -768,10 +763,6 @@ describe('Copilot Checkpoints Revert API Route', () => {
parallel1: { branches: ['branch1', 'branch2'] },
},
isDeployed: true,
deploymentStatuses: {
production: 'deployed',
staging: 'pending',
},
deployedAt: '2024-01-01T10:00:00.000Z',
},
}
@@ -816,10 +807,6 @@ describe('Copilot Checkpoints Revert API Route', () => {
parallel1: { branches: ['branch1', 'branch2'] },
},
isDeployed: true,
deploymentStatuses: {
production: 'deployed',
staging: 'pending',
},
deployedAt: '2024-01-01T10:00:00.000Z',
lastSaved: 1640995200000,
})

View File

@@ -82,7 +82,6 @@ export async function POST(request: NextRequest) {
loops: checkpointState?.loops || {},
parallels: checkpointState?.parallels || {},
isDeployed: checkpointState?.isDeployed || false,
deploymentStatuses: checkpointState?.deploymentStatuses || {},
lastSaved: Date.now(),
...(checkpointState?.deployedAt &&
checkpointState.deployedAt !== null &&

View File

@@ -79,7 +79,6 @@ export async function POST(
loops: deployedState.loops || {},
parallels: deployedState.parallels || {},
lastSaved: Date.now(),
deploymentStatuses: deployedState.deploymentStatuses || {},
})
if (!saveResult.success) {

View File

@@ -89,7 +89,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const finalWorkflowData = {
...workflowData,
state: {
deploymentStatuses: {},
blocks: normalizedData.blocks,
edges: normalizedData.edges,
loops: normalizedData.loops,
@@ -115,7 +114,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const emptyWorkflowData = {
...workflowData,
state: {
deploymentStatuses: {},
blocks: {},
edges: [],
loops: {},

View File

@@ -8,7 +8,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
import type { Variable } from '@/stores/panel/variables/types'
import type { Variable } from '@/stores/variables/types'
const logger = createLogger('WorkflowVariablesAPI')

View File

@@ -90,6 +90,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
}
// Sidebar width
var defaultSidebarWidth = '248px';
try {
var stored = localStorage.getItem('sidebar-state');
if (stored) {
@@ -108,11 +109,15 @@ export default function RootLayout({ children }: { children: React.ReactNode })
document.documentElement.style.setProperty('--sidebar-width', width + 'px');
} else if (width > maxSidebarWidth) {
document.documentElement.style.setProperty('--sidebar-width', maxSidebarWidth + 'px');
} else {
document.documentElement.style.setProperty('--sidebar-width', defaultSidebarWidth);
}
}
} else {
document.documentElement.style.setProperty('--sidebar-width', defaultSidebarWidth);
}
} catch (e) {
// Fallback handled by CSS defaults
document.documentElement.style.setProperty('--sidebar-width', defaultSidebarWidth);
}
// Panel width and active tab

View File

@@ -1,42 +1,44 @@
import { getBaseUrl } from '@/lib/core/utils/urls'
export async function GET() {
export function GET() {
const baseUrl = getBaseUrl()
const llmsContent = `# Sim
const content = `# Sim
> Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.
> Sim is the open-source platform to build AI agents and run your agentic workforce. Connect integrations and LLMs to deploy and orchestrate agentic workflows.
Sim lets teams create agents, workflows, knowledge bases, tables, and docs. Over 100,000 builders use Sim — from startups to Fortune 500 companies. SOC2 compliant.
Sim lets teams create agents, workflows, knowledge bases, tables, and docs. It supports both product discovery pages and deeper technical documentation.
## Core Pages
## Preferred URLs
- [Homepage](${baseUrl}): Product overview, features, and pricing
- [Homepage](${baseUrl}): Product overview and primary entry point
- [Integrations directory](${baseUrl}/integrations): Public catalog of integrations and automation capabilities
- [Models directory](${baseUrl}/models): Public catalog of AI models, pricing, context windows, and capabilities
- [Blog](${baseUrl}/blog): Announcements, guides, and product context
- [Changelog](${baseUrl}/changelog): Product updates and release notes
- [Sim Blog](${baseUrl}/blog): Announcements, insights, and guides
## Documentation
- [Documentation](https://docs.sim.ai): Complete guides and API reference
- [Quickstart](https://docs.sim.ai/quickstart): Get started in 5 minutes
- [API Reference](https://docs.sim.ai/api): REST API documentation
- [Documentation](https://docs.sim.ai): Product guides and technical reference
- [Quickstart](https://docs.sim.ai/quickstart): Fastest path to getting started
- [API Reference](https://docs.sim.ai/api): API documentation
## Key Concepts
- **Workspace**: Container for workflows, data sources, and executions
- **Workflow**: Directed graph of blocks defining an agentic process
- **Block**: Individual step (LLM call, tool call, HTTP request, code execution)
- **Block**: Individual step such as an LLM call, tool call, HTTP request, or code execution
- **Trigger**: Event or schedule that initiates workflow execution
- **Execution**: A single run of a workflow with logs and outputs
- **Knowledge Base**: Vector-indexed document store for retrieval-augmented generation
- **Knowledge Base**: Document store used for retrieval-augmented generation
## Capabilities
- AI agent creation and deployment
- Agentic workflow orchestration
- 1,000+ integrations (Slack, Gmail, Notion, Airtable, databases, and more)
- Multi-model LLM orchestration (OpenAI, Anthropic, Google, Mistral, xAI, Perplexity)
- Knowledge base creation with retrieval-augmented generation (RAG)
- Integrations across business tools, databases, and communication platforms
- Multi-model LLM orchestration
- Knowledge bases and retrieval-augmented generation
- Table creation and management
- Document creation and processing
- Scheduled and webhook-triggered executions
@@ -45,24 +47,19 @@ Sim lets teams create agents, workflows, knowledge bases, tables, and docs. Over
- AI agent deployment and orchestration
- Knowledge bases and RAG pipelines
- Document creation and processing
- Customer support automation
- Internal operations (sales, marketing, legal, finance)
- Internal operations workflows across sales, marketing, legal, and finance
## Links
## Additional Links
- [GitHub Repository](https://github.com/simstudioai/sim): Open-source codebase
- [Discord Community](https://discord.gg/Hr4UWYEcTT): Get help and connect with 100,000+ builders
- [X/Twitter](https://x.com/simdotai): Product updates and announcements
## Optional
- [Careers](https://jobs.ashbyhq.com/sim): Join the Sim team
- [Docs](https://docs.sim.ai): Canonical documentation source
- [Terms of Service](${baseUrl}/terms): Legal terms
- [Privacy Policy](${baseUrl}/privacy): Data handling practices
- [Sitemap](${baseUrl}/sitemap.xml): Public URL inventory
`
return new Response(llmsContent, {
return new Response(content, {
headers: {
'Content-Type': 'text/markdown; charset=utf-8',
'Cache-Control': 'public, max-age=86400, s-maxage=86400',

View File

@@ -8,6 +8,34 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = getBaseUrl()
const now = new Date()
const integrationPages: MetadataRoute.Sitemap = integrations.map((integration) => ({
url: `${baseUrl}/integrations/${integration.slug}`,
lastModified: now,
}))
const modelHubPages: MetadataRoute.Sitemap = [
{
url: `${baseUrl}/integrations`,
lastModified: now,
},
{
url: `${baseUrl}/models`,
lastModified: now,
},
{
url: `${baseUrl}/partners`,
lastModified: now,
},
]
const providerPages: MetadataRoute.Sitemap = MODEL_PROVIDERS_WITH_CATALOGS.map((provider) => ({
url: `${baseUrl}${provider.href}`,
lastModified: new Date(
Math.max(...provider.models.map((model) => new Date(model.pricing.updatedAt).getTime()))
),
}))
const modelPages: MetadataRoute.Sitemap = ALL_CATALOG_MODELS.map((model) => ({
url: `${baseUrl}${model.href}`,
lastModified: new Date(model.pricing.updatedAt),
}))
const staticPages: MetadataRoute.Sitemap = [
{
@@ -26,14 +54,6 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
// url: `${baseUrl}/templates`,
// lastModified: now,
// },
{
url: `${baseUrl}/integrations`,
lastModified: now,
},
{
url: `${baseUrl}/models`,
lastModified: now,
},
{
url: `${baseUrl}/changelog`,
lastModified: now,
@@ -54,20 +74,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
lastModified: new Date(p.updated ?? p.date),
}))
const integrationPages: MetadataRoute.Sitemap = integrations.map((i) => ({
url: `${baseUrl}/integrations/${i.slug}`,
lastModified: now,
}))
const providerPages: MetadataRoute.Sitemap = MODEL_PROVIDERS_WITH_CATALOGS.map((provider) => ({
url: `${baseUrl}${provider.href}`,
lastModified: now,
}))
const modelPages: MetadataRoute.Sitemap = ALL_CATALOG_MODELS.map((model) => ({
url: `${baseUrl}${model.href}`,
lastModified: new Date(model.pricing.updatedAt),
}))
return [...staticPages, ...blogPages, ...integrationPages, ...providerPages, ...modelPages]
return [
...staticPages,
...modelHubPages,
...integrationPages,
...providerPages,
...modelPages,
...blogPages,
]
}

View File

@@ -108,8 +108,6 @@ function normalizeWorkflowState(input?: any): WorkflowState | null {
lastUpdate: input.lastUpdate,
metadata: input.metadata,
variables: input.variables,
deploymentStatuses: input.deploymentStatuses,
needsRedeployment: input.needsRedeployment,
dragStartPosition: input.dragStartPosition ?? null,
}

View File

@@ -1,22 +1,59 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Check, Copy, Ellipsis, Hash } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Button,
Check,
Copy,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Textarea,
ThumbsDown,
ThumbsUp,
} from '@/components/emcn'
import { useSubmitCopilotFeedback } from '@/hooks/queries/copilot-feedback'
const SPECIAL_TAGS = 'thinking|options|usage_upgrade|credential|mothership-error|file'
function toPlainText(raw: string): string {
return (
raw
// Strip special tags and their contents
.replace(new RegExp(`<\\/?(${SPECIAL_TAGS})(?:>[\\s\\S]*?<\\/(${SPECIAL_TAGS})>|>)`, 'g'), '')
// Strip markdown
.replace(/^#{1,6}\s+/gm, '')
.replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/\*(.+?)\*/g, '$1')
.replace(/`{3}[\s\S]*?`{3}/g, '')
.replace(/`(.+?)`/g, '$1')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/^[>\-*]\s+/gm, '')
.replace(/!\[[^\]]*\]\([^)]+\)/g, '')
// Normalize whitespace
.replace(/\n{3,}/g, '\n\n')
.trim()
)
}
const ICON_CLASS = 'h-[14px] w-[14px]'
const BUTTON_CLASS =
'flex h-[26px] w-[26px] items-center justify-center rounded-[6px] text-[var(--text-icon)] transition-colors hover-hover:bg-[var(--surface-hover)] focus-visible:outline-none'
interface MessageActionsProps {
content: string
requestId?: string
chatId?: string
userQuery?: string
}
export function MessageActions({ content, requestId }: MessageActionsProps) {
const [copied, setCopied] = useState<'message' | 'request' | null>(null)
export function MessageActions({ content, chatId, userQuery }: MessageActionsProps) {
const [copied, setCopied] = useState(false)
const [pendingFeedback, setPendingFeedback] = useState<'up' | 'down' | null>(null)
const [feedbackText, setFeedbackText] = useState('')
const resetTimeoutRef = useRef<number | null>(null)
const submitFeedback = useSubmitCopilotFeedback()
useEffect(() => {
return () => {
@@ -26,59 +63,119 @@ export function MessageActions({ content, requestId }: MessageActionsProps) {
}
}, [])
const copyToClipboard = useCallback(async (text: string, type: 'message' | 'request') => {
const copyToClipboard = useCallback(async () => {
if (!content) return
const text = toPlainText(content)
if (!text) return
try {
await navigator.clipboard.writeText(text)
setCopied(type)
setCopied(true)
if (resetTimeoutRef.current !== null) {
window.clearTimeout(resetTimeoutRef.current)
}
resetTimeoutRef.current = window.setTimeout(() => setCopied(null), 1500)
resetTimeoutRef.current = window.setTimeout(() => setCopied(false), 1500)
} catch {
/* clipboard unavailable */
}
}, [content])
const handleFeedbackClick = useCallback(
(type: 'up' | 'down') => {
if (chatId && userQuery) {
setPendingFeedback(type)
setFeedbackText('')
}
},
[chatId, userQuery]
)
const handleSubmitFeedback = useCallback(() => {
if (!pendingFeedback || !chatId || !userQuery) return
const text = feedbackText.trim()
if (!text) {
setPendingFeedback(null)
setFeedbackText('')
return
}
submitFeedback.mutate({
chatId,
userQuery,
agentResponse: content,
isPositiveFeedback: pendingFeedback === 'up',
feedback: text,
})
setPendingFeedback(null)
setFeedbackText('')
}, [pendingFeedback, chatId, userQuery, content, feedbackText])
const handleModalClose = useCallback((open: boolean) => {
if (!open) {
setPendingFeedback(null)
setFeedbackText('')
}
}, [])
if (!content && !requestId) {
return null
}
if (!content) return null
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<>
<div className='flex items-center gap-0.5'>
<button
type='button'
aria-label='More options'
className='flex h-5 w-5 items-center justify-center rounded-sm text-[var(--text-icon)] opacity-0 transition-colors transition-opacity hover-hover:bg-[var(--surface-3)] hover-hover:text-[var(--text-primary)] focus-visible:opacity-100 focus-visible:outline-none group-hover/msg:opacity-100 data-[state=open]:opacity-100'
onClick={(event) => event.stopPropagation()}
aria-label='Copy message'
onClick={copyToClipboard}
className={BUTTON_CLASS}
>
<Ellipsis className='h-3 w-3' strokeWidth={2} />
{copied ? <Check className={ICON_CLASS} /> : <Copy className={ICON_CLASS} />}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' side='top' sideOffset={4}>
<DropdownMenuItem
disabled={!content}
onSelect={(event) => {
event.stopPropagation()
void copyToClipboard(content, 'message')
}}
<button
type='button'
aria-label='Like'
onClick={() => handleFeedbackClick('up')}
className={BUTTON_CLASS}
>
{copied === 'message' ? <Check /> : <Copy />}
<span>Copy Message</span>
</DropdownMenuItem>
<DropdownMenuItem
disabled={!requestId}
onSelect={(event) => {
event.stopPropagation()
if (requestId) {
void copyToClipboard(requestId, 'request')
}
}}
<ThumbsUp className={ICON_CLASS} />
</button>
<button
type='button'
aria-label='Dislike'
onClick={() => handleFeedbackClick('down')}
className={BUTTON_CLASS}
>
{copied === 'request' ? <Check /> : <Hash />}
<span>Copy Request ID</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ThumbsDown className={ICON_CLASS} />
</button>
</div>
<Modal open={pendingFeedback !== null} onOpenChange={handleModalClose}>
<ModalContent size='sm'>
<ModalHeader>Give feedback</ModalHeader>
<ModalBody>
<div className='flex flex-col gap-2'>
<p className='font-medium text-[var(--text-secondary)] text-sm'>
{pendingFeedback === 'up' ? 'What did you like?' : 'What could be improved?'}
</p>
<Textarea
placeholder={
pendingFeedback === 'up'
? 'Tell us what was helpful...'
: 'Tell us what went wrong...'
}
value={feedbackText}
onChange={(e) => setFeedbackText(e.target.value)}
rows={3}
/>
</div>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => handleModalClose(false)}>
Cancel
</Button>
<Button variant='primary' onClick={handleSubmitFeedback}>
Submit
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
)
}

View File

@@ -16,7 +16,7 @@ import {
} from '@/components/emcn'
import { client, useSession } from '@/lib/auth/auth-client'
import type { OAuthReturnContext } from '@/lib/credentials/client-state'
import { writeOAuthReturnContext } from '@/lib/credentials/client-state'
import { ADD_CONNECTOR_SEARCH_PARAM, writeOAuthReturnContext } from '@/lib/credentials/client-state'
import {
getCanonicalScopesForProvider,
getProviderIdFromServiceId,
@@ -59,8 +59,8 @@ type OAuthModalConnectProps = OAuthModalBaseProps & {
workspaceId: string
credentialCount: number
} & (
| { workflowId: string; knowledgeBaseId?: never }
| { workflowId?: never; knowledgeBaseId: string }
| { workflowId: string; knowledgeBaseId?: never; connectorType?: never }
| { workflowId?: never; knowledgeBaseId: string; connectorType?: string }
)
interface OAuthModalReauthorizeProps extends OAuthModalBaseProps {
@@ -81,6 +81,7 @@ export function OAuthModal(props: OAuthModalProps) {
const workspaceId = isConnect ? props.workspaceId : ''
const workflowId = isConnect ? props.workflowId : undefined
const knowledgeBaseId = isConnect ? props.knowledgeBaseId : undefined
const connectorType = isConnect ? props.connectorType : undefined
const toolName = !isConnect ? props.toolName : ''
const requiredScopes = !isConnect ? (props.requiredScopes ?? EMPTY_SCOPES) : EMPTY_SCOPES
const newScopes = !isConnect ? (props.newScopes ?? EMPTY_SCOPES) : EMPTY_SCOPES
@@ -172,7 +173,7 @@ export function OAuthModal(props: OAuthModalProps) {
}
const returnContext: OAuthReturnContext = knowledgeBaseId
? { ...baseContext, origin: 'kb-connectors' as const, knowledgeBaseId }
? { ...baseContext, origin: 'kb-connectors' as const, knowledgeBaseId, connectorType }
: { ...baseContext, origin: 'workflow' as const, workflowId: workflowId! }
writeOAuthReturnContext(returnContext)
@@ -205,7 +206,11 @@ export function OAuthModal(props: OAuthModalProps) {
return
}
await client.oauth2.link({ providerId, callbackURL: window.location.href })
const callbackURL = new URL(window.location.href)
if (connectorType) {
callbackURL.searchParams.set(ADD_CONNECTOR_SEARCH_PARAM, connectorType)
}
await client.oauth2.link({ providerId, callbackURL: callbackURL.toString() })
handleClose()
} catch (err) {
logger.error('Failed to initiate OAuth connection', { error: err })

View File

@@ -1,9 +0,0 @@
import { Loader2 } from 'lucide-react'
export default function FileViewLoading() {
return (
<div className='fixed inset-0 z-50 flex items-center justify-center bg-[var(--bg)]'>
<Loader2 className='h-[20px] w-[20px] animate-spin text-[var(--text-tertiary)]' />
</div>
)
}

View File

@@ -473,9 +473,9 @@ function MothershipErrorDisplay({ data }: { data: MothershipErrorTagData }) {
const detail = data.code ? `${data.message} (${data.code})` : data.message
return (
<span className='animate-stream-fade-in font-base text-[13px] text-[var(--text-secondary)] italic leading-[20px]'>
<p className='animate-stream-fade-in font-base text-[13px] text-[var(--text-secondary)] italic leading-[20px]'>
{detail}
</span>
</p>
)
}

View File

@@ -35,6 +35,7 @@ interface MothershipChatProps {
onSendQueuedMessage: (id: string) => Promise<void>
onEditQueuedMessage: (id: string) => void
userId?: string
chatId?: string
onContextAdd?: (context: ChatContext) => void
editValue?: string
onEditValueConsumed?: () => void
@@ -53,7 +54,7 @@ const LAYOUT_STYLES = {
userRow: 'flex flex-col items-end gap-[6px] pt-3',
attachmentWidth: 'max-w-[70%]',
userBubble: 'max-w-[70%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3.5 py-2',
assistantRow: 'group/msg relative pb-5',
assistantRow: 'group/msg',
footer: 'flex-shrink-0 px-[24px] pb-[16px]',
footerInner: 'mx-auto max-w-[42rem]',
},
@@ -63,7 +64,7 @@ const LAYOUT_STYLES = {
userRow: 'flex flex-col items-end gap-[6px] pt-2',
attachmentWidth: 'max-w-[85%]',
userBubble: 'max-w-[85%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3 py-2',
assistantRow: 'group/msg relative pb-3',
assistantRow: 'group/msg',
footer: 'flex-shrink-0 px-3 pb-3',
footerInner: '',
},
@@ -80,6 +81,7 @@ export function MothershipChat({
onSendQueuedMessage,
onEditQueuedMessage,
userId,
chatId,
onContextAdd,
editValue,
onEditValueConsumed,
@@ -147,20 +149,28 @@ export function MothershipChat({
}
const isLastMessage = index === messages.length - 1
const precedingUserMsg = [...messages]
.slice(0, index)
.reverse()
.find((m) => m.role === 'user')
return (
<div key={msg.id} className={styles.assistantRow}>
{!isThisStreaming && (msg.content || msg.contentBlocks?.length) && (
<div className='absolute right-0 bottom-0 z-10'>
<MessageActions content={msg.content} requestId={msg.requestId} />
</div>
)}
<MessageContent
blocks={msg.contentBlocks || []}
fallbackContent={msg.content}
isStreaming={isThisStreaming}
onOptionSelect={isLastMessage ? onSubmit : undefined}
/>
{!isThisStreaming && (msg.content || msg.contentBlocks?.length) && (
<div className='mt-2.5'>
<MessageActions
content={msg.content}
chatId={chatId}
userQuery={precedingUserMsg?.content}
/>
</div>
)}
</div>
)
})}

View File

@@ -115,7 +115,7 @@ export const MothershipView = memo(
<div
ref={ref}
className={cn(
'relative z-10 flex h-full flex-col overflow-hidden border-[var(--border)] bg-[var(--bg)] transition-[width,min-width,border-width] duration-500 ease-[cubic-bezier(0.16,1,0.3,1)]',
'relative z-10 flex h-full flex-col overflow-hidden border-[var(--border)] bg-[var(--bg)] transition-[width,min-width,border-width] duration-200 ease-[cubic-bezier(0.25,0.1,0.25,1)]',
isCollapsed ? 'w-0 min-w-0 border-l-0' : 'w-1/2 border-l',
className
)}

View File

@@ -348,6 +348,7 @@ export function Home({ chatId }: HomeProps = {}) {
onSendQueuedMessage={sendNow}
onEditQueuedMessage={handleEditQueuedMessage}
userId={session?.user?.id}
chatId={resolvedChatId}
onContextAdd={handleContextAdd}
editValue={editingInputValue}
onEditValueConsumed={clearEditingValue}

View File

@@ -1407,17 +1407,6 @@ export function useChat(
const output = tc.result?.output as Record<string, unknown> | undefined
const deployedWorkflowId = (output?.workflowId as string) ?? undefined
if (deployedWorkflowId && typeof output?.isDeployed === 'boolean') {
const isDeployed = output.isDeployed as boolean
const serverDeployedAt = output.deployedAt
? new Date(output.deployedAt as string)
: undefined
useWorkflowRegistry
.getState()
.setDeploymentStatus(
deployedWorkflowId,
isDeployed,
isDeployed ? (serverDeployedAt ?? new Date()) : undefined
)
queryClient.invalidateQueries({
queryKey: deploymentKeys.info(deployedWorkflowId),
})

View File

@@ -1,22 +0,0 @@
import { Skeleton } from '@/components/emcn'
const SKELETON_LINE_COUNT = 4
export default function HomeLoading() {
return (
<div className='flex h-full flex-col bg-[var(--bg)]'>
<div className='min-h-0 flex-1 overflow-hidden px-6 py-4'>
<div className='mx-auto max-w-[42rem] space-y-[10px] pt-3'>
{Array.from({ length: SKELETON_LINE_COUNT }).map((_, i) => (
<Skeleton key={i} className='h-[16px]' style={{ width: `${120 + (i % 4) * 48}px` }} />
))}
</div>
</div>
<div className='flex-shrink-0 px-[24px] pb-[16px]'>
<div className='mx-auto max-w-[42rem]'>
<Skeleton className='h-[48px] w-full rounded-[12px]' />
</div>
</div>
</div>
)
}

View File

@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { format } from 'date-fns'
import { AlertCircle, Loader2, Pencil, Plus, Tag, X } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { useParams, usePathname, useRouter, useSearchParams } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
import {
Badge,
@@ -25,6 +25,7 @@ import {
import { Database, DatabaseX } from '@/components/emcn/icons'
import { SearchHighlight } from '@/components/ui/search-highlight'
import { cn } from '@/lib/core/utils/cn'
import { ADD_CONNECTOR_SEARCH_PARAM } from '@/lib/credentials/client-state'
import { ALL_TAG_SLOTS, type AllTagSlot, getFieldTypeForSlot } from '@/lib/knowledge/constants'
import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types'
import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/filters/types'
@@ -192,6 +193,10 @@ export function KnowledgeBase({
}: KnowledgeBaseProps) {
const params = useParams()
const workspaceId = propWorkspaceId || (params.workspaceId as string)
const router = useRouter()
const searchParams = useSearchParams()
const pathname = usePathname()
const addConnectorParam = searchParams.get(ADD_CONNECTOR_SEARCH_PARAM)
const posthog = usePostHog()
useEffect(() => {
@@ -278,7 +283,29 @@ export function KnowledgeBase({
const [contextMenuDocument, setContextMenuDocument] = useState<DocumentData | null>(null)
const [showRenameModal, setShowRenameModal] = useState(false)
const [documentToRename, setDocumentToRename] = useState<DocumentData | null>(null)
const [showAddConnectorModal, setShowAddConnectorModal] = useState(false)
const showAddConnectorModal = addConnectorParam != null
const searchParamsRef = useRef(searchParams)
searchParamsRef.current = searchParams
const updateAddConnectorParam = useCallback(
(value: string | null) => {
const current = searchParamsRef.current
const currentValue = current.get(ADD_CONNECTOR_SEARCH_PARAM)
if (value === currentValue || (value === null && currentValue === null)) return
const next = new URLSearchParams(current.toString())
if (value === null) {
next.delete(ADD_CONNECTOR_SEARCH_PARAM)
} else {
next.set(ADD_CONNECTOR_SEARCH_PARAM, value)
}
const qs = next.toString()
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false })
},
[pathname, router]
)
const setShowAddConnectorModal = useCallback(
(open: boolean) => updateAddConnectorParam(open ? '' : null),
[updateAddConnectorParam]
)
const {
isOpen: isContextMenuOpen,
@@ -340,8 +367,6 @@ export function KnowledgeBase({
prevHadSyncingRef.current = hasSyncingConnectors
}, [hasSyncingConnectors, refreshKnowledgeBase, refreshDocuments])
const router = useRouter()
const knowledgeBaseName = knowledgeBase?.name || passedKnowledgeBaseName || 'Knowledge Base'
const error = knowledgeBaseError || documentsError
@@ -1254,7 +1279,13 @@ export function KnowledgeBase({
/>
{showAddConnectorModal && (
<AddConnectorModal open onOpenChange={setShowAddConnectorModal} knowledgeBaseId={id} />
<AddConnectorModal
open
onOpenChange={setShowAddConnectorModal}
onConnectorTypeChange={updateAddConnectorParam}
knowledgeBaseId={id}
initialConnectorType={addConnectorParam || undefined}
/>
)}
{documentToRename && (

View File

@@ -44,14 +44,22 @@ const CONNECTOR_ENTRIES = Object.entries(CONNECTOR_REGISTRY)
interface AddConnectorModalProps {
open: boolean
onOpenChange: (open: boolean) => void
onConnectorTypeChange?: (connectorType: string | null) => void
knowledgeBaseId: string
initialConnectorType?: string | null
}
type Step = 'select-type' | 'configure'
export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddConnectorModalProps) {
const [step, setStep] = useState<Step>('select-type')
const [selectedType, setSelectedType] = useState<string | null>(null)
export function AddConnectorModal({
open,
onOpenChange,
onConnectorTypeChange,
knowledgeBaseId,
initialConnectorType,
}: AddConnectorModalProps) {
const [step, setStep] = useState<Step>(() => (initialConnectorType ? 'configure' : 'select-type'))
const [selectedType, setSelectedType] = useState<string | null>(initialConnectorType ?? null)
const [sourceConfig, setSourceConfig] = useState<Record<string, string>>({})
const [syncInterval, setSyncInterval] = useState(1440)
const [selectedCredentialId, setSelectedCredentialId] = useState<string | null>(null)
@@ -151,6 +159,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
setError(null)
setSearchTerm('')
setStep('configure')
onConnectorTypeChange?.(type)
}
const handleFieldChange = useCallback(
@@ -286,7 +295,10 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
<Button
variant='ghost'
className='mr-2 h-6 w-6 p-0'
onClick={() => setStep('select-type')}
onClick={() => {
setStep('select-type')
onConnectorTypeChange?.('')
}}
>
<ArrowLeft className='h-4 w-4' />
</Button>
@@ -565,6 +577,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
workspaceId={workspaceId}
knowledgeBaseId={knowledgeBaseId}
credentialCount={credentials.length}
connectorType={selectedType ?? undefined}
/>
)}
</>

View File

@@ -1,13 +0,0 @@
import { Loader2 } from 'lucide-react'
export default function TaskLoading() {
return (
<div className='flex h-full bg-[var(--bg)]'>
<div className='flex h-full min-w-0 flex-1 flex-col'>
<div className='flex min-h-0 flex-1 items-center justify-center'>
<Loader2 className='h-[20px] w-[20px] animate-spin text-[var(--text-tertiary)]' />
</div>
</div>
</div>
)
}

View File

@@ -111,8 +111,6 @@ function normalizeWorkflowState(input?: any): WorkflowState | null {
lastUpdate: input.lastUpdate,
metadata: input.metadata,
variables: input.variables,
deploymentStatuses: input.deploymentStatuses,
needsRedeployment: input.needsRedeployment,
dragStartPosition: input.dragStartPosition ?? null,
}

View File

@@ -40,7 +40,6 @@ import { useWorkflowMap } from '@/hooks/queries/workflows'
import { useWorkspaceSettings } from '@/hooks/queries/workspace'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
@@ -90,10 +89,7 @@ export function DeployModal({
const params = useParams()
const workspaceId = params?.workspaceId as string
const { navigateToSettings } = useSettingsNavigation()
const deploymentStatus = useWorkflowRegistry((state) =>
state.getWorkflowDeploymentStatus(workflowId)
)
const isDeployed = deploymentStatus?.isDeployed ?? isDeployedProp
const isDeployed = isDeployedProp
const { data: workflowMap = {} } = useWorkflowMap(workspaceId)
const workflowMetadata = workflowId ? workflowMap[workflowId] : undefined
const workflowWorkspaceId = workflowMetadata?.workspaceId ?? null
@@ -381,8 +377,6 @@ export function DeployModal({
invalidateDeploymentQueries(queryClient, workflowId)
useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, false)
if (chatSuccessTimeoutRef.current) {
clearTimeout(chatSuccessTimeoutRef.current)
}

View File

@@ -9,7 +9,7 @@ import {
useDeployment,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
import { useDeployedWorkflowState } from '@/hooks/queries/deployments'
import { useDeployedWorkflowState, useDeploymentInfo } from '@/hooks/queries/deployments'
import type { WorkspaceUserPermissions } from '@/hooks/use-user-permissions'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -25,10 +25,10 @@ export function Deploy({ activeWorkflowId, userPermissions, className }: DeployP
const isRegistryLoading = hydrationPhase === 'idle' || hydrationPhase === 'state-loading'
const { hasBlocks } = useCurrentWorkflow()
const deploymentStatus = useWorkflowRegistry((state) =>
state.getWorkflowDeploymentStatus(activeWorkflowId)
)
const isDeployed = deploymentStatus?.isDeployed || false
const { data: deploymentInfo } = useDeploymentInfo(activeWorkflowId, {
enabled: !isRegistryLoading,
})
const isDeployed = deploymentInfo?.isDeployed ?? false
const isDeployedStateEnabled = Boolean(activeWorkflowId) && isDeployed && !isRegistryLoading
const {

View File

@@ -1,7 +1,7 @@
import { useMemo } from 'react'
import { hasWorkflowChanged } from '@/lib/workflows/comparison'
import { mergeSubblockStateWithValues } from '@/lib/workflows/subblocks'
import { useVariablesStore } from '@/stores/panel/variables/store'
import { useVariablesStore } from '@/stores/variables/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'

View File

@@ -31,8 +31,8 @@ import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/
import { getBlock } from '@/blocks'
import type { BlockConfig } from '@/blocks/types'
import { normalizeName } from '@/executor/constants'
import type { Variable } from '@/stores/panel'
import { useVariablesStore } from '@/stores/panel'
import { useVariablesStore } from '@/stores/variables/store'
import type { Variable } from '@/stores/variables/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'

View File

@@ -19,8 +19,8 @@ import {
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import type { Variable } from '@/stores/panel'
import { useVariablesStore } from '@/stores/panel'
import { useVariablesStore } from '@/stores/variables/store'
import type { Variable } from '@/stores/variables/types'
interface VariableAssignment {
id: string

View File

@@ -5,8 +5,8 @@ import { useParams } from 'next/navigation'
import { SELECTOR_CONTEXT_FIELDS } from '@/lib/workflows/subblocks/context'
import type { SubBlockConfig } from '@/blocks/types'
import { extractEnvVarName, isEnvVarReference, isReference } from '@/executor/constants'
import { usePersonalEnvironment } from '@/hooks/queries/environment'
import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
import { useEnvironmentStore } from '@/stores/settings/environment'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useDependsOnGate } from './use-depends-on-gate'
import { useSubBlockValue } from './use-sub-block-value'
@@ -32,7 +32,7 @@ export function useSelectorSetup(
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
const workflowId = (params?.workflowId as string) || activeWorkflowId || ''
const envVariables = useEnvironmentStore((s) => s.variables)
const { data: envVariables = {} } = usePersonalEnvironment()
const { finalDisabled, dependencyValues, canonicalIndex } = useDependsOnGate(
blockId,

View File

@@ -63,7 +63,8 @@ import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
import { useChatStore } from '@/stores/chat/store'
import { useNotificationStore } from '@/stores/notifications/store'
import type { ChatContext, PanelTab } from '@/stores/panel'
import { usePanelStore, useVariablesStore as usePanelVariablesStore } from '@/stores/panel'
import { usePanelStore } from '@/stores/panel'
import { useVariablesModalStore } from '@/stores/variables/modal'
import { useVariablesStore } from '@/stores/variables/store'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { captureBaselineSnapshot } from '@/stores/workflow-diff/utils'
@@ -205,7 +206,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
setIsChatOpen: state.setIsChatOpen,
}))
)
const { isOpen: isVariablesOpen, setIsOpen: setVariablesOpen } = useVariablesStore(
const { isOpen: isVariablesOpen, setIsOpen: setVariablesOpen } = useVariablesModalStore(
useShallow((state) => ({
isOpen: state.isOpen,
setIsOpen: state.setIsOpen,
@@ -410,6 +411,17 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
setHasHydrated(true)
}, [setHasHydrated])
useEffect(() => {
const handler = (e: Event) => {
const message = (e as CustomEvent<{ message: string }>).detail?.message
if (!message) return
setActiveTab('copilot')
copilotSendMessage(message)
}
window.addEventListener('mothership-send-message', handler)
return () => window.removeEventListener('mothership-send-message', handler)
}, [setActiveTab, copilotSendMessage])
/**
* Handles tab click events
*/
@@ -482,7 +494,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
throw new Error('No workflow state found')
}
const workflowVariables = usePanelVariablesStore
const workflowVariables = useVariablesStore
.getState()
.getVariablesByWorkflowId(activeWorkflowId)
@@ -827,6 +839,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
onSendQueuedMessage={copilotSendNow}
onEditQueuedMessage={handleCopilotEditQueuedMessage}
userId={session?.user?.id}
chatId={copilotResolvedChatId}
editValue={copilotEditingInputValue}
onEditValueConsumed={clearCopilotEditingValue}
layout='copilot-view'

View File

@@ -27,15 +27,15 @@ import {
usePreventZoom,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useVariablesStore as usePanelVariablesStore } from '@/stores/panel'
import {
getVariablesPosition,
MAX_VARIABLES_HEIGHT,
MAX_VARIABLES_WIDTH,
MIN_VARIABLES_HEIGHT,
MIN_VARIABLES_WIDTH,
useVariablesStore,
} from '@/stores/variables/store'
useVariablesModalStore,
} from '@/stores/variables/modal'
import { useVariablesStore } from '@/stores/variables/store'
import type { Variable } from '@/stores/variables/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -96,7 +96,7 @@ export function Variables() {
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
const { isOpen, position, width, height, setIsOpen, setPosition, setDimensions } =
useVariablesStore(
useVariablesModalStore(
useShallow((s) => ({
isOpen: s.isOpen,
position: s.position,
@@ -108,7 +108,7 @@ export function Variables() {
}))
)
const variables = usePanelVariablesStore((s) => s.variables)
const variables = useVariablesStore((s) => s.variables)
const { collaborativeUpdateVariable, collaborativeAddVariable, collaborativeDeleteVariable } =
useCollaborativeWorkflow()

View File

@@ -48,7 +48,7 @@ import { useSkills } from '@/hooks/queries/skills'
import { useTablesList } from '@/hooks/queries/tables'
import { useWorkflowMap } from '@/hooks/queries/workflows'
import { useSelectorDisplayName } from '@/hooks/use-selector-display-name'
import { useVariablesStore } from '@/stores/panel'
import { useVariablesStore } from '@/stores/variables/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { wouldCreateCycle } from '@/stores/workflows/workflow/utils'

View File

@@ -2,7 +2,6 @@ import { useMemo } from 'react'
import type { Edge } from 'reactflow'
import { useShallow } from 'zustand/react/shallow'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import type { DeploymentStatus } from '@/stores/workflows/registry/types'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { BlockState, Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types'
@@ -16,8 +15,6 @@ export interface CurrentWorkflow {
loops: Record<string, Loop>
parallels: Record<string, Parallel>
lastSaved?: number
deploymentStatuses?: Record<string, DeploymentStatus>
needsRedeployment?: boolean
// Mode information
isDiffMode: boolean
@@ -48,8 +45,6 @@ export function useCurrentWorkflow(): CurrentWorkflow {
loops: state.loops,
parallels: state.parallels,
lastSaved: state.lastSaved,
deploymentStatuses: state.deploymentStatuses,
needsRedeployment: state.needsRedeployment,
}))
)
@@ -78,8 +73,6 @@ export function useCurrentWorkflow(): CurrentWorkflow {
loops: activeWorkflow.loops || {},
parallels: activeWorkflow.parallels || {},
lastSaved: activeWorkflow.lastSaved,
deploymentStatuses: activeWorkflow.deploymentStatuses,
needsRedeployment: activeWorkflow.needsRedeployment,
// Mode information - update to reflect ready state
isDiffMode: hasActiveDiff && isShowingDiff,

View File

@@ -36,8 +36,6 @@ import { useExecutionStream } from '@/hooks/use-execution-stream'
import { WorkflowValidationError } from '@/serializer'
import { useCurrentWorkflowExecution, useExecutionStore } from '@/stores/execution'
import { useNotificationStore } from '@/stores/notifications'
import { useVariablesStore } from '@/stores/panel'
import { useEnvironmentStore } from '@/stores/settings/environment'
import {
clearExecutionPointer,
consolePersistence,
@@ -45,6 +43,7 @@ import {
saveExecutionPointer,
useTerminalConsoleStore,
} from '@/stores/terminal'
import { useVariablesStore } from '@/stores/variables/store'
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
@@ -120,7 +119,6 @@ export function useWorkflowExecution() {
}))
)
const hasHydrated = useTerminalConsoleStore((s) => s._hasHydrated)
const getAllVariables = useEnvironmentStore((s) => s.getAllVariables)
const { getVariablesByWorkflowId, variables } = useVariablesStore(
useShallow((s) => ({
getVariablesByWorkflowId: s.getVariablesByWorkflowId,
@@ -744,7 +742,6 @@ export function useWorkflowExecution() {
activeWorkflowId,
currentWorkflow,
toggleConsole,
getAllVariables,
getVariablesByWorkflowId,
setIsExecuting,
setIsDebugging,

View File

@@ -1,11 +0,0 @@
import { Loader2 } from 'lucide-react'
export default function WorkflowLoading() {
return (
<div className='flex h-full w-full flex-col overflow-hidden bg-[var(--bg)]'>
<div className='relative flex h-full w-full flex-1 items-center justify-center bg-[var(--bg)]'>
<Loader2 className='h-[20px] w-[20px] animate-spin text-[var(--text-tertiary)]' />
</div>
</div>
)
}

View File

@@ -124,8 +124,7 @@ export async function applyAutoLayoutAndUpdateStore(
try {
useWorkflowStore.getState().updateLastSaved()
const { deploymentStatuses, needsRedeployment, dragStartPosition, ...stateToSave } =
newWorkflowState
const { dragStartPosition, ...stateToSave } = newWorkflowState
const cleanedWorkflowState = {
...stateToSave,

View File

@@ -85,7 +85,7 @@ import { useSearchModalStore } from '@/stores/modals/search/store'
import { useNotificationStore } from '@/stores/notifications'
import { usePanelEditorStore } from '@/stores/panel'
import { useUndoRedoStore } from '@/stores/undo-redo'
import { useVariablesStore } from '@/stores/variables/store'
import { useVariablesModalStore } from '@/stores/variables/modal'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { getUniqueBlockName, prepareBlockState } from '@/stores/workflows/utils'
@@ -265,7 +265,7 @@ const WorkflowContent = React.memo(
const { fitViewToBounds, getViewportCenter } = useCanvasViewport(reactFlowInstance, {
embedded,
})
const { emitCursorUpdate } = useSocket()
const { emitCursorUpdate, joinWorkflow, leaveWorkflow } = useSocket()
useDynamicHandleRefresh()
const workspaceId = propWorkspaceId || (params.workspaceId as string)
@@ -273,6 +273,14 @@ const WorkflowContent = React.memo(
const addNotification = useNotificationStore((state) => state.addNotification)
useEffect(() => {
if (!embedded || !workflowIdParam) return
joinWorkflow(workflowIdParam)
return () => {
leaveWorkflow()
}
}, [embedded, workflowIdParam, joinWorkflow, leaveWorkflow])
useOAuthReturnForWorkflow(workflowIdParam)
const {
@@ -337,7 +345,7 @@ const WorkflowContent = React.memo(
autoConnectRef.current = isAutoConnectEnabled
// Panel open states for context menu
const isVariablesOpen = useVariablesStore((state) => state.isOpen)
const isVariablesOpen = useVariablesModalStore((state) => state.isOpen)
const isChatOpen = useChatStore((state) => state.isChatOpen)
const snapGrid: [number, number] = useMemo(
@@ -1374,7 +1382,7 @@ const WorkflowContent = React.memo(
}, [router, workspaceId, workflowIdParam])
const handleContextToggleVariables = useCallback(() => {
const { isOpen, setIsOpen } = useVariablesStore.getState()
const { isOpen, setIsOpen } = useVariablesModalStore.getState()
setIsOpen(!isOpen)
}, [])
@@ -2144,12 +2152,9 @@ const WorkflowContent = React.memo(
const handleCanvasPointerMove = useCallback(
(event: React.PointerEvent<Element>) => {
const target = event.currentTarget as HTMLElement
const bounds = target.getBoundingClientRect()
const position = screenToFlowPosition({
x: event.clientX - bounds.left,
y: event.clientY - bounds.top,
x: event.clientX,
y: event.clientY,
})
emitCursorUpdate(position)

View File

@@ -13,7 +13,7 @@ import { DELETED_WORKFLOW_LABEL } from '@/app/workspace/[workspaceId]/logs/utils
import { getDisplayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
import { getBlock } from '@/blocks'
import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types'
import { useVariablesStore } from '@/stores/panel/variables/store'
import { useVariablesStore } from '@/stores/variables/store'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
/** Execution status for blocks in preview mode */

View File

@@ -12,7 +12,6 @@ import {
Button,
Combobox,
Input,
Label,
Modal,
ModalBody,
ModalContent,
@@ -432,7 +431,7 @@ export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpM
<div ref={scrollContainerRef} className='min-h-0 flex-1 overflow-y-auto'>
<div className='space-y-3'>
<div className='flex flex-col gap-2'>
<Label htmlFor='type'>Request</Label>
<p className='font-medium text-[var(--text-secondary)] text-sm'>Request</p>
<Combobox
id='type'
options={REQUEST_TYPE_OPTIONS}
@@ -447,7 +446,7 @@ export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpM
</div>
<div className='flex flex-col gap-2'>
<Label htmlFor='subject'>Subject</Label>
<p className='font-medium text-[var(--text-secondary)] text-sm'>Subject</p>
<Input
id='subject'
placeholder='Brief description of your request'
@@ -457,7 +456,7 @@ export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpM
</div>
<div className='flex flex-col gap-2'>
<Label htmlFor='message'>Message</Label>
<p className='font-medium text-[var(--text-secondary)] text-sm'>Message</p>
<Textarea
id='message'
placeholder='Please provide details about your request...'
@@ -468,7 +467,9 @@ export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpM
</div>
<div className='flex flex-col gap-2'>
<Label>Attach Images (Optional)</Label>
<p className='font-medium text-[var(--text-secondary)] text-sm'>
Attach Images (Optional)
</p>
<Button
type='button'
variant='default'
@@ -505,7 +506,9 @@ export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpM
{images.length > 0 && (
<div className='space-y-2'>
<Label>Uploaded Images</Label>
<p className='font-medium text-[var(--text-secondary)] text-sm'>
Uploaded Images
</p>
<div className='grid grid-cols-2 gap-3'>
{images.map((image, index) => (
<div

View File

@@ -343,7 +343,11 @@ export function SearchModal({
'-translate-x-1/2 fixed top-[15%] z-50 w-[500px] rounded-xl border-[4px] border-black/[0.06] bg-[var(--bg)] shadow-[0_24px_80px_-16px_rgba(0,0,0,0.15)] dark:border-white/[0.06] dark:shadow-[0_24px_80px_-16px_rgba(0,0,0,0.4)]',
open ? 'visible opacity-100' : 'invisible opacity-0'
)}
style={{ left: 'calc(var(--sidebar-width) / 2 + 50%)' }}
style={{
left: isOnWorkflowPage
? 'calc(50% + (var(--sidebar-width) - var(--panel-width)) / 2)'
: 'calc(var(--sidebar-width) / 2 + 50%)',
}}
>
<Command label='Search' shouldFilter={false}>
<div className='mx-2 mt-2 mb-1 flex items-center gap-1.5 rounded-lg border border-[var(--border-1)] bg-[var(--surface-5)] px-2 dark:bg-[var(--surface-4)]'>

View File

@@ -316,6 +316,7 @@ export const Sidebar = memo(function Sidebar() {
const sidebarRef = useRef<HTMLElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const scrollContentRef = useRef<HTMLDivElement>(null)
const posthog = usePostHog()
const { data: sessionData, isPending: sessionLoading } = useSession()
@@ -894,6 +895,9 @@ export const Sidebar = memo(function Sidebar() {
container.addEventListener('scroll', updateScrollState, { passive: true })
const observer = new ResizeObserver(updateScrollState)
observer.observe(container)
if (scrollContentRef.current) {
observer.observe(scrollContentRef.current)
}
return () => {
container.removeEventListener('scroll', updateScrollState)
@@ -1336,275 +1340,286 @@ export const Sidebar = memo(function Sidebar() {
!hasOverflowTop && 'border-transparent'
)}
>
<div className='tasks-section flex flex-shrink-0 flex-col' data-tour='nav-tasks'>
<div className='flex h-[18px] flex-shrink-0 items-center justify-between px-4'>
<div className='font-base text-[var(--text-icon)] text-small'>All tasks</div>
{!isCollapsed && (
<div className='flex items-center justify-center gap-2'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-hover)]'
onClick={handleNewTask}
>
<Plus className='h-[16px] w-[16px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<Tooltip.Shortcut keys={isMac ? '⌘⇧K' : 'Ctrl+Shift+K'}>
New task
</Tooltip.Shortcut>
</Tooltip.Content>
</Tooltip.Root>
<div ref={scrollContentRef} className='flex flex-col'>
<div
className='tasks-section flex flex-shrink-0 flex-col'
data-tour='nav-tasks'
>
<div className='flex h-[18px] flex-shrink-0 items-center justify-between px-4'>
<div className='font-base text-[var(--text-icon)] text-small'>
All tasks
</div>
)}
</div>
{isCollapsed ? (
<CollapsedSidebarMenu
icon={tasksCollapsedIcon}
hover={tasksHover}
ariaLabel='Tasks'
className='mt-1.5'
primaryAction={tasksPrimaryAction}
>
{tasksLoading ? (
<DropdownMenuItem disabled>
<Loader className='h-[14px] w-[14px]' animate />
Loading...
</DropdownMenuItem>
) : (
tasks.map((task) => (
<CollapsedTaskFlyoutItem
key={task.id}
task={task}
isCurrentRoute={task.id !== 'new' && pathname === task.href}
isMenuOpen={menuOpenTaskId === task.id}
isEditing={task.id === taskFlyoutRename.editingId}
editValue={taskFlyoutRename.value}
inputRef={taskFlyoutRename.inputRef}
isRenaming={taskFlyoutRename.isSaving}
onEditValueChange={taskFlyoutRename.setValue}
onEditKeyDown={taskFlyoutRename.handleKeyDown}
onEditBlur={handleTaskRenameBlur}
onContextMenu={handleTaskContextMenu}
onMorePointerDown={handleTaskMorePointerDown}
onMoreClick={handleTaskMoreClick}
/>
))
)}
</CollapsedSidebarMenu>
) : (
<div className='mt-1.5 flex flex-col gap-0.5 px-2'>
{tasksLoading ? (
<SidebarItemSkeleton />
) : (
<>
{tasks.slice(0, visibleTaskCount).map((task) => {
const isCurrentRoute = task.id !== 'new' && pathname === task.href
const isRenaming = taskFlyoutRename.editingId === task.id
const isSelected = task.id !== 'new' && selectedTasks.has(task.id)
if (isRenaming) {
return (
<div
key={task.id}
className='mx-0.5 flex h-[30px] items-center gap-2 rounded-lg bg-[var(--surface-active)] px-2 text-sm'
>
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<input
ref={taskFlyoutRename.inputRef}
value={taskFlyoutRename.value}
onChange={(e) => taskFlyoutRename.setValue(e.target.value)}
onKeyDown={taskFlyoutRename.handleKeyDown}
onBlur={handleTaskRenameBlur}
className='min-w-0 flex-1 border-none bg-transparent font-base text-[14px] text-[var(--text-body)] outline-none'
/>
</div>
)
}
return (
<SidebarTaskItem
key={task.id}
task={task}
isCurrentRoute={isCurrentRoute}
isSelected={isSelected}
isActive={!!task.isActive}
isUnread={!!task.isUnread}
isMenuOpen={menuOpenTaskId === task.id}
showCollapsedTooltips={showCollapsedTooltips}
onMultiSelectClick={handleTaskClick}
onContextMenu={handleTaskContextMenu}
onMorePointerDown={handleTaskMorePointerDown}
onMoreClick={handleTaskMoreClick}
/>
)
})}
{tasks.length > visibleTaskCount && (
<button
type='button'
onClick={handleSeeMoreTasks}
className='mx-0.5 flex h-[30px] items-center gap-2 rounded-lg px-2 text-[var(--text-icon)] text-sm hover-hover:bg-[var(--surface-hover)]'
>
<MoreHorizontal className='h-[16px] w-[16px] flex-shrink-0' />
<span className='font-base'>See more</span>
</button>
)}
</>
)}
</div>
)}
</div>
<div
className='workflows-section relative mt-3.5 flex flex-col'
data-tour='nav-workflows'
>
<div className='flex h-[18px] flex-shrink-0 items-center justify-between px-4'>
<div className='font-base text-[var(--text-icon)] text-small'>Workflows</div>
{!isCollapsed && (
<div className='flex items-center justify-center gap-2'>
<DropdownMenu>
{!isCollapsed && (
<div className='flex items-center justify-center gap-2'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-hover)]'
disabled={!canEdit}
>
{isImporting || isCreatingFolder ? (
<Loader className='h-[16px] w-[16px]' animate />
) : (
<MoreHorizontal className='h-[16px] w-[16px]' />
)}
</Button>
</DropdownMenuTrigger>
<Button
variant='ghost'
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-hover)]'
onClick={handleNewTask}
>
<Plus className='h-[16px] w-[16px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>More actions</p>
<Tooltip.Shortcut keys={isMac ? '⌘⇧K' : 'Ctrl+Shift+K'}>
New task
</Tooltip.Shortcut>
</Tooltip.Content>
</Tooltip.Root>
<DropdownMenuContent
align='start'
sideOffset={8}
className='min-w-[160px]'
>
<DropdownMenuItem
onSelect={handleImportWorkflow}
disabled={!canEdit || isImporting}
>
<Download />
{isImporting ? 'Importing...' : 'Import workflow'}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={handleCreateFolder}
disabled={!canEdit || isCreatingFolder}
>
<FolderPlus />
{isCreatingFolder ? 'Creating folder...' : 'Create folder'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-hover)]'
onClick={handleCreateWorkflow}
disabled={isCreatingWorkflow || !canEdit}
>
<Plus className='h-[16px] w-[16px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
{isCreatingWorkflow ? (
<p>Creating workflow...</p>
) : (
<Tooltip.Shortcut keys={isMac ? '⌘⇧P' : 'Ctrl+Shift+P'}>
New workflow
</Tooltip.Shortcut>
</div>
)}
</div>
{isCollapsed ? (
<CollapsedSidebarMenu
icon={tasksCollapsedIcon}
hover={tasksHover}
ariaLabel='Tasks'
className='mt-1.5'
primaryAction={tasksPrimaryAction}
>
{tasksLoading ? (
<DropdownMenuItem disabled>
<Loader className='h-[14px] w-[14px]' animate />
Loading...
</DropdownMenuItem>
) : (
tasks.map((task) => (
<CollapsedTaskFlyoutItem
key={task.id}
task={task}
isCurrentRoute={task.id !== 'new' && pathname === task.href}
isMenuOpen={menuOpenTaskId === task.id}
isEditing={task.id === taskFlyoutRename.editingId}
editValue={taskFlyoutRename.value}
inputRef={taskFlyoutRename.inputRef}
isRenaming={taskFlyoutRename.isSaving}
onEditValueChange={taskFlyoutRename.setValue}
onEditKeyDown={taskFlyoutRename.handleKeyDown}
onEditBlur={handleTaskRenameBlur}
onContextMenu={handleTaskContextMenu}
onMorePointerDown={handleTaskMorePointerDown}
onMoreClick={handleTaskMoreClick}
/>
))
)}
</CollapsedSidebarMenu>
) : (
<div className='mt-1.5 flex flex-col gap-0.5 px-2'>
{tasksLoading ? (
<SidebarItemSkeleton />
) : (
<>
{tasks.slice(0, visibleTaskCount).map((task) => {
const isCurrentRoute = task.id !== 'new' && pathname === task.href
const isRenaming = taskFlyoutRename.editingId === task.id
const isSelected = task.id !== 'new' && selectedTasks.has(task.id)
if (isRenaming) {
return (
<div
key={task.id}
className='mx-0.5 flex h-[30px] items-center gap-2 rounded-lg bg-[var(--surface-active)] px-2 text-sm'
>
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<input
ref={taskFlyoutRename.inputRef}
value={taskFlyoutRename.value}
onChange={(e) => taskFlyoutRename.setValue(e.target.value)}
onKeyDown={taskFlyoutRename.handleKeyDown}
onBlur={handleTaskRenameBlur}
className='min-w-0 flex-1 border-none bg-transparent font-base text-[14px] text-[var(--text-body)] outline-none'
/>
</div>
)
}
return (
<SidebarTaskItem
key={task.id}
task={task}
isCurrentRoute={isCurrentRoute}
isSelected={isSelected}
isActive={!!task.isActive}
isUnread={!!task.isUnread}
isMenuOpen={menuOpenTaskId === task.id}
showCollapsedTooltips={showCollapsedTooltips}
onMultiSelectClick={handleTaskClick}
onContextMenu={handleTaskContextMenu}
onMorePointerDown={handleTaskMorePointerDown}
onMoreClick={handleTaskMoreClick}
/>
)
})}
{tasks.length > visibleTaskCount && (
<button
type='button'
onClick={handleSeeMoreTasks}
className='mx-0.5 flex h-[30px] items-center gap-2 rounded-lg px-2 text-[var(--text-icon)] text-sm hover-hover:bg-[var(--surface-hover)]'
>
<MoreHorizontal className='h-[16px] w-[16px] flex-shrink-0' />
<span className='font-base'>See more</span>
</button>
)}
</Tooltip.Content>
</Tooltip.Root>
</>
)}
</div>
)}
</div>
{isCollapsed ? (
<CollapsedSidebarMenu
icon={workflowsCollapsedIcon}
hover={workflowsHover}
ariaLabel='Workflows'
className='mt-1.5'
primaryAction={workflowsPrimaryAction}
>
{workflowsLoading && regularWorkflows.length === 0 ? (
<DropdownMenuItem disabled>
<Loader className='h-[14px] w-[14px]' animate />
Loading...
</DropdownMenuItem>
) : regularWorkflows.length === 0 ? (
<DropdownMenuItem disabled>No workflows yet</DropdownMenuItem>
) : (
<>
<CollapsedFolderItems
nodes={folderTree}
workflowsByFolder={workflowsByFolder}
workspaceId={workspaceId}
currentWorkflowId={workflowId}
editingWorkflowId={workflowFlyoutRename.editingId}
editingValue={workflowFlyoutRename.value}
editInputRef={workflowFlyoutRename.inputRef}
isRenamingWorkflow={workflowFlyoutRename.isSaving}
onEditValueChange={workflowFlyoutRename.setValue}
onEditKeyDown={workflowFlyoutRename.handleKeyDown}
onEditBlur={handleWorkflowRenameBlur}
onWorkflowOpenInNewTab={handleCollapsedWorkflowOpenInNewTab}
onWorkflowRename={handleCollapsedWorkflowRename}
canRenameWorkflow={canEdit}
/>
{(workflowsByFolder.root || []).map((workflow) => (
<CollapsedWorkflowFlyoutItem
key={workflow.id}
workflow={workflow}
href={`/workspace/${workspaceId}/w/${workflow.id}`}
isCurrentRoute={workflow.id === workflowId}
isEditing={workflow.id === workflowFlyoutRename.editingId}
editValue={workflowFlyoutRename.value}
inputRef={workflowFlyoutRename.inputRef}
isRenaming={workflowFlyoutRename.isSaving}
<div
className='workflows-section relative mt-3.5 flex flex-col'
data-tour='nav-workflows'
>
<div className='flex h-[18px] flex-shrink-0 items-center justify-between px-4'>
<div className='font-base text-[var(--text-icon)] text-small'>
Workflows
</div>
{!isCollapsed && (
<div className='flex items-center justify-center gap-2'>
<DropdownMenu>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-hover)]'
disabled={!canEdit}
>
{isImporting || isCreatingFolder ? (
<Loader className='h-[16px] w-[16px]' animate />
) : (
<MoreHorizontal className='h-[16px] w-[16px]' />
)}
</Button>
</DropdownMenuTrigger>
</Tooltip.Trigger>
<Tooltip.Content>
<p>More actions</p>
</Tooltip.Content>
</Tooltip.Root>
<DropdownMenuContent
align='start'
sideOffset={8}
className='min-w-[160px]'
>
<DropdownMenuItem
onSelect={handleImportWorkflow}
disabled={!canEdit || isImporting}
>
<Download />
{isImporting ? 'Importing...' : 'Import workflow'}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={handleCreateFolder}
disabled={!canEdit || isCreatingFolder}
>
<FolderPlus />
{isCreatingFolder ? 'Creating folder...' : 'Create folder'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-hover)]'
onClick={handleCreateWorkflow}
disabled={isCreatingWorkflow || !canEdit}
>
<Plus className='h-[16px] w-[16px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
{isCreatingWorkflow ? (
<p>Creating workflow...</p>
) : (
<Tooltip.Shortcut keys={isMac ? '⌘⇧P' : 'Ctrl+Shift+P'}>
New workflow
</Tooltip.Shortcut>
)}
</Tooltip.Content>
</Tooltip.Root>
</div>
)}
</div>
{isCollapsed ? (
<CollapsedSidebarMenu
icon={workflowsCollapsedIcon}
hover={workflowsHover}
ariaLabel='Workflows'
className='mt-1.5'
primaryAction={workflowsPrimaryAction}
>
{workflowsLoading && regularWorkflows.length === 0 ? (
<DropdownMenuItem disabled>
<Loader className='h-[14px] w-[14px]' animate />
Loading...
</DropdownMenuItem>
) : regularWorkflows.length === 0 ? (
<DropdownMenuItem disabled>No workflows yet</DropdownMenuItem>
) : (
<>
<CollapsedFolderItems
nodes={folderTree}
workflowsByFolder={workflowsByFolder}
workspaceId={workspaceId}
currentWorkflowId={workflowId}
editingWorkflowId={workflowFlyoutRename.editingId}
editingValue={workflowFlyoutRename.value}
editInputRef={workflowFlyoutRename.inputRef}
isRenamingWorkflow={workflowFlyoutRename.isSaving}
onEditValueChange={workflowFlyoutRename.setValue}
onEditKeyDown={workflowFlyoutRename.handleKeyDown}
onEditBlur={handleWorkflowRenameBlur}
onOpenInNewTab={() => handleCollapsedWorkflowOpenInNewTab(workflow)}
onRename={() => handleCollapsedWorkflowRename(workflow)}
canRename={canEdit}
onWorkflowOpenInNewTab={handleCollapsedWorkflowOpenInNewTab}
onWorkflowRename={handleCollapsedWorkflowRename}
canRenameWorkflow={canEdit}
/>
))}
</>
)}
</CollapsedSidebarMenu>
) : (
<div className='mt-1.5 px-2'>
{workflowsLoading && regularWorkflows.length === 0 && (
<SidebarItemSkeleton />
)}
<WorkflowList
workspaceId={workspaceId}
workflowId={workflowId}
regularWorkflows={regularWorkflows}
isLoading={isLoading}
canReorder={canEdit}
handleFileChange={handleImportFileChange}
fileInputRef={fileInputRef}
scrollContainerRef={scrollContainerRef}
onCreateWorkflow={handleCreateWorkflow}
onCreateFolder={handleCreateFolder}
disableCreate={!canEdit || isCreatingWorkflow || isCreatingFolder}
/>
</div>
)}
{(workflowsByFolder.root || []).map((workflow) => (
<CollapsedWorkflowFlyoutItem
key={workflow.id}
workflow={workflow}
href={`/workspace/${workspaceId}/w/${workflow.id}`}
isCurrentRoute={workflow.id === workflowId}
isEditing={workflow.id === workflowFlyoutRename.editingId}
editValue={workflowFlyoutRename.value}
inputRef={workflowFlyoutRename.inputRef}
isRenaming={workflowFlyoutRename.isSaving}
onEditValueChange={workflowFlyoutRename.setValue}
onEditKeyDown={workflowFlyoutRename.handleKeyDown}
onEditBlur={handleWorkflowRenameBlur}
onOpenInNewTab={() =>
handleCollapsedWorkflowOpenInNewTab(workflow)
}
onRename={() => handleCollapsedWorkflowRename(workflow)}
canRename={canEdit}
/>
))}
</>
)}
</CollapsedSidebarMenu>
) : (
<div className='mt-1.5 px-2'>
{workflowsLoading && regularWorkflows.length === 0 && (
<SidebarItemSkeleton />
)}
<WorkflowList
workspaceId={workspaceId}
workflowId={workflowId}
regularWorkflows={regularWorkflows}
isLoading={isLoading}
canReorder={canEdit}
handleFileChange={handleImportFileChange}
fileInputRef={fileInputRef}
scrollContainerRef={scrollContainerRef}
onCreateWorkflow={handleCreateWorkflow}
onCreateFolder={handleCreateFolder}
disableCreate={!canEdit || isCreatingWorkflow || isCreatingFolder}
/>
</div>
)}
</div>
</div>
</div>

View File

@@ -1,11 +0,0 @@
import { Loader2 } from 'lucide-react'
export default function WorkflowsLoading() {
return (
<div className='flex h-full w-full flex-col overflow-hidden bg-[var(--bg)]'>
<div className='relative flex h-full w-full flex-1 items-center justify-center bg-[var(--bg)]'>
<Loader2 className='h-[20px] w-[20px] animate-spin text-[var(--text-tertiary)]' />
</div>
</div>
)
}

View File

@@ -90,6 +90,7 @@ interface SocketContextType {
onSelectionUpdate: (handler: (data: any) => void) => void
onWorkflowDeleted: (handler: (data: any) => void) => void
onWorkflowReverted: (handler: (data: any) => void) => void
onWorkflowUpdated: (handler: (data: any) => void) => void
onOperationConfirmed: (handler: (data: any) => void) => void
onOperationFailed: (handler: (data: any) => void) => void
}
@@ -118,6 +119,7 @@ const SocketContext = createContext<SocketContextType>({
onSelectionUpdate: () => {},
onWorkflowDeleted: () => {},
onWorkflowReverted: () => {},
onWorkflowUpdated: () => {},
onOperationConfirmed: () => {},
onOperationFailed: () => {},
})
@@ -155,6 +157,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
selectionUpdate?: (data: any) => void
workflowDeleted?: (data: any) => void
workflowReverted?: (data: any) => void
workflowUpdated?: (data: any) => void
operationConfirmed?: (data: any) => void
operationFailed?: (data: any) => void
}>({})
@@ -334,7 +337,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
socketInstance.on('join-workflow-success', ({ workflowId, presenceUsers }) => {
isRejoiningRef.current = false
// Ignore stale success responses from previous navigation
if (workflowId !== urlWorkflowIdRef.current) {
if (urlWorkflowIdRef.current && workflowId !== urlWorkflowIdRef.current) {
logger.debug(`Ignoring stale join-workflow-success for ${workflowId}`)
return
}
@@ -382,6 +385,11 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
eventHandlers.current.workflowReverted?.(data)
})
socketInstance.on('workflow-updated', (data) => {
logger.info(`Workflow ${data.workflowId} has been updated externally`)
eventHandlers.current.workflowUpdated?.(data)
})
const rehydrateWorkflowStores = async (workflowId: string, workflowState: any) => {
const [
{ useOperationQueueStore },
@@ -424,7 +432,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
loops: workflowState.loops || {},
parallels: workflowState.parallels || {},
lastSaved: workflowState.lastSaved || Date.now(),
deploymentStatuses: workflowState.deploymentStatuses || {},
})
useSubBlockStore.setState((state: any) => ({
@@ -804,6 +811,10 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
eventHandlers.current.workflowReverted = handler
}, [])
const onWorkflowUpdated = useCallback((handler: (data: any) => void) => {
eventHandlers.current.workflowUpdated = handler
}, [])
const onOperationConfirmed = useCallback((handler: (data: any) => void) => {
eventHandlers.current.operationConfirmed = handler
}, [])
@@ -837,6 +848,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
onSelectionUpdate,
onWorkflowDeleted,
onWorkflowReverted,
onWorkflowUpdated,
onOperationConfirmed,
onOperationFailed,
}),
@@ -864,6 +876,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
onSelectionUpdate,
onWorkflowDeleted,
onWorkflowReverted,
onWorkflowUpdated,
onOperationConfirmed,
onOperationFailed,
]

View File

@@ -40,6 +40,7 @@ import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import * as TabsPrimitive from '@radix-ui/react-tabs'
import { X } from 'lucide-react'
import { usePathname } from 'next/navigation'
import { cn } from '@/lib/core/utils/cn'
import { Button } from '../button/button'
@@ -50,13 +51,6 @@ import { Button } from '../button/button'
const ANIMATION_CLASSES =
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:animate-out data-[state=open]:animate-in motion-reduce:animate-none'
/**
* Modal content animation classes.
* We keep only the slide animations (no zoom) to stabilize positioning while avoiding scale effects.
*/
const CONTENT_ANIMATION_CLASSES =
'data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[50%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[50%] motion-reduce:animate-none'
/**
* Root modal component. Manages open state.
*/
@@ -145,6 +139,8 @@ const ModalContent = React.forwardRef<
ModalContentProps
>(({ className, children, showClose = true, size = 'md', style, ...props }, ref) => {
const [isInteractionReady, setIsInteractionReady] = React.useState(false)
const pathname = usePathname()
const isWorkflowPage = pathname?.includes('/w/') ?? false
React.useEffect(() => {
const timer = setTimeout(() => setIsInteractionReady(true), 100)
@@ -157,14 +153,15 @@ const ModalContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
ANIMATION_CLASSES,
CONTENT_ANIMATION_CLASSES,
'fixed top-[50%] z-[var(--z-modal)] flex max-h-[84vh] translate-x-[-50%] translate-y-[-50%] flex-col overflow-hidden rounded-xl bg-[var(--bg)] text-small ring-1 ring-foreground/10 duration-200',
MODAL_SIZES[size],
className
)}
style={{
left: '50%',
left: isWorkflowPage
? // --panel-width is always the rendered panel width on /w/ routes (panel is never hidden/collapsed)
'calc(50% + (var(--sidebar-width) - var(--panel-width)) / 2)'
: 'calc(var(--sidebar-width) / 2 + 50%)',
...style,
}}
onEscapeKeyDown={(e) => {

View File

@@ -72,6 +72,8 @@ export { Table } from './table'
export { TableX } from './table-x'
export { TagIcon } from './tag'
export { TerminalWindow } from './terminal-window'
export { ThumbsDown } from './thumbs-down'
export { ThumbsUp } from './thumbs-up'
export { Trash } from './trash'
export { TrashOutline } from './trash-outline'
export { Trash2 } from './trash2'

View File

@@ -0,0 +1,28 @@
import type { SVGProps } from 'react'
/**
* ThumbsDown icon component
* @param props - SVG properties including className, fill, etc.
*/
export function ThumbsDown(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='-1 -2 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.75'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
{...props}
>
<g transform='scale(1,-1) translate(0,-20)'>
<path d='M6 8v12' />
<path d='M14 3.88L13 8h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 16.5 20H3a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L11 0a3.13 3.13 0 0 1 3 3.88Z' />
</g>
</svg>
)
}

View File

@@ -0,0 +1,26 @@
import type { SVGProps } from 'react'
/**
* ThumbsUp icon component
* @param props - SVG properties including className, fill, etc.
*/
export function ThumbsUp(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='-1 -2 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.75'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
aria-hidden='true'
{...props}
>
<path d='M6 8v12' />
<path d='M14 3.88L13 8h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 16.5 20H3a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L11 0a3.13 3.13 0 0 1 3 3.88Z' />
</svg>
)
}

View File

@@ -0,0 +1,39 @@
import { createLogger } from '@sim/logger'
import { useMutation } from '@tanstack/react-query'
const logger = createLogger('CopilotFeedbackMutation')
interface SubmitFeedbackVariables {
chatId: string
userQuery: string
agentResponse: string
isPositiveFeedback: boolean
feedback?: string
}
interface SubmitFeedbackResponse {
success: boolean
feedbackId: string
}
export function useSubmitCopilotFeedback() {
return useMutation({
mutationFn: async (variables: SubmitFeedbackVariables): Promise<SubmitFeedbackResponse> => {
const response = await fetch('/api/copilot/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(variables),
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.error || 'Failed to submit feedback')
}
return response.json()
},
onError: (error) => {
logger.error('Failed to submit copilot feedback:', error)
},
})
}

View File

@@ -5,7 +5,6 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tansta
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
import { fetchDeploymentVersionState } from '@/hooks/queries/utils/fetch-deployment-version-state'
import { workflowKeys } from '@/hooks/queries/utils/workflow-keys'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('DeploymentQueries')
@@ -321,7 +320,6 @@ interface DeployWorkflowResult {
*/
export function useDeployWorkflow() {
const queryClient = useQueryClient()
const setDeploymentStatus = useWorkflowRegistry((state) => state.setDeploymentStatus)
return useMutation({
mutationFn: async ({
@@ -351,18 +349,12 @@ export function useDeployWorkflow() {
warnings: data.warnings,
}
},
onSuccess: (data, variables) => {
logger.info('Workflow deployed successfully', { workflowId: variables.workflowId })
setDeploymentStatus(
variables.workflowId,
data.isDeployed,
data.deployedAt ? new Date(data.deployedAt) : undefined,
data.apiKey
)
useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(variables.workflowId, false)
onSettled: (_data, error, variables) => {
if (error) {
logger.error('Failed to deploy workflow', { error })
} else {
logger.info('Workflow deployed successfully', { workflowId: variables.workflowId })
}
return Promise.all([
invalidateDeploymentQueries(queryClient, variables.workflowId),
queryClient.invalidateQueries({
@@ -370,9 +362,6 @@ export function useDeployWorkflow() {
}),
])
},
onError: (error) => {
logger.error('Failed to deploy workflow', { error })
},
})
}
@@ -389,7 +378,6 @@ interface UndeployWorkflowVariables {
*/
export function useUndeployWorkflow() {
const queryClient = useQueryClient()
const setDeploymentStatus = useWorkflowRegistry((state) => state.setDeploymentStatus)
return useMutation({
mutationFn: async ({ workflowId }: UndeployWorkflowVariables): Promise<void> => {
@@ -402,11 +390,12 @@ export function useUndeployWorkflow() {
throw new Error(errorData.error || 'Failed to undeploy workflow')
}
},
onSuccess: (_, variables) => {
logger.info('Workflow undeployed successfully', { workflowId: variables.workflowId })
setDeploymentStatus(variables.workflowId, false)
onSettled: (_data, error, variables) => {
if (error) {
logger.error('Failed to undeploy workflow', { error })
} else {
logger.info('Workflow undeployed successfully', { workflowId: variables.workflowId })
}
return Promise.all([
invalidateDeploymentQueries(queryClient, variables.workflowId),
queryClient.invalidateQueries({
@@ -414,9 +403,6 @@ export function useUndeployWorkflow() {
}),
])
},
onError: (error) => {
logger.error('Failed to undeploy workflow', { error })
},
})
}
@@ -613,7 +599,6 @@ interface ActivateVersionResult {
*/
export function useActivateDeploymentVersion() {
const queryClient = useQueryClient()
const setDeploymentStatus = useWorkflowRegistry((state) => state.setDeploymentStatus)
return useMutation({
mutationFn: async ({
@@ -663,20 +648,13 @@ export function useActivateDeploymentVersion() {
)
}
},
onSuccess: (data, variables) => {
logger.info('Deployment version activated', {
workflowId: variables.workflowId,
version: variables.version,
})
setDeploymentStatus(
variables.workflowId,
true,
data.deployedAt ? new Date(data.deployedAt) : undefined,
data.apiKey
)
},
onSettled: (_, __, variables) => {
onSettled: (_data, error, variables) => {
if (!error) {
logger.info('Deployment version activated', {
workflowId: variables.workflowId,
version: variables.version,
})
}
return invalidateDeploymentQueries(queryClient, variables.workflowId)
},
})

View File

@@ -1,4 +1,3 @@
import { useEffect } from 'react'
import { createLogger } from '@sim/logger'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { WorkspaceEnvironmentData } from '@/lib/environment/api'
@@ -6,7 +5,6 @@ import { fetchPersonalEnvironment, fetchWorkspaceEnvironment } from '@/lib/envir
import { workspaceCredentialKeys } from '@/hooks/queries/credentials'
import { API_ENDPOINTS } from '@/stores/constants'
import type { EnvironmentVariable } from '@/stores/settings/environment'
import { useEnvironmentStore } from '@/stores/settings/environment'
export type { WorkspaceEnvironmentData } from '@/lib/environment/api'
export type { EnvironmentVariable } from '@/stores/settings/environment'
@@ -22,29 +20,16 @@ export const environmentKeys = {
workspace: (workspaceId: string) => [...environmentKeys.all, 'workspace', workspaceId] as const,
}
/**
* Environment Variable Types
*/
/**
* Hook to fetch personal environment variables
*/
export function usePersonalEnvironment() {
const setVariables = useEnvironmentStore((state) => state.setVariables)
const query = useQuery({
return useQuery({
queryKey: environmentKeys.personal(),
queryFn: ({ signal }) => fetchPersonalEnvironment(signal),
staleTime: 60 * 1000, // 1 minute
placeholderData: keepPreviousData,
})
useEffect(() => {
if (query.data) {
setVariables(query.data)
}
}, [query.data, setVariables])
return query
}
/**

View File

@@ -1,39 +0,0 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { getQueryDataMock } = vi.hoisted(() => ({
getQueryDataMock: vi.fn(),
}))
vi.mock('@/app/_shell/providers/get-query-client', () => ({
getQueryClient: vi.fn(() => ({
getQueryData: getQueryDataMock,
})),
}))
import { getCustomTool, getCustomTools } from '@/hooks/queries/utils/custom-tool-cache'
describe('custom tool cache helpers', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('reads workspace-scoped custom tools from the cache', () => {
const tools = [{ id: 'tool-1', title: 'Weather', schema: {}, code: '', workspaceId: 'ws-1' }]
getQueryDataMock.mockReturnValue(tools)
expect(getCustomTools('ws-1')).toBe(tools)
expect(getQueryDataMock).toHaveBeenCalledWith(['customTools', 'list', 'ws-1'])
})
it('resolves custom tools by id or title', () => {
getQueryDataMock.mockReturnValue([
{ id: 'tool-1', title: 'Weather', schema: {}, code: '', workspaceId: 'ws-1' },
])
expect(getCustomTool('tool-1', 'ws-1')?.title).toBe('Weather')
expect(getCustomTool('Weather', 'ws-1')?.id).toBe('tool-1')
})
})

View File

@@ -1,23 +0,0 @@
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import type { CustomToolDefinition } from '@/hooks/queries/custom-tools'
import { customToolsKeys } from '@/hooks/queries/utils/custom-tool-keys'
/**
* Reads custom tools for a workspace directly from the React Query cache.
*/
export function getCustomTools(workspaceId: string): CustomToolDefinition[] {
return (
getQueryClient().getQueryData<CustomToolDefinition[]>(customToolsKeys.list(workspaceId)) ?? []
)
}
/**
* Resolves a custom tool from the cache by id or title.
*/
export function getCustomTool(
identifier: string,
workspaceId: string
): CustomToolDefinition | undefined {
const tools = getCustomTools(workspaceId)
return tools.find((tool) => tool.id === identifier || tool.title === identifier)
}

View File

@@ -1,9 +1,9 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { extractEnvVarName, isEnvVarReference, isReference } from '@/executor/constants'
import { usePersonalEnvironment } from '@/hooks/queries/environment'
import { getSelectorDefinition, mergeOption } from '@/hooks/selectors/registry'
import type { SelectorKey, SelectorOption, SelectorQueryArgs } from '@/hooks/selectors/types'
import { useEnvironmentStore } from '@/stores/settings/environment'
interface SelectorHookArgs extends Omit<SelectorQueryArgs, 'key'> {
search?: string
@@ -31,7 +31,7 @@ export function useSelectorOptionDetail(
key: SelectorKey,
args: SelectorHookArgs & { detailId?: string }
) {
const envVariables = useEnvironmentStore((s) => s.variables)
const { data: envVariables = {} } = usePersonalEnvironment()
const definition = getSelectorDefinition(key)
const resolvedDetailId = useMemo(() => {

View File

@@ -19,8 +19,9 @@ import {
} from '@/socket/constants'
import { useNotificationStore } from '@/stores/notifications'
import { registerEmitFunctions, useOperationQueue } from '@/stores/operation-queue/store'
import { usePanelEditorStore, useVariablesStore } from '@/stores/panel'
import { usePanelEditorStore } from '@/stores/panel'
import { useCodeUndoRedoStore, useUndoRedoStore } from '@/stores/undo-redo'
import { useVariablesStore } from '@/stores/variables/store'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
@@ -122,6 +123,7 @@ export function useCollaborativeWorkflow() {
onVariableUpdate,
onWorkflowDeleted,
onWorkflowReverted,
onWorkflowUpdated,
onOperationConfirmed,
onOperationFailed,
} = useSocket()
@@ -536,82 +538,99 @@ export function useCollaborativeWorkflow() {
}
}
const reloadWorkflowFromApi = async (workflowId: string, reason: string): Promise<boolean> => {
const response = await fetch(`/api/workflows/${workflowId}`)
if (!response.ok) {
logger.error(`Failed to fetch workflow data after ${reason}: ${response.statusText}`)
return false
}
const responseData = await response.json()
const workflowData = responseData.data
if (!workflowData?.state) {
logger.error(`No state found in workflow data after ${reason}`, { workflowData })
return false
}
isApplyingRemoteChange.current = true
try {
useWorkflowStore.getState().replaceWorkflowState({
blocks: workflowData.state.blocks || {},
edges: workflowData.state.edges || [],
loops: workflowData.state.loops || {},
parallels: workflowData.state.parallels || {},
lastSaved: workflowData.state.lastSaved || Date.now(),
})
const subblockValues: Record<string, Record<string, any>> = {}
Object.entries(workflowData.state.blocks || {}).forEach(([blockId, block]) => {
const blockState = block as any
subblockValues[blockId] = {}
Object.entries(blockState.subBlocks || {}).forEach(([subblockId, subblock]) => {
subblockValues[blockId][subblockId] = (subblock as any).value
})
})
useSubBlockStore.setState((state: any) => ({
workflowValues: {
...state.workflowValues,
[workflowId]: subblockValues,
},
}))
const graph = {
blocksById: workflowData.state.blocks || {},
edgesById: Object.fromEntries(
(workflowData.state.edges || []).map((e: any) => [e.id, e])
),
}
const undoRedoStore = useUndoRedoStore.getState()
const stackKeys = Object.keys(undoRedoStore.stacks)
stackKeys.forEach((key) => {
const [wfId, userId] = key.split(':')
if (wfId === workflowId) {
undoRedoStore.pruneInvalidEntries(wfId, userId, graph)
}
})
logger.info(`Successfully reloaded workflow state after ${reason}`, { workflowId })
return true
} finally {
isApplyingRemoteChange.current = false
}
}
const handleWorkflowReverted = async (data: any) => {
const { workflowId } = data
logger.info(`Workflow ${workflowId} has been reverted to deployed state`)
// If the reverted workflow is the currently active one, reload the workflow state
if (activeWorkflowId === workflowId) {
logger.info(`Currently active workflow ${workflowId} was reverted, reloading state`)
if (activeWorkflowId !== workflowId) return
try {
// Fetch the updated workflow state from the server (which loads from normalized tables)
const response = await fetch(`/api/workflows/${workflowId}`)
if (response.ok) {
const responseData = await response.json()
const workflowData = responseData.data
try {
await reloadWorkflowFromApi(workflowId, 'revert')
} catch (error) {
logger.error('Error reloading workflow state after revert:', error)
}
}
if (workflowData?.state) {
// Update the workflow store with the reverted state
isApplyingRemoteChange.current = true
try {
// Update the main workflow state using the API response
useWorkflowStore.getState().replaceWorkflowState({
blocks: workflowData.state.blocks || {},
edges: workflowData.state.edges || [],
loops: workflowData.state.loops || {},
parallels: workflowData.state.parallels || {},
lastSaved: workflowData.state.lastSaved || Date.now(),
deploymentStatuses: workflowData.state.deploymentStatuses || {},
})
const handleWorkflowUpdated = async (data: any) => {
const { workflowId } = data
logger.info(`Workflow ${workflowId} has been updated externally`)
// Update subblock store with reverted values
const subblockValues: Record<string, Record<string, any>> = {}
Object.entries(workflowData.state.blocks || {}).forEach(([blockId, block]) => {
const blockState = block as any
subblockValues[blockId] = {}
Object.entries(blockState.subBlocks || {}).forEach(([subblockId, subblock]) => {
subblockValues[blockId][subblockId] = (subblock as any).value
})
})
if (activeWorkflowId !== workflowId) return
// Update subblock store for this workflow
useSubBlockStore.setState((state: any) => ({
workflowValues: {
...state.workflowValues,
[workflowId]: subblockValues,
},
}))
const { hasActiveDiff } = useWorkflowDiffStore.getState()
if (hasActiveDiff) {
logger.info('Skipping workflow-updated: active diff in progress', { workflowId })
return
}
logger.info(`Successfully loaded reverted workflow state for ${workflowId}`)
const graph = {
blocksById: workflowData.state.blocks || {},
edgesById: Object.fromEntries(
(workflowData.state.edges || []).map((e: any) => [e.id, e])
),
}
const undoRedoStore = useUndoRedoStore.getState()
const stackKeys = Object.keys(undoRedoStore.stacks)
stackKeys.forEach((key) => {
const [wfId, userId] = key.split(':')
if (wfId === workflowId) {
undoRedoStore.pruneInvalidEntries(wfId, userId, graph)
}
})
} finally {
isApplyingRemoteChange.current = false
}
} else {
logger.error('No state found in workflow data after revert', { workflowData })
}
} else {
logger.error(`Failed to fetch workflow data after revert: ${response.statusText}`)
}
} catch (error) {
logger.error('Error reloading workflow state after revert:', error)
}
try {
await reloadWorkflowFromApi(workflowId, 'external update')
} catch (error) {
logger.error('Error reloading workflow state after external update:', error)
}
}
@@ -633,6 +652,7 @@ export function useCollaborativeWorkflow() {
onVariableUpdate(handleVariableUpdate)
onWorkflowDeleted(handleWorkflowDeleted)
onWorkflowReverted(handleWorkflowReverted)
onWorkflowUpdated(handleWorkflowUpdated)
onOperationConfirmed(handleOperationConfirmed)
onOperationFailed(handleOperationFailed)
}, [
@@ -641,6 +661,7 @@ export function useCollaborativeWorkflow() {
onVariableUpdate,
onWorkflowDeleted,
onWorkflowReverted,
onWorkflowUpdated,
onOperationConfirmed,
onOperationFailed,
activeWorkflowId,

View File

@@ -4,6 +4,7 @@ import { useEffect, useRef } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { toast } from '@/components/emcn'
import {
ADD_CONNECTOR_SEARCH_PARAM,
consumeOAuthReturnContext,
type OAuthReturnContext,
readOAuthReturnContext,
@@ -98,7 +99,11 @@ export function useOAuthReturnRouter() {
try {
sessionStorage.removeItem(SETTINGS_RETURN_URL_KEY)
} catch {}
router.replace(`/workspace/${workspaceId}/knowledge/${ctx.knowledgeBaseId}`)
const kbUrl = `/workspace/${workspaceId}/knowledge/${ctx.knowledgeBaseId}`
const connectorParam = ctx.connectorType
? `?${ADD_CONNECTOR_SEARCH_PARAM}=${encodeURIComponent(ctx.connectorType)}`
: ''
router.replace(`${kbUrl}${connectorParam}`)
return
}
}, [router, workspaceId])

View File

@@ -40,7 +40,11 @@ import {
XCircle,
Zap,
} from 'lucide-react'
import { getCustomTool } from '@/hooks/queries/utils/custom-tool-cache'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import type { CustomToolDefinition } from '@/hooks/queries/custom-tools'
import type { WorkflowDeploymentInfo } from '@/hooks/queries/deployments'
import { deploymentKeys } from '@/hooks/queries/deployments'
import { customToolsKeys } from '@/hooks/queries/utils/custom-tool-keys'
import { getWorkflowById } from '@/hooks/queries/utils/workflow-cache'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -445,7 +449,8 @@ const META_deploy_api: ToolMetadata = {
const workflowId = params?.workflowId || useWorkflowRegistry.getState().activeWorkflowId
const isAlreadyDeployed = workflowId
? useWorkflowRegistry.getState().getWorkflowDeploymentStatus(workflowId)?.isDeployed
? (getQueryClient().getQueryData<WorkflowDeploymentInfo>(deploymentKeys.info(workflowId))
?.isDeployed ?? false)
: false
let actionText = action
@@ -1053,7 +1058,11 @@ const META_manage_custom_tool: ToolMetadata = {
let toolName = params?.schema?.function?.name
if (!toolName && params?.toolId && workspaceId) {
try {
const tool = getCustomTool(params.toolId, workspaceId)
const tools =
getQueryClient().getQueryData<CustomToolDefinition[]>(
customToolsKeys.list(workspaceId)
) ?? []
const tool = tools.find((t) => t.id === params.toolId || t.title === params.toolId)
toolName = tool?.schema?.function?.name
} catch {
// Ignore errors accessing cache

View File

@@ -7,6 +7,7 @@ import {
type BaseServerTool,
type ServerToolContext,
} from '@/lib/copilot/tools/server/base-tool'
import { env } from '@/lib/core/config/env'
import { applyTargetedLayout, getTargetedLayoutImpact } from '@/lib/workflows/autolayout'
import {
DEFAULT_HORIZONTAL_SPACING,
@@ -287,6 +288,18 @@ export const editWorkflowServerTool: BaseServerTool<EditWorkflowParams, unknown>
logger.info('Workflow state persisted to database', { workflowId })
const socketUrl = env.SOCKET_SERVER_URL || 'http://localhost:3002'
fetch(`${socketUrl}/api/workflow-updated`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': env.INTERNAL_API_SECRET,
},
body: JSON.stringify({ workflowId }),
}).catch((error) => {
logger.warn('Failed to notify socket server of workflow update', { workflowId, error })
})
const sanitizationWarnings = validation.warnings.length > 0 ? validation.warnings : undefined
return {

View File

@@ -91,6 +91,8 @@ export function clearPendingCredentialCreateRequest() {
window.sessionStorage.removeItem(PENDING_CREDENTIAL_CREATE_REQUEST_KEY)
}
export const ADD_CONNECTOR_SEARCH_PARAM = 'addConnector' as const
const OAUTH_RETURN_CONTEXT_KEY = 'sim.oauth-return-context'
export type OAuthReturnOrigin = 'workflow' | 'integrations' | 'kb-connectors'
@@ -116,6 +118,7 @@ interface OAuthReturnIntegrations extends OAuthReturnBase {
interface OAuthReturnKBConnectors extends OAuthReturnBase {
origin: 'kb-connectors'
knowledgeBaseId: string
connectorType?: string
}
export type OAuthReturnContext =

View File

@@ -2,10 +2,9 @@ import type { Edge } from 'reactflow'
import type { AsyncExecutionCorrelation } from '@/lib/core/async-jobs/types'
import type { ParentIteration, SerializableExecutionState } from '@/executor/execution/types'
import type { BlockLog, NormalizedBlockOutput } from '@/executor/types'
import type { DeploymentStatus } from '@/stores/workflows/registry/types'
import type { Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types'
export type { WorkflowState, Loop, Parallel, DeploymentStatus }
export type { WorkflowState, Loop, Parallel }
export type WorkflowEdge = Edge
export type { NormalizedBlockOutput, BlockLog }

View File

@@ -123,8 +123,6 @@ export function buildDefaultWorkflowArtifacts(): DefaultWorkflowArtifacts {
loops: {},
parallels: {},
lastSaved: Date.now(),
deploymentStatuses: {},
needsRedeployment: false,
}
return {

View File

@@ -123,6 +123,7 @@ describe('executeWorkflowCore terminal finalization sequencing', () => {
requestId: 'req-1',
workflowId: 'workflow-1',
userId: 'user-1',
workflowUserId: 'workflow-owner',
workspaceId: 'workspace-1',
triggerType: 'api',
executionId: 'execution-1',
@@ -755,4 +756,92 @@ describe('executeWorkflowCore terminal finalization sequencing', () => {
expect(safeCompleteWithErrorMock).not.toHaveBeenCalled()
expect(wasExecutionFinalizedByCore(envError, 'execution-no-log-start')).toBe(false)
})
it('uses sessionUserId for env resolution when isClientSession is true', async () => {
const snapshot = {
...createSnapshot(),
metadata: {
...createSnapshot().metadata,
isClientSession: true,
sessionUserId: 'session-user',
workflowUserId: 'workflow-owner',
},
}
getPersonalAndWorkspaceEnvMock.mockResolvedValue({
personalEncrypted: {},
workspaceEncrypted: {},
personalDecrypted: {},
workspaceDecrypted: {},
})
safeStartMock.mockResolvedValue(true)
executorExecuteMock.mockResolvedValue({
output: { done: true },
logs: [],
metadata: { duration: 123, startTime: 'start', endTime: 'end' },
})
await executeWorkflowCore({
snapshot: snapshot as any,
callbacks: {},
loggingSession: loggingSession as any,
})
expect(getPersonalAndWorkspaceEnvMock).toHaveBeenCalledWith('session-user', 'workspace-1')
})
it('uses workflowUserId for env resolution in server-side execution', async () => {
const snapshot = {
...createSnapshot(),
metadata: {
...createSnapshot().metadata,
isClientSession: false,
sessionUserId: undefined,
workflowUserId: 'workflow-owner',
userId: 'billing-actor',
},
}
getPersonalAndWorkspaceEnvMock.mockResolvedValue({
personalEncrypted: {},
workspaceEncrypted: {},
personalDecrypted: {},
workspaceDecrypted: {},
})
safeStartMock.mockResolvedValue(true)
executorExecuteMock.mockResolvedValue({
output: { done: true },
logs: [],
metadata: { duration: 123, startTime: 'start', endTime: 'end' },
})
await executeWorkflowCore({
snapshot: snapshot as any,
callbacks: {},
loggingSession: loggingSession as any,
})
expect(getPersonalAndWorkspaceEnvMock).toHaveBeenCalledWith('workflow-owner', 'workspace-1')
})
it('throws when workflowUserId is missing in server-side execution', async () => {
const snapshot = {
...createSnapshot(),
metadata: {
...createSnapshot().metadata,
isClientSession: false,
sessionUserId: undefined,
workflowUserId: undefined,
userId: 'billing-actor',
},
}
await expect(
executeWorkflowCore({
snapshot: snapshot as any,
callbacks: {},
loggingSession: loggingSession as any,
})
).rejects.toThrow('Missing workflowUserId in execution metadata')
})
})

View File

@@ -325,10 +325,13 @@ export async function executeWorkflowCore(
const mergedStates = mergeSubblockStateWithValues(blocks)
const personalEnvUserId = metadata.sessionUserId || metadata.userId
const personalEnvUserId =
metadata.isClientSession && metadata.sessionUserId
? metadata.sessionUserId
: metadata.workflowUserId
if (!personalEnvUserId) {
throw new Error('Missing execution actor for environment resolution')
throw new Error('Missing workflowUserId in execution metadata')
}
const { personalEncrypted, workspaceEncrypted, personalDecrypted, workspaceDecrypted } =

View File

@@ -14,7 +14,7 @@ import {
deduplicateWorkflowName,
} from '@/lib/workflows/utils'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import type { Variable } from '@/stores/panel/variables/types'
import type { Variable } from '@/stores/variables/types'
import type { LoopConfig, ParallelConfig } from '@/stores/workflows/workflow/types'
const logger = createLogger('WorkflowDuplicateHelper')

View File

@@ -985,7 +985,6 @@ describe('Database Helpers', () => {
edges: loadedState!.edges,
loops: {},
parallels: {},
deploymentStatuses: {},
}
const mockTransaction = vi.fn().mockImplementation(async (callback) => {

View File

@@ -1,4 +1,4 @@
import type { VariableType } from '@/stores/panel/variables/types'
import type { VariableType } from '@/stores/variables/types'
/**
* Central manager for handling all variable-related operations.

View File

@@ -271,6 +271,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
verbosity: {
values: ['low', 'medium', 'high'],
},
maxOutputTokens: 128000,
},
contextWindow: 400000,
},
@@ -290,6 +291,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
verbosity: {
values: ['low', 'medium', 'high'],
},
maxOutputTokens: 128000,
},
contextWindow: 400000,
},
@@ -324,6 +326,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
verbosity: {
values: ['low', 'medium', 'high'],
},
maxOutputTokens: 128000,
},
contextWindow: 400000,
},
@@ -342,6 +345,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
verbosity: {
values: ['low', 'medium', 'high'],
},
maxOutputTokens: 128000,
},
contextWindow: 400000,
},
@@ -360,6 +364,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
verbosity: {
values: ['low', 'medium', 'high'],
},
maxOutputTokens: 128000,
},
contextWindow: 400000,
},
@@ -373,6 +378,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 2 },
maxOutputTokens: 16384,
},
contextWindow: 128000,
},
@@ -449,6 +455,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
reasoningEffort: {
values: ['low', 'medium', 'high'],
},
maxOutputTokens: 100000,
},
contextWindow: 200000,
},
@@ -463,6 +470,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 2 },
maxOutputTokens: 16384,
},
contextWindow: 128000,
},
@@ -509,7 +517,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
nativeStructuredOutputs: true,
maxOutputTokens: 128000,
maxOutputTokens: 64000,
thinking: {
levels: ['low', 'medium', 'high', 'max'],
default: 'high',
@@ -741,6 +749,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
verbosity: {
values: ['low', 'medium', 'high'],
},
maxOutputTokens: 128000,
},
contextWindow: 400000,
},
@@ -759,6 +768,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
verbosity: {
values: ['low', 'medium', 'high'],
},
maxOutputTokens: 128000,
},
contextWindow: 400000,
},
@@ -777,6 +787,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
verbosity: {
values: ['low', 'medium', 'high'],
},
maxOutputTokens: 128000,
},
contextWindow: 400000,
},
@@ -795,6 +806,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
verbosity: {
values: ['low', 'medium', 'high'],
},
maxOutputTokens: 128000,
},
contextWindow: 400000,
},
@@ -813,6 +825,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
verbosity: {
values: ['low', 'medium', 'high'],
},
maxOutputTokens: 128000,
},
contextWindow: 400000,
},
@@ -831,6 +844,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
verbosity: {
values: ['low', 'medium', 'high'],
},
maxOutputTokens: 128000,
},
contextWindow: 400000,
},
@@ -1067,6 +1081,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
levels: ['minimal', 'low', 'medium', 'high'],
default: 'high',
},
maxOutputTokens: 65536,
},
contextWindow: 1048576,
},
@@ -1084,6 +1099,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
levels: ['minimal', 'low', 'medium', 'high'],
default: 'minimal',
},
maxOutputTokens: 65536,
},
contextWindow: 1048576,
},
@@ -1101,6 +1117,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
levels: ['minimal', 'low', 'medium', 'high'],
default: 'high',
},
maxOutputTokens: 65536,
},
contextWindow: 1000000,
},
@@ -1114,6 +1131,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 2 },
maxOutputTokens: 65536,
},
contextWindow: 1048576,
},
@@ -1127,6 +1145,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 2 },
maxOutputTokens: 65536,
},
contextWindow: 1048576,
},
@@ -1140,6 +1159,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 2 },
maxOutputTokens: 65536,
},
contextWindow: 1048576,
},
@@ -1153,6 +1173,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 2 },
maxOutputTokens: 8192,
},
contextWindow: 1048576,
},
@@ -1165,6 +1186,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 2 },
maxOutputTokens: 8192,
},
contextWindow: 1048576,
},
@@ -1178,6 +1200,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
deepResearch: true,
memory: false,
maxOutputTokens: 65536,
},
contextWindow: 1000000,
},
@@ -2094,6 +2117,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
nativeStructuredOutputs: true,
maxOutputTokens: 64000,
},
contextWindow: 200000,
},
@@ -2107,6 +2131,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
nativeStructuredOutputs: true,
maxOutputTokens: 64000,
},
contextWindow: 200000,
},
@@ -2120,6 +2145,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
nativeStructuredOutputs: true,
maxOutputTokens: 64000,
},
contextWindow: 200000,
},
@@ -2133,6 +2159,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
capabilities: {
temperature: { min: 0, max: 1 },
nativeStructuredOutputs: true,
maxOutputTokens: 64000,
},
contextWindow: 200000,
},
@@ -2337,6 +2364,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 1 },
maxOutputTokens: 32768,
},
contextWindow: 128000,
},
@@ -2373,6 +2401,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 1 },
maxOutputTokens: 16384,
},
contextWindow: 128000,
},
@@ -2385,6 +2414,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 1 },
maxOutputTokens: 40000,
},
contextWindow: 128000,
},
@@ -2397,6 +2427,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 1 },
maxOutputTokens: 8192,
},
contextWindow: 128000,
},
@@ -2409,6 +2440,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 1 },
maxOutputTokens: 8192,
},
contextWindow: 128000,
},
@@ -2421,6 +2453,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 1 },
maxOutputTokens: 8192,
},
contextWindow: 128000,
},
@@ -2863,13 +2896,17 @@ export function getModelsWithoutMemory(): string[] {
export function getMaxOutputTokensForModel(modelId: string): number {
const normalizedModelId = modelId.toLowerCase()
const STANDARD_MAX_OUTPUT_TOKENS = 4096
const allModels = Object.values(PROVIDER_DEFINITIONS).flatMap((provider) => provider.models)
for (const provider of Object.values(PROVIDER_DEFINITIONS)) {
for (const model of provider.models) {
const baseModelId = model.id.toLowerCase()
if (normalizedModelId === baseModelId || normalizedModelId.startsWith(`${baseModelId}-`)) {
return model.capabilities.maxOutputTokens || STANDARD_MAX_OUTPUT_TOKENS
}
const exactMatch = allModels.find((model) => model.id.toLowerCase() === normalizedModelId)
if (exactMatch) {
return exactMatch.capabilities.maxOutputTokens || STANDARD_MAX_OUTPUT_TOKENS
}
for (const model of allModels) {
const baseModelId = model.id.toLowerCase()
if (normalizedModelId.startsWith(`${baseModelId}-`)) {
return model.capabilities.maxOutputTokens || STANDARD_MAX_OUTPUT_TOKENS
}
}

View File

@@ -664,6 +664,45 @@ describe('Model Capabilities', () => {
describe('Max Output Tokens', () => {
describe('getMaxOutputTokensForModel', () => {
it.concurrent('should return published max for OpenAI GPT-4o', () => {
expect(getMaxOutputTokensForModel('gpt-4o')).toBe(16384)
})
it.concurrent('should return published max for OpenAI GPT-5.1', () => {
expect(getMaxOutputTokensForModel('gpt-5.1')).toBe(128000)
})
it.concurrent('should return published max for OpenAI GPT-5 Chat', () => {
expect(getMaxOutputTokensForModel('gpt-5-chat-latest')).toBe(16384)
})
it.concurrent('should return published max for OpenAI o1', () => {
expect(getMaxOutputTokensForModel('o1')).toBe(100000)
})
it.concurrent('should return updated max for Claude Sonnet 4.6', () => {
expect(getMaxOutputTokensForModel('claude-sonnet-4-6')).toBe(64000)
})
it.concurrent('should return published max for Gemini 2.5 Pro', () => {
expect(getMaxOutputTokensForModel('gemini-2.5-pro')).toBe(65536)
})
it.concurrent('should return published max for Azure GPT-5.2', () => {
expect(getMaxOutputTokensForModel('azure/gpt-5.2')).toBe(128000)
})
it.concurrent('should return standard default for models without maxOutputTokens', () => {
expect(getMaxOutputTokensForModel('deepseek-reasoner')).toBe(4096)
expect(getMaxOutputTokensForModel('grok-4-latest')).toBe(4096)
})
it.concurrent('should return published max for Bedrock Claude Opus 4.1', () => {
expect(getMaxOutputTokensForModel('bedrock/anthropic.claude-opus-4-1-20250805-v1:0')).toBe(
64000
)
})
it.concurrent('should return correct max for Claude Opus 4.6', () => {
expect(getMaxOutputTokensForModel('claude-opus-4-6')).toBe(128000)
})
@@ -676,10 +715,6 @@ describe('Max Output Tokens', () => {
expect(getMaxOutputTokensForModel('claude-opus-4-1')).toBe(32000)
})
it.concurrent('should return standard default for models without maxOutputTokens', () => {
expect(getMaxOutputTokensForModel('gpt-4o')).toBe(4096)
})
it.concurrent('should return standard default for unknown models', () => {
expect(getMaxOutputTokensForModel('unknown-model')).toBe(4096)
})

View File

@@ -186,7 +186,6 @@ export async function getWorkflowState(workflowId: string) {
if (normalizedData) {
const finalState = {
deploymentStatuses: {},
hasActiveWebhook: false,
blocks: normalizedData.blocks,
edges: normalizedData.edges,

View File

@@ -1,10 +1,9 @@
'use client'
import { useEffect } from 'react'
import { createLogger } from '@sim/logger'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import { environmentKeys } from '@/hooks/queries/environment'
import { useExecutionStore } from '@/stores/execution'
import { useVariablesStore } from '@/stores/panel'
import { useEnvironmentStore } from '@/stores/settings/environment'
import { consolePersistence, useTerminalConsoleStore } from '@/stores/terminal'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
@@ -12,198 +11,13 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('Stores')
// Track initialization state
let isInitializing = false
let appFullyInitialized = false
let dataInitialized = false // Flag for actual data loading completion
/**
* Initialize the application state and sync system
* localStorage persistence has been removed - relies on DB and Zustand stores only
* Reset all Zustand stores and React Query caches to initial state.
*/
async function initializeApplication(): Promise<void> {
if (typeof window === 'undefined' || isInitializing) return
isInitializing = true
appFullyInitialized = false
// Track initialization start time
const initStartTime = Date.now()
try {
// Load environment variables directly from DB
await useEnvironmentStore.getState().loadEnvironmentVariables()
// Mark data as initialized only after sync managers have loaded data from DB
dataInitialized = true
// Log initialization timing information
const initDuration = Date.now() - initStartTime
logger.info(`Application initialization completed in ${initDuration}ms`)
// Mark application as fully initialized
appFullyInitialized = true
} catch (error) {
logger.error('Error during application initialization:', { error })
// Still mark as initialized to prevent being stuck in initializing state
appFullyInitialized = true
// But don't mark data as initialized on error
dataInitialized = false
} finally {
isInitializing = false
}
}
/**
* Checks if application is fully initialized
*/
export function isAppInitialized(): boolean {
return appFullyInitialized
}
/**
* Checks if data has been loaded from the database
* This should be checked before any sync operations
*/
export function isDataInitialized(): boolean {
return dataInitialized
}
/**
* Handle application cleanup before unload
*/
function handleBeforeUnload(event: BeforeUnloadEvent): void {
// Check if we're on an authentication page and skip confirmation if we are
if (typeof window !== 'undefined') {
const path = window.location.pathname
// Skip confirmation for auth-related pages
if (
path === '/login' ||
path === '/signup' ||
path === '/reset-password' ||
path === '/verify'
) {
return
}
}
// Standard beforeunload pattern
event.preventDefault()
event.returnValue = ''
}
/**
* Clean up sync system
*/
function cleanupApplication(): void {
window.removeEventListener('beforeunload', handleBeforeUnload)
// Note: No sync managers to dispose - Socket.IO handles cleanup
}
/**
* Clear all user data when signing out
* localStorage persistence has been removed
*/
export async function clearUserData(): Promise<void> {
if (typeof window === 'undefined') return
try {
// Note: No sync managers to dispose - Socket.IO handles cleanup
// Reset all stores to their initial state
resetAllStores()
// Clear localStorage except for essential app settings (minimal usage)
const keysToKeep = ['next-favicon', 'theme']
const keysToRemove = Object.keys(localStorage).filter((key) => !keysToKeep.includes(key))
keysToRemove.forEach((key) => localStorage.removeItem(key))
// Reset application initialization state
appFullyInitialized = false
dataInitialized = false
logger.info('User data cleared successfully')
} catch (error) {
logger.error('Error clearing user data:', { error })
}
}
/**
* Hook to manage application lifecycle
*/
export function useAppInitialization() {
useEffect(() => {
// Use Promise to handle async initialization
initializeApplication()
return () => {
cleanupApplication()
}
}, [])
}
/**
* Hook to reinitialize the application after successful login
* Use this in the login success handler or post-login page
*/
export function useLoginInitialization() {
useEffect(() => {
reinitializeAfterLogin()
}, [])
}
/**
* Reinitialize the application after login
* This ensures we load fresh data from the database for the new user
*/
export async function reinitializeAfterLogin(): Promise<void> {
if (typeof window === 'undefined') return
try {
// Reset application initialization state
appFullyInitialized = false
dataInitialized = false
// Note: No sync managers to dispose - Socket.IO handles cleanup
// Clean existing state to avoid stale data
resetAllStores()
// Reset initialization flags to force a fresh load
isInitializing = false
// Reinitialize the application
await initializeApplication()
logger.info('Application reinitialized after login')
} catch (error) {
logger.error('Error reinitializing application:', { error })
}
}
// Initialize immediately when imported on client
if (typeof window !== 'undefined') {
initializeApplication()
}
// Export all stores
export {
useWorkflowStore,
useWorkflowRegistry,
useEnvironmentStore,
useExecutionStore,
useTerminalConsoleStore,
useVariablesStore,
useSubBlockStore,
}
// Helper function to reset all stores
export const resetAllStores = () => {
// Reset all stores to initial state
useWorkflowRegistry.setState({
activeWorkflowId: null,
error: null,
deploymentStatuses: {},
hydration: {
phase: 'idle',
workspaceId: null,
@@ -214,7 +28,7 @@ export const resetAllStores = () => {
})
useWorkflowStore.getState().clear()
useSubBlockStore.getState().clear()
useEnvironmentStore.getState().reset()
getQueryClient().removeQueries({ queryKey: environmentKeys.all })
useExecutionStore.getState().reset()
useTerminalConsoleStore.setState({
workflowEntries: {},
@@ -223,21 +37,24 @@ export const resetAllStores = () => {
isOpen: false,
})
consolePersistence.persist()
// Custom tools are managed by React Query cache, not a Zustand store
// Variables store has no tracking to reset; registry hydrates
}
// Helper function to log all store states
export const logAllStores = () => {
const state = {
workflow: useWorkflowStore.getState(),
workflowRegistry: useWorkflowRegistry.getState(),
environment: useEnvironmentStore.getState(),
execution: useExecutionStore.getState(),
console: useTerminalConsoleStore.getState(),
subBlock: useSubBlockStore.getState(),
variables: useVariablesStore.getState(),
/**
* Clear all user data when signing out.
*/
export async function clearUserData(): Promise<void> {
if (typeof window === 'undefined') return
try {
resetAllStores()
// Clear localStorage except for essential app settings
const keysToKeep = ['next-favicon', 'theme']
const keysToRemove = Object.keys(localStorage).filter((key) => !keysToKeep.includes(key))
keysToRemove.forEach((key) => localStorage.removeItem(key))
logger.info('User data cleared successfully')
} catch (error) {
logger.error('Error clearing user data:', { error })
}
return state
}

View File

@@ -9,6 +9,3 @@ export { usePanelStore } from './store'
// Toolbar
export { useToolbarStore } from './toolbar'
export type { ChatContext, PanelState, PanelTab } from './types'
export type { Variable, VariablesStore, VariableType } from './variables'
// Variables
export { useVariablesStore } from './variables'

View File

@@ -1,2 +0,0 @@
export { useVariablesStore } from './store'
export type { Variable, VariablesStore, VariableType } from './types'

View File

@@ -1,290 +0,0 @@
import { createLogger } from '@sim/logger'
import JSON5 from 'json5'
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { normalizeName } from '@/executor/constants'
import { useOperationQueueStore } from '@/stores/operation-queue/store'
import type { Variable, VariablesStore } from '@/stores/panel/variables/types'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
const logger = createLogger('VariablesStore')
function validateVariable(variable: Variable): string | undefined {
try {
switch (variable.type) {
case 'number':
if (Number.isNaN(Number(variable.value))) {
return 'Not a valid number'
}
break
case 'boolean':
if (!/^(true|false)$/i.test(String(variable.value).trim())) {
return 'Expected "true" or "false"'
}
break
case 'object':
try {
const valueToEvaluate = String(variable.value).trim()
if (!valueToEvaluate.startsWith('{') || !valueToEvaluate.endsWith('}')) {
return 'Not a valid object format'
}
const parsed = JSON5.parse(valueToEvaluate)
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
return 'Not a valid object'
}
return undefined
} catch (e) {
logger.error('Object parsing error:', e)
return 'Invalid object syntax'
}
case 'array':
try {
const parsed = JSON5.parse(String(variable.value))
if (!Array.isArray(parsed)) {
return 'Not a valid array'
}
} catch {
return 'Invalid array syntax'
}
break
}
return undefined
} catch (e) {
return e instanceof Error ? e.message : 'Invalid format'
}
}
function migrateStringToPlain(variable: Variable): Variable {
if (variable.type !== 'string') {
return variable
}
const updated = {
...variable,
type: 'plain' as const,
}
return updated
}
export const useVariablesStore = create<VariablesStore>()(
devtools((set, get) => ({
variables: {},
isLoading: false,
error: null,
isEditing: null,
async loadForWorkflow(workflowId) {
try {
set({ isLoading: true, error: null })
const res = await fetch(`/api/workflows/${workflowId}/variables`, { method: 'GET' })
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `Failed to load variables: ${res.statusText}`)
}
const data = await res.json()
const variables = (data?.data as Record<string, Variable>) || {}
set((state) => {
const withoutWorkflow = Object.fromEntries(
Object.entries(state.variables).filter(
(entry): entry is [string, Variable] => entry[1].workflowId !== workflowId
)
)
return {
variables: { ...withoutWorkflow, ...variables },
isLoading: false,
error: null,
}
})
} catch (e) {
const message = e instanceof Error ? e.message : 'Unknown error'
set({ isLoading: false, error: message })
}
},
addVariable: (variable, providedId?: string) => {
const id = providedId || crypto.randomUUID()
const workflowVariables = get().getVariablesByWorkflowId(variable.workflowId)
if (!variable.name || /^variable\d+$/.test(variable.name)) {
const existingNumbers = workflowVariables
.map((v) => {
const match = v.name.match(/^variable(\d+)$/)
return match ? Number.parseInt(match[1]) : 0
})
.filter((n) => !Number.isNaN(n))
const nextNumber = existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1
variable.name = `variable${nextNumber}`
}
let uniqueName = variable.name
let nameIndex = 1
while (workflowVariables.some((v) => v.name === uniqueName)) {
uniqueName = `${variable.name} (${nameIndex})`
nameIndex++
}
if (variable.type === 'string') {
variable.type = 'plain'
}
const newVariable: Variable = {
id,
workflowId: variable.workflowId,
name: uniqueName,
type: variable.type,
value: variable.value || '',
validationError: undefined,
}
const validationError = validateVariable(newVariable)
if (validationError) {
newVariable.validationError = validationError
}
set((state) => ({
variables: {
...state.variables,
[id]: newVariable,
},
}))
return id
},
updateVariable: (id, update) => {
set((state) => {
if (!state.variables[id]) return state
if (update.name !== undefined) {
const oldVariable = state.variables[id]
const oldVariableName = oldVariable.name
const newName = update.name.trim()
if (!newName) {
update = { ...update }
update.name = undefined
} else if (newName !== oldVariableName) {
const subBlockStore = useSubBlockStore.getState()
const targetWorkflowId = oldVariable.workflowId
if (targetWorkflowId) {
const workflowValues = subBlockStore.workflowValues[targetWorkflowId] || {}
const updatedWorkflowValues = { ...workflowValues }
const changedSubBlocks: Array<{ blockId: string; subBlockId: string; value: any }> =
[]
const oldVarName = normalizeName(oldVariableName)
const newVarName = normalizeName(newName)
const regex = new RegExp(`<variable\\.${oldVarName}>`, 'gi')
const updateReferences = (value: any, pattern: RegExp, replacement: string): any => {
if (typeof value === 'string') {
return pattern.test(value) ? value.replace(pattern, replacement) : value
}
if (Array.isArray(value)) {
return value.map((item) => updateReferences(item, pattern, replacement))
}
if (value !== null && typeof value === 'object') {
const result = { ...value }
for (const key in result) {
result[key] = updateReferences(result[key], pattern, replacement)
}
return result
}
return value
}
Object.entries(workflowValues).forEach(([blockId, blockValues]) => {
Object.entries(blockValues as Record<string, any>).forEach(
([subBlockId, value]) => {
const updatedValue = updateReferences(value, regex, `<variable.${newVarName}>`)
if (JSON.stringify(updatedValue) !== JSON.stringify(value)) {
if (!updatedWorkflowValues[blockId]) {
updatedWorkflowValues[blockId] = { ...workflowValues[blockId] }
}
updatedWorkflowValues[blockId][subBlockId] = updatedValue
changedSubBlocks.push({ blockId, subBlockId, value: updatedValue })
}
}
)
})
// Update local state
useSubBlockStore.setState({
workflowValues: {
...subBlockStore.workflowValues,
[targetWorkflowId]: updatedWorkflowValues,
},
})
// Queue operations for persistence via socket
const operationQueue = useOperationQueueStore.getState()
for (const { blockId, subBlockId, value } of changedSubBlocks) {
operationQueue.addToQueue({
id: crypto.randomUUID(),
operation: {
operation: 'subblock-update',
target: 'subblock',
payload: { blockId, subblockId: subBlockId, value },
},
workflowId: targetWorkflowId,
userId: 'system',
})
}
}
}
}
if (update.type === 'string') {
update = { ...update, type: 'plain' }
}
const updatedVariable: Variable = {
...state.variables[id],
...update,
validationError: undefined,
}
if (update.type || update.value !== undefined) {
updatedVariable.validationError = validateVariable(updatedVariable)
}
const updated = {
...state.variables,
[id]: updatedVariable,
}
return { variables: updated }
})
},
deleteVariable: (id) => {
set((state) => {
if (!state.variables[id]) return state
const workflowId = state.variables[id].workflowId
const { [id]: _, ...rest } = state.variables
return { variables: rest }
})
},
getVariablesByWorkflowId: (workflowId) => {
return Object.values(get().variables).filter((variable) => variable.workflowId === workflowId)
},
}))
)

View File

@@ -1,50 +0,0 @@
/**
* Variable types supported in the application
* Note: 'string' is deprecated - use 'plain' for text values instead
*/
export type VariableType = 'plain' | 'number' | 'boolean' | 'object' | 'array' | 'string'
/**
* Represents a workflow variable with workflow-specific naming
* Variable names must be unique within each workflow
*/
export interface Variable {
id: string
workflowId: string
name: string // Must be unique per workflow
type: VariableType
value: unknown
validationError?: string // Tracks format validation errors
}
export interface VariablesStore {
variables: Record<string, Variable>
isLoading: boolean
error: string | null
isEditing: string | null
/**
* Loads variables for a specific workflow from the API and hydrates the store.
*/
loadForWorkflow: (workflowId: string) => Promise<void>
/**
* Adds a new variable with automatic name uniqueness validation
* If a variable with the same name exists, it will be suffixed with a number
* Optionally accepts a predetermined ID for collaborative operations
*/
addVariable: (variable: Omit<Variable, 'id'>, providedId?: string) => string
/**
* Updates a variable, ensuring name remains unique within the workflow
* If an updated name conflicts with existing ones, a numbered suffix is added
*/
updateVariable: (id: string, update: Partial<Omit<Variable, 'id' | 'workflowId'>>) => void
deleteVariable: (id: string) => void
/**
* Returns all variables for a specific workflow
*/
getVariablesByWorkflowId: (workflowId: string) => Variable[]
}

View File

@@ -1,7 +1 @@
export { useEnvironmentStore } from './store'
export type {
CachedWorkspaceEnvData,
EnvironmentState,
EnvironmentStore,
EnvironmentVariable,
} from './types'
export type { CachedWorkspaceEnvData, EnvironmentState, EnvironmentVariable } from './types'

View File

@@ -1,43 +0,0 @@
import { createLogger } from '@sim/logger'
import { create } from 'zustand'
import { fetchPersonalEnvironment } from '@/lib/environment/api'
import type { EnvironmentStore, EnvironmentVariable } from './types'
const logger = createLogger('EnvironmentStore')
export const useEnvironmentStore = create<EnvironmentStore>()((set, get) => ({
variables: {},
isLoading: false,
error: null,
loadEnvironmentVariables: async () => {
try {
set({ isLoading: true, error: null })
const data = await fetchPersonalEnvironment()
set({ variables: data, isLoading: false })
} catch (error) {
logger.error('Error loading environment variables:', { error })
set({
error: error instanceof Error ? error.message : 'Unknown error',
isLoading: false,
})
throw error
}
},
setVariables: (variables: Record<string, EnvironmentVariable>) => {
set({ variables })
},
getAllVariables: () => {
return get().variables
},
reset: () => {
set({
variables: {},
isLoading: false,
error: null,
})
},
}))

View File

@@ -15,10 +15,3 @@ export interface EnvironmentState {
isLoading: boolean
error: string | null
}
export interface EnvironmentStore extends EnvironmentState {
loadEnvironmentVariables: () => Promise<void>
setVariables: (variables: Record<string, EnvironmentVariable>) => void
getAllVariables: () => Record<string, EnvironmentVariable>
reset: () => void
}

View File

@@ -0,0 +1,11 @@
export {
getDefaultVariablesDimensions,
getVariablesPosition,
MAX_VARIABLES_HEIGHT,
MAX_VARIABLES_WIDTH,
MIN_VARIABLES_HEIGHT,
MIN_VARIABLES_WIDTH,
useVariablesModalStore,
} from './modal'
export { useVariablesStore } from './store'
export type { Variable, VariablesStore, VariableType } from './types'

View File

@@ -0,0 +1,145 @@
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import type {
VariablesDimensions,
VariablesModalStore,
VariablesPosition,
} from '@/stores/variables/types'
/**
* Floating variables modal default dimensions.
* Slightly larger than the chat modal for more comfortable editing.
*/
const DEFAULT_WIDTH = 320
const DEFAULT_HEIGHT = 320
/**
* Minimum and maximum modal dimensions.
*/
export const MIN_VARIABLES_WIDTH = DEFAULT_WIDTH
export const MIN_VARIABLES_HEIGHT = DEFAULT_HEIGHT
export const MAX_VARIABLES_WIDTH = 500
export const MAX_VARIABLES_HEIGHT = 600
/** Inset gap between the viewport edge and the content window */
const CONTENT_WINDOW_GAP = 8
/**
* Compute a center-biased default position, factoring in current layout chrome
* (sidebar, right panel, terminal) and content window inset.
*/
const calculateDefaultPosition = (): VariablesPosition => {
if (typeof window === 'undefined') {
return { x: 100, y: 100 }
}
const sidebarWidth = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0'
)
const panelWidth = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--panel-width') || '0'
)
const terminalHeight = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--terminal-height') || '0'
)
const availableWidth = window.innerWidth - sidebarWidth - CONTENT_WINDOW_GAP - panelWidth
const availableHeight = window.innerHeight - CONTENT_WINDOW_GAP * 2 - terminalHeight
const x = sidebarWidth + (availableWidth - DEFAULT_WIDTH) / 2
const y = CONTENT_WINDOW_GAP + (availableHeight - DEFAULT_HEIGHT) / 2
return { x, y }
}
/**
* Constrain a position to the visible canvas, considering layout chrome.
*/
const constrainPosition = (
position: VariablesPosition,
width: number = DEFAULT_WIDTH,
height: number = DEFAULT_HEIGHT
): VariablesPosition => {
if (typeof window === 'undefined') return position
const sidebarWidth = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0'
)
const panelWidth = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--panel-width') || '0'
)
const terminalHeight = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--terminal-height') || '0'
)
const minX = sidebarWidth
const maxX = window.innerWidth - CONTENT_WINDOW_GAP - panelWidth - width
const minY = CONTENT_WINDOW_GAP
const maxY = window.innerHeight - CONTENT_WINDOW_GAP - terminalHeight - height
return {
x: Math.max(minX, Math.min(maxX, position.x)),
y: Math.max(minY, Math.min(maxY, position.y)),
}
}
/**
* Return a valid, constrained position. If the stored one is off-bounds due to
* layout changes, prefer a fresh default center position.
*/
export const getVariablesPosition = (
stored: VariablesPosition | null,
width: number = DEFAULT_WIDTH,
height: number = DEFAULT_HEIGHT
): VariablesPosition => {
if (!stored) return calculateDefaultPosition()
const constrained = constrainPosition(stored, width, height)
const deltaX = Math.abs(constrained.x - stored.x)
const deltaY = Math.abs(constrained.y - stored.y)
if (deltaX > 100 || deltaY > 100) return calculateDefaultPosition()
return constrained
}
/**
* UI-only store for the floating variables modal.
* Variable data lives in the variables data store (`@/stores/variables/store`).
*/
export const useVariablesModalStore = create<VariablesModalStore>()(
devtools(
persist(
(set) => ({
isOpen: false,
position: null,
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
setIsOpen: (open) => set({ isOpen: open }),
setPosition: (position) => set({ position }),
setDimensions: (dimensions) =>
set({
width: Math.max(MIN_VARIABLES_WIDTH, Math.min(MAX_VARIABLES_WIDTH, dimensions.width)),
height: Math.max(
MIN_VARIABLES_HEIGHT,
Math.min(MAX_VARIABLES_HEIGHT, dimensions.height)
),
}),
resetPosition: () => set({ position: null }),
}),
{
name: 'variables-modal-store',
partialize: (state) => ({
position: state.position,
width: state.width,
height: state.height,
}),
}
),
{ name: 'variables-modal-store' }
)
)
/**
* Get default floating variables modal dimensions.
*/
export const getDefaultVariablesDimensions = (): VariablesDimensions => ({
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
})

View File

@@ -1,145 +1,47 @@
import { createLogger } from '@sim/logger'
import JSON5 from 'json5'
import { v4 as uuidv4 } from 'uuid'
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { devtools } from 'zustand/middleware'
import { normalizeName } from '@/executor/constants'
import type {
Variable,
VariablesDimensions,
VariablesPosition,
VariablesStore,
VariableType,
} from '@/stores/variables/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useOperationQueueStore } from '@/stores/operation-queue/store'
import type { Variable, VariablesStore } from '@/stores/variables/types'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
const logger = createLogger('VariablesModalStore')
const logger = createLogger('VariablesStore')
/**
* Floating variables modal default dimensions.
* Slightly larger than the chat modal for more comfortable editing.
*/
const DEFAULT_WIDTH = 320
const DEFAULT_HEIGHT = 320
/**
* Minimum and maximum modal dimensions.
* Kept in sync with the chat modal experience.
*/
export const MIN_VARIABLES_WIDTH = DEFAULT_WIDTH
export const MIN_VARIABLES_HEIGHT = DEFAULT_HEIGHT
export const MAX_VARIABLES_WIDTH = 500
export const MAX_VARIABLES_HEIGHT = 600
/** Inset gap between the viewport edge and the content window */
const CONTENT_WINDOW_GAP = 8
/**
* Compute a center-biased default position, factoring in current layout chrome
* (sidebar, right panel, terminal) and content window inset.
*/
const calculateDefaultPosition = (): VariablesPosition => {
if (typeof window === 'undefined') {
return { x: 100, y: 100 }
}
const sidebarWidth = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0'
)
const panelWidth = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--panel-width') || '0'
)
const terminalHeight = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--terminal-height') || '0'
)
const availableWidth = window.innerWidth - sidebarWidth - CONTENT_WINDOW_GAP - panelWidth
const availableHeight = window.innerHeight - CONTENT_WINDOW_GAP * 2 - terminalHeight
const x = sidebarWidth + (availableWidth - DEFAULT_WIDTH) / 2
const y = CONTENT_WINDOW_GAP + (availableHeight - DEFAULT_HEIGHT) / 2
return { x, y }
}
/**
* Constrain a position to the visible canvas, considering layout chrome.
*/
const constrainPosition = (
position: VariablesPosition,
width: number = DEFAULT_WIDTH,
height: number = DEFAULT_HEIGHT
): VariablesPosition => {
if (typeof window === 'undefined') return position
const sidebarWidth = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0'
)
const panelWidth = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--panel-width') || '0'
)
const terminalHeight = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--terminal-height') || '0'
)
const minX = sidebarWidth
const maxX = window.innerWidth - CONTENT_WINDOW_GAP - panelWidth - width
const minY = CONTENT_WINDOW_GAP
const maxY = window.innerHeight - CONTENT_WINDOW_GAP - terminalHeight - height
return {
x: Math.max(minX, Math.min(maxX, position.x)),
y: Math.max(minY, Math.min(maxY, position.y)),
}
}
/**
* Return a valid, constrained position. If the stored one is off-bounds due to
* layout changes, prefer a fresh default center position.
*/
export const getVariablesPosition = (
stored: VariablesPosition | null,
width: number = DEFAULT_WIDTH,
height: number = DEFAULT_HEIGHT
): VariablesPosition => {
if (!stored) return calculateDefaultPosition()
const constrained = constrainPosition(stored, width, height)
const deltaX = Math.abs(constrained.x - stored.x)
const deltaY = Math.abs(constrained.y - stored.y)
if (deltaX > 100 || deltaY > 100) return calculateDefaultPosition()
return constrained
}
/**
* Validate a variable's value given its type. Returns an error message or undefined.
*/
function validateVariable(variable: Variable): string | undefined {
try {
switch (variable.type) {
case 'number': {
return Number.isNaN(Number(variable.value)) ? 'Not a valid number' : undefined
}
case 'boolean': {
return !/^(true|false)$/i.test(String(variable.value).trim())
? 'Expected "true" or "false"'
: undefined
}
case 'object': {
case 'number':
if (Number.isNaN(Number(variable.value))) {
return 'Not a valid number'
}
break
case 'boolean':
if (!/^(true|false)$/i.test(String(variable.value).trim())) {
return 'Expected "true" or "false"'
}
break
case 'object':
try {
const valueToEvaluate = String(variable.value).trim()
if (!valueToEvaluate.startsWith('{') || !valueToEvaluate.endsWith('}')) {
return 'Not a valid object format'
}
const parsed = JSON5.parse(valueToEvaluate)
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
return 'Not a valid object'
}
return undefined
} catch (e) {
logger.error('Object parsing error:', e)
return 'Invalid object syntax'
}
}
case 'array': {
case 'array':
try {
const parsed = JSON5.parse(String(variable.value))
if (!Array.isArray(parsed)) {
@@ -148,257 +50,199 @@ function validateVariable(variable: Variable): string | undefined {
} catch {
return 'Invalid array syntax'
}
return undefined
}
default:
return undefined
break
}
return undefined
} catch (e) {
return e instanceof Error ? e.message : 'Invalid format'
}
}
/**
* Migrate deprecated type 'string' -> 'plain'.
*/
function migrateStringToPlain(variable: Variable): Variable {
if (variable.type !== 'string') return variable
return { ...variable, type: 'plain' as const }
}
/**
* Floating Variables modal + Variables data store.
*/
export const useVariablesStore = create<VariablesStore>()(
devtools(
persist(
(set, get) => ({
// UI
isOpen: false,
position: null,
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
devtools((set, get) => ({
variables: {},
isLoading: false,
error: null,
isEditing: null,
setIsOpen: (open) => set({ isOpen: open }),
setPosition: (position) => set({ position }),
setDimensions: (dimensions) =>
set({
width: Math.max(MIN_VARIABLES_WIDTH, Math.min(MAX_VARIABLES_WIDTH, dimensions.width)),
height: Math.max(
MIN_VARIABLES_HEIGHT,
Math.min(MAX_VARIABLES_HEIGHT, dimensions.height)
),
}),
resetPosition: () => set({ position: null }),
addVariable: (variable, providedId?: string) => {
const id = providedId || crypto.randomUUID()
// Data
variables: {},
isLoading: false,
error: null,
const workflowVariables = get().getVariablesByWorkflowId(variable.workflowId)
async loadForWorkflow(workflowId) {
try {
set({ isLoading: true, error: null })
const res = await fetch(`/api/workflows/${workflowId}/variables`, { method: 'GET' })
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `Failed to load variables: ${res.statusText}`)
}
const data = await res.json()
const variables = (data?.data as Record<string, Variable>) || {}
// Migrate any deprecated types and merge into store (remove other workflow entries)
const migrated: Record<string, Variable> = Object.fromEntries(
Object.entries(variables).map(([id, v]) => [id, migrateStringToPlain(v)])
)
set((state) => {
const withoutThisWorkflow = Object.fromEntries(
Object.entries(state.variables).filter(
(entry): entry is [string, Variable] => entry[1].workflowId !== workflowId
)
)
return {
variables: { ...withoutThisWorkflow, ...migrated },
isLoading: false,
error: null,
}
})
} catch (e) {
const message = e instanceof Error ? e.message : 'Unknown error'
set({ isLoading: false, error: message })
}
},
addVariable: (variable, providedId) => {
const id = providedId || uuidv4()
const state = get()
const workflowVariables = state
.getVariablesByWorkflowId(variable.workflowId)
.map((v) => ({ id: v.id, name: v.name }))
// Default naming: variableN
if (!variable.name || /^variable\d+$/.test(variable.name)) {
const existingNumbers = workflowVariables
.map((v) => {
const match = v.name.match(/^variable(\d+)$/)
return match ? Number.parseInt(match[1]) : 0
})
.filter((n) => !Number.isNaN(n))
const nextNumber = existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1
variable.name = `variable${nextNumber}`
}
// Ensure uniqueness
let uniqueName = variable.name
let nameIndex = 1
while (workflowVariables.some((v) => v.name === uniqueName)) {
uniqueName = `${variable.name} (${nameIndex})`
nameIndex++
}
if (variable.type === 'string') {
variable.type = 'plain'
}
const newVariable: Variable = {
id,
workflowId: variable.workflowId,
name: uniqueName,
type: variable.type,
value: variable.value ?? '',
validationError: undefined,
}
const validationError = validateVariable(newVariable)
if (validationError) {
newVariable.validationError = validationError
}
set((state) => ({
variables: {
...state.variables,
[id]: newVariable,
},
}))
return id
},
updateVariable: (id, update) => {
set((state) => {
const existing = state.variables[id]
if (!existing) return state
// Handle name changes: keep references in sync across workflow values
if (update.name !== undefined) {
const oldVariableName = existing.name
const newName = String(update.name).trim()
if (!newName) {
update = { ...update, name: undefined }
} else if (newName !== oldVariableName) {
const subBlockStore = useSubBlockStore.getState()
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (activeWorkflowId) {
const workflowValues = subBlockStore.workflowValues[activeWorkflowId] || {}
const updatedWorkflowValues = { ...workflowValues }
Object.entries(workflowValues).forEach(([blockId, blockValues]) => {
Object.entries(blockValues as Record<string, any>).forEach(
([subBlockId, value]) => {
const oldVarName = normalizeName(oldVariableName)
const newVarName = normalizeName(newName)
const regex = new RegExp(`<variable\\.${oldVarName}>`, 'gi')
updatedWorkflowValues[blockId][subBlockId] = updateReferences(
value,
regex,
`<variable.${newVarName}>`
)
function updateReferences(
val: any,
refRegex: RegExp,
replacement: string
): any {
if (typeof val === 'string') {
return refRegex.test(val) ? val.replace(refRegex, replacement) : val
}
if (Array.isArray(val)) {
return val.map((item) => updateReferences(item, refRegex, replacement))
}
if (val !== null && typeof val === 'object') {
const result: Record<string, any> = { ...val }
for (const key in result) {
result[key] = updateReferences(result[key], refRegex, replacement)
}
return result
}
return val
}
}
)
})
useSubBlockStore.setState({
workflowValues: {
...subBlockStore.workflowValues,
[activeWorkflowId]: updatedWorkflowValues,
},
})
}
}
}
// Handle deprecated -> new type migration
if (update.type === 'string') {
update = { ...update, type: 'plain' as VariableType }
}
const updated: Variable = {
...existing,
...update,
validationError: undefined,
}
// Validate only when type or value changed
if (update.type || update.value !== undefined) {
updated.validationError = validateVariable(updated)
}
return {
variables: {
...state.variables,
[id]: updated,
},
}
if (!variable.name || /^variable\d+$/.test(variable.name)) {
const existingNumbers = workflowVariables
.map((v) => {
const match = v.name.match(/^variable(\d+)$/)
return match ? Number.parseInt(match[1]) : 0
})
},
.filter((n) => !Number.isNaN(n))
deleteVariable: (id) => {
set((state) => {
if (!state.variables[id]) return state
const { [id]: _deleted, ...rest } = state.variables
return { variables: rest }
})
},
const nextNumber = existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1
getVariablesByWorkflowId: (workflowId) => {
return Object.values(get().variables).filter((v) => v.workflowId === workflowId)
},
}),
{
name: 'variables-modal-store',
variable.name = `variable${nextNumber}`
}
)
)
)
/**
* Get default floating variables modal dimensions.
*/
export const getDefaultVariablesDimensions = (): VariablesDimensions => ({
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
})
let uniqueName = variable.name
let nameIndex = 1
while (workflowVariables.some((v) => v.name === uniqueName)) {
uniqueName = `${variable.name} (${nameIndex})`
nameIndex++
}
if (variable.type === 'string') {
variable.type = 'plain'
}
const newVariable: Variable = {
id,
workflowId: variable.workflowId,
name: uniqueName,
type: variable.type,
value: variable.value || '',
validationError: undefined,
}
const validationError = validateVariable(newVariable)
if (validationError) {
newVariable.validationError = validationError
}
set((state) => ({
variables: {
...state.variables,
[id]: newVariable,
},
}))
return id
},
updateVariable: (id, update) => {
set((state) => {
if (!state.variables[id]) return state
if (update.name !== undefined) {
const oldVariable = state.variables[id]
const oldVariableName = oldVariable.name
const newName = update.name.trim()
if (!newName) {
update = { ...update }
update.name = undefined
} else if (newName !== oldVariableName) {
const subBlockStore = useSubBlockStore.getState()
const targetWorkflowId = oldVariable.workflowId
if (targetWorkflowId) {
const workflowValues = subBlockStore.workflowValues[targetWorkflowId] || {}
const updatedWorkflowValues = { ...workflowValues }
const changedSubBlocks: Array<{ blockId: string; subBlockId: string; value: any }> =
[]
const oldVarName = normalizeName(oldVariableName)
const newVarName = normalizeName(newName)
const regex = new RegExp(`<variable\\.${oldVarName}>`, 'gi')
const updateReferences = (value: any, pattern: RegExp, replacement: string): any => {
if (typeof value === 'string') {
return pattern.test(value) ? value.replace(pattern, replacement) : value
}
if (Array.isArray(value)) {
return value.map((item) => updateReferences(item, pattern, replacement))
}
if (value !== null && typeof value === 'object') {
const result = { ...value }
for (const key in result) {
result[key] = updateReferences(result[key], pattern, replacement)
}
return result
}
return value
}
Object.entries(workflowValues).forEach(([blockId, blockValues]) => {
Object.entries(blockValues as Record<string, any>).forEach(
([subBlockId, value]) => {
const updatedValue = updateReferences(value, regex, `<variable.${newVarName}>`)
if (JSON.stringify(updatedValue) !== JSON.stringify(value)) {
if (!updatedWorkflowValues[blockId]) {
updatedWorkflowValues[blockId] = { ...workflowValues[blockId] }
}
updatedWorkflowValues[blockId][subBlockId] = updatedValue
changedSubBlocks.push({ blockId, subBlockId, value: updatedValue })
}
}
)
})
// Update local state
useSubBlockStore.setState({
workflowValues: {
...subBlockStore.workflowValues,
[targetWorkflowId]: updatedWorkflowValues,
},
})
// Queue operations for persistence via socket
const operationQueue = useOperationQueueStore.getState()
for (const { blockId, subBlockId, value } of changedSubBlocks) {
operationQueue.addToQueue({
id: crypto.randomUUID(),
operation: {
operation: 'subblock-update',
target: 'subblock',
payload: { blockId, subblockId: subBlockId, value },
},
workflowId: targetWorkflowId,
userId: 'system',
})
}
}
}
}
if (update.type === 'string') {
update = { ...update, type: 'plain' }
}
const updatedVariable: Variable = {
...state.variables[id],
...update,
validationError: undefined,
}
if (update.type || update.value !== undefined) {
updatedVariable.validationError = validateVariable(updatedVariable)
}
const updated = {
...state.variables,
[id]: updatedVariable,
}
return { variables: updated }
})
},
deleteVariable: (id) => {
set((state) => {
if (!state.variables[id]) return state
const { [id]: _, ...rest } = state.variables
return { variables: rest }
})
},
getVariablesByWorkflowId: (workflowId) => {
return Object.values(get().variables).filter((variable) => variable.workflowId === workflowId)
},
}))
)

View File

@@ -1,19 +1,47 @@
/**
* Variable types supported by the variables modal/editor.
* Note: 'string' is deprecated. Use 'plain' for freeform text values instead.
* Variable types supported in the application
* Note: 'string' is deprecated - use 'plain' for text values instead
*/
export type VariableType = 'plain' | 'number' | 'boolean' | 'object' | 'array' | 'string'
/**
* Workflow-scoped variable model.
* Represents a workflow variable with workflow-specific naming
* Variable names must be unique within each workflow
*/
export interface Variable {
id: string
workflowId: string
name: string
name: string // Must be unique per workflow
type: VariableType
value: unknown
validationError?: string
validationError?: string // Tracks format validation errors
}
export interface VariablesStore {
variables: Record<string, Variable>
isLoading: boolean
error: string | null
isEditing: string | null
/**
* Adds a new variable with automatic name uniqueness validation
* If a variable with the same name exists, it will be suffixed with a number
* Optionally accepts a predetermined ID for collaborative operations
*/
addVariable: (variable: Omit<Variable, 'id'>, providedId?: string) => string
/**
* Updates a variable, ensuring name remains unique within the workflow
* If an updated name conflicts with existing ones, a numbered suffix is added
*/
updateVariable: (id: string, update: Partial<Omit<Variable, 'id' | 'workflowId'>>) => void
deleteVariable: (id: string) => void
/**
* Returns all variables for a specific workflow
*/
getVariablesByWorkflowId: (workflowId: string) => Variable[]
}
/**
@@ -33,11 +61,10 @@ export interface VariablesDimensions {
}
/**
* Public store interface for variables editor/modal.
* Combines UI state of the floating modal and the variables data/actions.
* UI-only store interface for the floating variables modal.
* Variable data lives in the variables data store (`@/stores/variables/store`).
*/
export interface VariablesStore {
// UI State
export interface VariablesModalStore {
isOpen: boolean
position: VariablesPosition | null
width: number
@@ -46,16 +73,4 @@ export interface VariablesStore {
setPosition: (position: VariablesPosition) => void
setDimensions: (dimensions: VariablesDimensions) => void
resetPosition: () => void
// Data
variables: Record<string, Variable>
isLoading: boolean
error: string | null
// Actions
loadForWorkflow: (workflowId: string) => Promise<void>
addVariable: (variable: Omit<Variable, 'id'>, providedId?: string) => string
updateVariable: (id: string, update: Partial<Omit<Variable, 'id' | 'workflowId'>>) => void
deleteVariable: (id: string) => void
getVariablesByWorkflowId: (workflowId: string) => Variable[]
}

View File

@@ -3,7 +3,7 @@ import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('Workflows')
@@ -30,9 +30,6 @@ export function getWorkflowWithValues(workflowId: string, workspaceId: string) {
return null
}
// Get deployment status from registry
const deploymentStatus = useWorkflowRegistry.getState().getWorkflowDeploymentStatus(workflowId)
// Use the current state from the store (only available for active workflow)
const workflowState: WorkflowState = useWorkflowStore.getState().getWorkflowState()
@@ -52,110 +49,10 @@ export function getWorkflowWithValues(workflowId: string, workspaceId: string) {
loops: workflowState.loops,
parallels: workflowState.parallels,
lastSaved: workflowState.lastSaved,
// Get deployment fields from registry for API compatibility
isDeployed: deploymentStatus?.isDeployed || false,
deployedAt: deploymentStatus?.deployedAt,
},
}
}
/**
* Get a specific block with its subblock values merged in
* @param blockId ID of the block to retrieve
* @returns The block with merged subblock values or null if not found
*/
export function getBlockWithValues(blockId: string): BlockState | null {
const workflowState = useWorkflowStore.getState()
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (!activeWorkflowId || !workflowState.blocks[blockId]) return null
const mergedBlocks = mergeSubblockState(workflowState.blocks, activeWorkflowId, blockId)
return mergedBlocks[blockId] || null
}
/**
* Get all workflows with their values merged
* Note: Since localStorage has been removed, this only includes the active workflow state
* @param workspaceId Workspace containing the workflow metadata
* @returns An object containing workflows, with state only for the active workflow
*/
export function getAllWorkflowsWithValues(workspaceId: string) {
const workflows = getWorkflows(workspaceId)
const result: Record<
string,
{
id: string
name: string
description?: string
color: string
folderId?: string | null
workspaceId?: string
apiKey?: string
state: WorkflowState & { isDeployed: boolean; deployedAt?: Date }
}
> = {}
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
const currentState = useWorkflowStore.getState()
// Only sync the active workflow to ensure we always send valid state data
const activeMetadata = activeWorkflowId
? workflows.find((w) => w.id === activeWorkflowId)
: undefined
if (activeWorkflowId && activeMetadata) {
const metadata = activeMetadata
// Get deployment status from registry
const deploymentStatus = useWorkflowRegistry
.getState()
.getWorkflowDeploymentStatus(activeWorkflowId)
// Ensure state has all required fields for Zod validation
const workflowState: WorkflowState = {
...useWorkflowStore.getState().getWorkflowState(),
// Ensure fallback values for safer handling
blocks: currentState.blocks || {},
edges: currentState.edges || [],
loops: currentState.loops || {},
parallels: currentState.parallels || {},
lastSaved: currentState.lastSaved || Date.now(),
}
// Merge the subblock values for this specific workflow
const mergedBlocks = mergeSubblockState(workflowState.blocks, activeWorkflowId)
// Include the API key in the state if it exists in the deployment status
const apiKey = deploymentStatus?.apiKey
result[activeWorkflowId] = {
id: activeWorkflowId,
name: metadata.name,
description: metadata.description,
color: metadata.color || '#3972F6',
folderId: metadata.folderId,
state: {
blocks: mergedBlocks,
edges: workflowState.edges,
loops: workflowState.loops,
parallels: workflowState.parallels,
lastSaved: workflowState.lastSaved,
// Get deployment fields from registry for API compatibility
isDeployed: deploymentStatus?.isDeployed || false,
deployedAt: deploymentStatus?.deployedAt,
},
// Include API key if available
apiKey,
}
// Only include workspaceId if it's not null/undefined
if (metadata.workspaceId) {
result[activeWorkflowId].workspaceId = metadata.workspaceId
}
}
return result
}
export { useWorkflowRegistry } from '@/stores/workflows/registry/store'
export type { WorkflowMetadata } from '@/stores/workflows/registry/types'
export { useSubBlockStore } from '@/stores/workflows/subblock/store'

View File

@@ -3,14 +3,12 @@ import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import type { WorkflowDeploymentInfo } from '@/hooks/queries/deployments'
import { deploymentKeys } from '@/hooks/queries/deployments'
import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists'
import { useVariablesStore } from '@/stores/panel/variables/store'
import type { Variable } from '@/stores/panel/variables/types'
import type {
DeploymentStatus,
HydrationState,
WorkflowRegistry,
} from '@/stores/workflows/registry/types'
import { useVariablesStore } from '@/stores/variables/store'
import type { Variable } from '@/stores/variables/types'
import type { HydrationState, WorkflowRegistry } from '@/stores/workflows/registry/types'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { getUniqueBlockName, regenerateBlockIds } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -34,7 +32,6 @@ function resetWorkflowStores() {
edges: [],
loops: {},
parallels: {},
deploymentStatuses: {},
lastSaved: Date.now(),
})
@@ -48,7 +45,6 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
(set, get) => ({
activeWorkflowId: null,
error: null,
deploymentStatuses: {},
hydration: initialHydration,
clipboard: null,
pendingSelection: null,
@@ -61,7 +57,6 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
set({
activeWorkflowId: null,
deploymentStatuses: {},
error: null,
hydration: {
phase: 'idle',
@@ -73,74 +68,6 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
})
},
getWorkflowDeploymentStatus: (workflowId: string | null): DeploymentStatus | null => {
if (!workflowId) {
workflowId = get().activeWorkflowId
if (!workflowId) return null
}
const { deploymentStatuses = {} } = get()
if (deploymentStatuses[workflowId]) {
return deploymentStatuses[workflowId]
}
return null
},
setDeploymentStatus: (
workflowId: string | null,
isDeployed: boolean,
deployedAt?: Date,
apiKey?: string
) => {
if (!workflowId) {
workflowId = get().activeWorkflowId
if (!workflowId) return
}
set((state) => ({
deploymentStatuses: {
...state.deploymentStatuses,
[workflowId as string]: {
isDeployed,
deployedAt: deployedAt || (isDeployed ? new Date() : undefined),
apiKey,
needsRedeployment: isDeployed
? false
: (state.deploymentStatuses?.[workflowId as string]?.needsRedeployment ?? false),
},
},
}))
},
setWorkflowNeedsRedeployment: (workflowId: string | null, needsRedeployment: boolean) => {
if (!workflowId) {
workflowId = get().activeWorkflowId
if (!workflowId) return
}
set((state) => {
const deploymentStatuses = state.deploymentStatuses || {}
const currentStatus = deploymentStatuses[workflowId as string] || { isDeployed: false }
return {
deploymentStatuses: {
...deploymentStatuses,
[workflowId as string]: {
...currentStatus,
needsRedeployment,
},
},
}
})
const { activeWorkflowId } = get()
if (workflowId === activeWorkflowId) {
useWorkflowStore.getState().setNeedsRedeploymentFlag(needsRedeployment)
}
},
loadWorkflowState: async (workflowId: string) => {
const workspaceId = get().hydration.workspaceId
if (!workspaceId) {
@@ -170,20 +97,19 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
}
const workflowData = (await response.json()).data
const nextDeploymentStatuses =
workflowData?.isDeployed || workflowData?.deployedAt
? {
...get().deploymentStatuses,
[workflowId]: {
isDeployed: workflowData.isDeployed || false,
deployedAt: workflowData.deployedAt
? new Date(workflowData.deployedAt)
: undefined,
apiKey: workflowData.apiKey || undefined,
needsRedeployment: false,
},
}
: get().deploymentStatuses
if (workflowData?.isDeployed !== undefined) {
getQueryClient().setQueryData<WorkflowDeploymentInfo>(
deploymentKeys.info(workflowId),
(prev) => ({
isDeployed: workflowData.isDeployed ?? false,
deployedAt: workflowData.deployedAt ?? null,
apiKey: workflowData.apiKey ?? prev?.apiKey ?? null,
needsRedeployment: prev?.needsRedeployment ?? false,
isPublicApi: prev?.isPublicApi ?? false,
})
)
}
let workflowState: WorkflowState
@@ -195,7 +121,6 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
loops: workflowData.state.loops || {},
parallels: workflowData.state.parallels || {},
lastSaved: Date.now(),
deploymentStatuses: nextDeploymentStatuses,
}
} else {
workflowState = {
@@ -204,7 +129,6 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
edges: [],
loops: {},
parallels: {},
deploymentStatuses: nextDeploymentStatuses,
lastSaved: Date.now(),
}
@@ -250,7 +174,6 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
set((state) => ({
activeWorkflowId: workflowId,
error: null,
deploymentStatuses: nextDeploymentStatuses,
hydration: {
phase: 'ready',
workspaceId: state.hydration.workspaceId,
@@ -367,7 +290,6 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
set({
activeWorkflowId: null,
deploymentStatuses: {},
error: null,
hydration: initialHydration,
clipboard: null,

View File

@@ -1,13 +1,6 @@
import type { Edge } from 'reactflow'
import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types'
export interface DeploymentStatus {
isDeployed: boolean
deployedAt?: Date
apiKey?: string
needsRedeployment?: boolean
}
export interface ClipboardData {
blocks: Record<string, BlockState>
edges: Edge[]
@@ -45,7 +38,6 @@ export interface HydrationState {
export interface WorkflowRegistryState {
activeWorkflowId: string | null
error: string | null
deploymentStatuses: Record<string, DeploymentStatus>
hydration: HydrationState
clipboard: ClipboardData | null
pendingSelection: string[] | null
@@ -57,14 +49,6 @@ export interface WorkflowRegistryActions {
switchToWorkspace: (id: string) => void
markWorkflowCreating: (workflowId: string) => void
markWorkflowCreated: (workflowId: string | null) => void
getWorkflowDeploymentStatus: (workflowId: string | null) => DeploymentStatus | null
setDeploymentStatus: (
workflowId: string | null,
isDeployed: boolean,
deployedAt?: Date,
apiKey?: string
) => void
setWorkflowNeedsRedeployment: (workflowId: string | null, needsRedeployment: boolean) => void
copyBlocks: (blockIds: string[]) => void
preparePasteData: (positionOffset?: { x: number; y: number }) => {
blocks: Record<string, BlockState>

View File

@@ -107,8 +107,6 @@ const initialState = {
loops: {},
parallels: {},
lastSaved: undefined,
deploymentStatuses: {},
needsRedeployment: false,
}
export const useWorkflowStore = create<WorkflowStore>()(
@@ -116,10 +114,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
(set, get) => ({
...initialState,
setNeedsRedeploymentFlag: (needsRedeployment: boolean) => {
set({ needsRedeployment })
},
setCurrentWorkflowId: (currentWorkflowId) => {
set({ currentWorkflowId })
},
@@ -540,8 +534,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
loops: state.loops,
parallels: state.parallels,
lastSaved: state.lastSaved,
deploymentStatuses: state.deploymentStatuses,
needsRedeployment: state.needsRedeployment,
}
},
replaceWorkflowState: (
@@ -580,11 +572,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
edges: nextEdges,
loops: nextLoops,
parallels: nextParallels,
deploymentStatuses: nextState.deploymentStatuses || state.deploymentStatuses,
needsRedeployment:
nextState.needsRedeployment !== undefined
? nextState.needsRedeployment
: state.needsRedeployment,
lastSaved:
options?.updateLastSaved === true
? Date.now()
@@ -1141,67 +1128,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
}))
},
revertToDeployedState: async (deployedState: WorkflowState) => {
const activeWorkflowId = get().currentWorkflowId
if (!activeWorkflowId) {
logger.error('Cannot revert: no active workflow ID')
return
}
const deploymentStatus = get().deploymentStatuses?.[activeWorkflowId]
get().replaceWorkflowState({
...deployedState,
needsRedeployment: false,
deploymentStatuses: {
...get().deploymentStatuses,
...(deploymentStatus ? { [activeWorkflowId]: deploymentStatus } : {}),
},
})
const values: Record<string, Record<string, any>> = {}
Object.entries(deployedState.blocks).forEach(([blockId, block]) => {
values[blockId] = {}
Object.entries(block.subBlocks || {}).forEach(([subBlockId, subBlock]) => {
values[blockId][subBlockId] = subBlock.value
})
})
useSubBlockStore.setState({
workflowValues: {
...useSubBlockStore.getState().workflowValues,
[activeWorkflowId]: values,
},
})
get().updateLastSaved()
// Call API to persist the revert to normalized tables
try {
const response = await fetch(
`/api/workflows/${activeWorkflowId}/deployments/active/revert`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
}
)
if (!response.ok) {
const errorData = await response.json()
logger.error('Failed to persist revert to deployed state:', errorData.error)
// Don't throw error to avoid breaking the UI, but log it
} else {
logger.info('Successfully persisted revert to deployed state')
}
} catch (error) {
logger.error('Error calling revert to deployed API:', error)
// Don't throw error to avoid breaking the UI
}
},
toggleBlockAdvancedMode: (id: string) => {
const block = get().blocks[id]
if (!block) return

View File

@@ -1,6 +1,5 @@
import type { Edge } from 'reactflow'
import type { OutputFieldDefinition, SubBlockType } from '@/blocks/types'
import type { DeploymentStatus } from '@/stores/workflows/registry/types'
export const SUBFLOW_TYPES = {
LOOP: 'loop',
@@ -173,8 +172,6 @@ export interface WorkflowState {
exportedAt?: string
}
variables?: Record<string, Variable>
deploymentStatuses?: Record<string, DeploymentStatus>
needsRedeployment?: boolean
dragStartPosition?: DragStartPosition | null
}
@@ -228,8 +225,6 @@ export interface WorkflowActions {
updateParallelType: (parallelId: string, parallelType: 'count' | 'collection') => void
generateLoopBlocks: () => Record<string, Loop>
generateParallelBlocks: () => Record<string, Parallel>
setNeedsRedeploymentFlag: (needsRedeployment: boolean) => void
revertToDeployedState: (deployedState: WorkflowState) => void
toggleBlockAdvancedMode: (id: string) => void
setDragStartPosition: (position: DragStartPosition | null) => void
getDragStartPosition: () => DragStartPosition | null

View File

@@ -25,6 +25,7 @@ const {
mockGetCustomToolById,
mockListCustomTools,
mockGetCustomToolByIdOrTitle,
mockGenerateInternalToken,
} = vi.hoisted(() => ({
mockIsHosted: { value: false },
mockEnv: { NEXT_PUBLIC_APP_URL: 'http://localhost:3000' } as Record<string, string | undefined>,
@@ -38,6 +39,7 @@ const {
mockGetCustomToolById: vi.fn(),
mockListCustomTools: vi.fn(),
mockGetCustomToolByIdOrTitle: vi.fn(),
mockGenerateInternalToken: vi.fn(),
}))
// Mock feature flags
@@ -65,6 +67,10 @@ vi.mock('@/lib/api-key/byok', () => ({
getBYOKKey: (...args: unknown[]) => mockGetBYOKKey(...args),
}))
vi.mock('@/lib/auth/internal', () => ({
generateInternalToken: (...args: unknown[]) => mockGenerateInternalToken(...args),
}))
vi.mock('@/lib/billing/core/usage-log', () => ({}))
vi.mock('@/lib/core/rate-limiter/hosted-key', () => ({
@@ -193,8 +199,8 @@ vi.mock('@/tools/registry', () => {
return { tools: mockTools }
})
// Mock custom tools - define mock data inside factory function
vi.mock('@/hooks/queries/utils/custom-tool-cache', () => {
// Mock query client for custom tool cache reads
vi.mock('@/app/_shell/providers/get-query-client', () => {
const mockCustomTool = {
id: 'custom-tool-123',
title: 'Custom Weather Tool',
@@ -214,13 +220,12 @@ vi.mock('@/hooks/queries/utils/custom-tool-cache', () => {
},
}
return {
getCustomTool: (toolId: string) => {
if (toolId === 'custom-tool-123') {
return mockCustomTool
}
return undefined
},
getCustomTools: () => [mockCustomTool],
getQueryClient: () => ({
getQueryData: (key: string[]) => {
if (key[0] === 'customTools') return [mockCustomTool]
return undefined
},
}),
}
})
@@ -1155,6 +1160,34 @@ describe('MCP Tool Execution', () => {
expect(result.timing).toBeDefined()
})
it('should embed userId in JWT when executionContext is undefined (agent block path)', async () => {
mockGenerateInternalToken.mockResolvedValue('test-token')
global.fetch = Object.assign(
vi.fn().mockImplementation(async () => ({
ok: true,
status: 200,
json: () =>
Promise.resolve({
success: true,
data: { output: { content: [{ type: 'text', text: 'OK' }] } },
}),
})),
{ preconnect: vi.fn() }
) as typeof fetch
await executeTool('mcp-123-test_tool', {
query: 'test',
_context: {
workspaceId: 'workspace-456',
workflowId: 'workflow-789',
userId: 'user-abc',
},
})
expect(mockGenerateInternalToken).toHaveBeenCalledWith('user-abc')
})
describe('Tool request retries', () => {
function makeJsonResponse(
status: number,

View File

@@ -1552,11 +1552,13 @@ async function executeMcpTool(
const baseUrl = getInternalApiBaseUrl()
const mcpScope = resolveToolScope(params, executionContext)
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (typeof window === 'undefined') {
try {
const internalToken = await generateInternalToken(executionContext?.userId)
const internalToken = await generateInternalToken(mcpScope.userId)
headers.Authorization = `Bearer ${internalToken}`
} catch (error) {
logger.error(`[${actualRequestId}] Failed to generate internal token:`, error)
@@ -1587,8 +1589,6 @@ async function executeMcpTool(
)
}
const mcpScope = resolveToolScope(params, executionContext)
if (mcpScope.callChain && mcpScope.callChain.length > 0) {
headers[SIM_VIA_HEADER] = serializeCallChain(mcpScope.callChain)
}

View File

@@ -21,24 +21,23 @@ vi.mock('@/lib/core/security/input-validation.server', () => ({
secureFetchWithPinnedIP: vi.fn(),
}))
vi.mock('@/stores/settings/environment', () => {
const mockStore = {
getAllVariables: vi.fn().mockReturnValue({
API_KEY: { value: 'mock-api-key' },
BASE_URL: { value: 'https://example.com' },
}),
}
const { mockGetQueryData } = vi.hoisted(() => ({
mockGetQueryData: vi.fn(),
}))
return {
useEnvironmentStore: {
getState: vi.fn().mockImplementation(() => mockStore),
},
}
})
vi.mock('@/app/_shell/providers/get-query-client', () => ({
getQueryClient: () => ({
getQueryData: mockGetQueryData,
}),
}))
const originalWindow = global.window
beforeEach(() => {
global.window = {} as any
mockGetQueryData.mockReturnValue({
API_KEY: { key: 'API_KEY', value: 'mock-api-key' },
BASE_URL: { key: 'BASE_URL', value: 'https://example.com' },
})
})
afterEach(() => {
@@ -651,15 +650,8 @@ describe('createParamSchema', () => {
})
describe('getClientEnvVars', () => {
it.concurrent('should return environment variables from store in browser environment', () => {
const mockStoreGetter = () => ({
getAllVariables: () => ({
API_KEY: { value: 'mock-api-key' },
BASE_URL: { value: 'https://example.com' },
}),
})
const result = getClientEnvVars(mockStoreGetter)
it('should return environment variables from React Query cache in browser environment', () => {
const result = getClientEnvVars()
expect(result).toEqual({
API_KEY: 'mock-api-key',
@@ -667,7 +659,7 @@ describe('getClientEnvVars', () => {
})
})
it.concurrent('should return empty object in server environment', () => {
it('should return empty object in server environment', () => {
global.window = undefined as any
const result = getClientEnvVars()
@@ -677,7 +669,7 @@ describe('getClientEnvVars', () => {
})
describe('createCustomToolRequestBody', () => {
it.concurrent('should create request body function for client-side execution', () => {
it('should create request body function for client-side execution', () => {
const customTool = {
code: 'return a + b',
schema: {
@@ -687,14 +679,7 @@ describe('createCustomToolRequestBody', () => {
},
}
const mockStoreGetter = () => ({
getAllVariables: () => ({
API_KEY: { value: 'mock-api-key' },
BASE_URL: { value: 'https://example.com' },
}),
})
const bodyFn = createCustomToolRequestBody(customTool, true, undefined, mockStoreGetter)
const bodyFn = createCustomToolRequestBody(customTool, true)
const result = bodyFn({ a: 5, b: 3 })
expect(result).toEqual({

View File

@@ -1,7 +1,9 @@
import { createLogger } from '@sim/logger'
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import type { CustomToolDefinition } from '@/hooks/queries/custom-tools'
import { useEnvironmentStore } from '@/stores/settings/environment'
import { environmentKeys } from '@/hooks/queries/environment'
import type { EnvironmentVariable } from '@/stores/settings/environment'
import { tools } from '@/tools/registry'
import type { ToolConfig } from '@/tools/types'
@@ -215,20 +217,20 @@ export function createParamSchema(customTool: any): Record<string, any> {
}
/**
* Get environment variables from store (client-side only)
* @param getStore Optional function to get the store (useful for testing)
* Get environment variables from React Query cache (client-side only)
*/
export function getClientEnvVars(getStore?: () => any): Record<string, string> {
export function getClientEnvVars(): Record<string, string> {
if (typeof window === 'undefined') return {}
try {
// Allow injecting the store for testing
const envStore = getStore ? getStore() : useEnvironmentStore.getState()
const allEnvVars = envStore.getAllVariables()
const allEnvVars =
getQueryClient().getQueryData<Record<string, EnvironmentVariable>>(
environmentKeys.personal()
) ?? {}
// Convert environment variables to a simple key-value object
return Object.entries(allEnvVars).reduce(
(acc, [key, variable]: [string, any]) => {
(acc, [key, variable]) => {
acc[key] = variable.value
return acc
},
@@ -245,20 +247,14 @@ export function getClientEnvVars(getStore?: () => any): Record<string, string> {
* @param customTool The custom tool configuration
* @param isClient Whether running on client side
* @param workflowId Optional workflow ID for server-side
* @param getStore Optional function to get the store (useful for testing)
*/
export function createCustomToolRequestBody(
customTool: any,
isClient = true,
workflowId?: string,
getStore?: () => any
) {
export function createCustomToolRequestBody(customTool: any, isClient = true, workflowId?: string) {
return (params: Record<string, any>) => {
// Get environment variables - try multiple sources in order of preference:
// 1. envVars parameter (passed from provider/agent context)
// 2. Client-side store (if running in browser)
// 3. Empty object (fallback)
const envVars = params.envVars || (isClient ? getClientEnvVars(getStore) : {})
const envVars = params.envVars || (isClient ? getClientEnvVars() : {})
// Get workflow variables from params (passed from execution context)
const workflowVariables = params.workflowVariables || {}