diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index 81b1b6893..41bce6a7b 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -4,7 +4,7 @@ on: push: branches: [main] paths: - - 'packages/simstudio/**' + - 'packages/cli/**' jobs: publish-npm: @@ -25,16 +25,16 @@ jobs: registry-url: 'https://registry.npmjs.org/' - name: Install dependencies - working-directory: packages/simstudio + working-directory: packages/cli run: bun install - name: Build package - working-directory: packages/simstudio + working-directory: packages/cli run: bun run build - name: Get package version id: package_version - working-directory: packages/simstudio + working-directory: packages/cli run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT - name: Check if version already exists @@ -48,7 +48,7 @@ jobs: - name: Publish to npm if: steps.version_check.outputs.exists == 'false' - working-directory: packages/simstudio + working-directory: packages/cli run: npm publish --access=public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/publish-python-sdk.yml b/.github/workflows/publish-python-sdk.yml new file mode 100644 index 000000000..6892405de --- /dev/null +++ b/.github/workflows/publish-python-sdk.yml @@ -0,0 +1,89 @@ +name: Publish Python SDK + +on: + push: + branches: [main] + paths: + - 'packages/python-sdk/**' + +jobs: + publish-pypi: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: 'pip' + + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + pip install build twine pytest requests tomli + + - name: Run tests + working-directory: packages/python-sdk + run: | + PYTHONPATH=. pytest tests/ -v + + - name: Get package version + id: package_version + working-directory: packages/python-sdk + run: echo "version=$(python -c "import tomli; print(tomli.load(open('pyproject.toml', 'rb'))['project']['version'])")" >> $GITHUB_OUTPUT + + - name: Check if version already exists + id: version_check + run: | + if pip index versions simstudio-sdk | grep -q "${{ steps.package_version.outputs.version }}"; then + echo "exists=true" >> $GITHUB_OUTPUT + else + echo "exists=false" >> $GITHUB_OUTPUT + fi + + - name: Build package + if: steps.version_check.outputs.exists == 'false' + working-directory: packages/python-sdk + run: python -m build + + - name: Check package + if: steps.version_check.outputs.exists == 'false' + working-directory: packages/python-sdk + run: twine check dist/* + + - name: Publish to PyPI + if: steps.version_check.outputs.exists == 'false' + working-directory: packages/python-sdk + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + run: twine upload dist/* + + - name: Log skipped publish + if: steps.version_check.outputs.exists == 'true' + run: echo "Skipped publishing because version ${{ steps.package_version.outputs.version }} already exists on PyPI" + + - name: Create GitHub Release + if: steps.version_check.outputs.exists == 'false' + uses: softprops/action-gh-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: python-sdk-v${{ steps.package_version.outputs.version }} + name: Python SDK v${{ steps.package_version.outputs.version }} + body: | + ## Python SDK v${{ steps.package_version.outputs.version }} + + Published simstudio-sdk==${{ steps.package_version.outputs.version }} to PyPI. + + ### Installation + ```bash + pip install simstudio-sdk==${{ steps.package_version.outputs.version }} + ``` + + ### Documentation + See the [README](https://github.com/simstudio/sim/tree/main/packages/python-sdk) for usage instructions. + draft: false + prerelease: false \ No newline at end of file diff --git a/.github/workflows/publish-ts-sdk.yml b/.github/workflows/publish-ts-sdk.yml new file mode 100644 index 000000000..360f5aa20 --- /dev/null +++ b/.github/workflows/publish-ts-sdk.yml @@ -0,0 +1,85 @@ +name: Publish TypeScript SDK + +on: + push: + branches: [main] + paths: + - 'packages/ts-sdk/**' + +jobs: + publish-npm: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Setup Node.js for npm publishing + uses: actions/setup-node@v4 + with: + node-version: '18' + registry-url: 'https://registry.npmjs.org/' + + - name: Install dependencies + working-directory: packages/ts-sdk + run: bun install + + - name: Run tests + working-directory: packages/ts-sdk + run: bun run test + + - name: Build package + working-directory: packages/ts-sdk + run: bun run build + + - name: Get package version + id: package_version + working-directory: packages/ts-sdk + run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT + + - name: Check if version already exists + id: version_check + run: | + if npm view simstudio-ts-sdk@${{ steps.package_version.outputs.version }} version &> /dev/null; then + echo "exists=true" >> $GITHUB_OUTPUT + else + echo "exists=false" >> $GITHUB_OUTPUT + fi + + - name: Publish to npm + if: steps.version_check.outputs.exists == 'false' + working-directory: packages/ts-sdk + run: npm publish --access=public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Log skipped publish + if: steps.version_check.outputs.exists == 'true' + run: echo "Skipped publishing because version ${{ steps.package_version.outputs.version }} already exists on npm" + + - name: Create GitHub Release + if: steps.version_check.outputs.exists == 'false' + uses: softprops/action-gh-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: typescript-sdk-v${{ steps.package_version.outputs.version }} + name: TypeScript SDK v${{ steps.package_version.outputs.version }} + body: | + ## TypeScript SDK v${{ steps.package_version.outputs.version }} + + Published simstudio-ts-sdk@${{ steps.package_version.outputs.version }} to npm. + + ### Installation + ```bash + npm install simstudio-ts-sdk@${{ steps.package_version.outputs.version }} + ``` + + ### Documentation + See the [README](https://github.com/simstudio/sim/tree/main/packages/ts-sdk) for usage instructions. + draft: false + prerelease: false \ No newline at end of file diff --git a/.gitignore b/.gitignore index 33e7b36c9..08dedb867 100644 --- a/.gitignore +++ b/.gitignore @@ -29,7 +29,6 @@ sim-standalone.tar.gz # misc .DS_Store *.pem -uploads/ # env files .env @@ -63,4 +62,7 @@ docker-compose.collector.yml start-collector.sh # Turborepo -.turbo \ No newline at end of file +.turbo + +# VSCode +.vscode \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit index f54fc9cd5..36946c38e 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1 @@ -bunx lint-staged \ No newline at end of file +bun lint \ No newline at end of file diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index feac645b6..876fb6ad0 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -263,3 +263,10 @@ export const SlackIcon = (props: SVGProps) => ( ) + +export const ResponseIcon = (props: SVGProps) => ( + + + + +) diff --git a/apps/docs/components/ui/block-types.tsx b/apps/docs/components/ui/block-types.tsx index 1b2394666..96937dc72 100644 --- a/apps/docs/components/ui/block-types.tsx +++ b/apps/docs/components/ui/block-types.tsx @@ -1,5 +1,13 @@ import { cn } from '@/lib/utils' -import { AgentIcon, ApiIcon, ChartBarIcon, CodeIcon, ConditionalIcon, ConnectIcon } from '../icons' +import { + AgentIcon, + ApiIcon, + ChartBarIcon, + CodeIcon, + ConditionalIcon, + ConnectIcon, + ResponseIcon, +} from '../icons' // Custom Feature component specifically for BlockTypes to handle the 6-item layout const BlockFeature = ({ @@ -127,6 +135,13 @@ export function BlockTypes() { icon: , href: '/blocks/evaluator', }, + { + title: 'Response', + description: + 'Send a response back to the caller with customizable data, status, and headers.', + icon: , + href: '/blocks/response', + }, ] const totalItems = features.length diff --git a/apps/docs/content/docs/blocks/meta.json b/apps/docs/content/docs/blocks/meta.json index 770522e1d..98a69a80e 100644 --- a/apps/docs/content/docs/blocks/meta.json +++ b/apps/docs/content/docs/blocks/meta.json @@ -1,4 +1,4 @@ { "title": "Blocks", - "pages": ["agent", "api", "condition", "function", "evaluator", "router"] + "pages": ["agent", "api", "condition", "function", "evaluator", "router", "response", "workflow"] } diff --git a/apps/docs/content/docs/blocks/response.mdx b/apps/docs/content/docs/blocks/response.mdx new file mode 100644 index 000000000..2570acd87 --- /dev/null +++ b/apps/docs/content/docs/blocks/response.mdx @@ -0,0 +1,188 @@ +--- +title: Response +description: Send a structured response back to API calls +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' +import { ThemeImage } from '@/components/ui/theme-image' + +The Response block is the final component in API-enabled workflows that transforms your workflow's variables into a structured HTTP response. This block serves as the endpoint that returns data, status codes, and headers back to API callers. + + + + + Response blocks are terminal blocks - they mark the end of a workflow execution and cannot have further connections. + + +## Overview + +The Response block serves as the final output mechanism for API workflows, enabling you to: + + + + Return structured data: Transform workflow variables into JSON responses + + + Set HTTP status codes: Control the response status (200, 400, 500, etc.) + + + Configure headers: Add custom HTTP headers to the response + + + Reference variables: Use workflow variables dynamically in the response + + + +## Configuration Options + +### Response Data + +The response data is the main content that will be sent back to the API caller. This should be formatted as JSON and can include: + +- Static values +- Dynamic references to workflow variables using the `` syntax +- Nested objects and arrays +- Any valid JSON structure + +### Status Code + +Set the HTTP status code for the response. Common status codes include: + + + +
    +
  • 200: OK - Standard success response
  • +
  • 201: Created - Resource successfully created
  • +
  • 204: No Content - Success with no response body
  • +
+
+ +
    +
  • 400: Bad Request - Invalid request parameters
  • +
  • 401: Unauthorized - Authentication required
  • +
  • 404: Not Found - Resource doesn't exist
  • +
  • 422: Unprocessable Entity - Validation errors
  • +
+
+ +
    +
  • 500: Internal Server Error - Server-side error
  • +
  • 502: Bad Gateway - External service error
  • +
  • 503: Service Unavailable - Service temporarily down
  • +
+
+
+ +

+ Default status code is 200 if not specified. +

+ +### Response Headers + +Configure additional HTTP headers to include in the response. + +Headers are configured as key-value pairs: + +| Key | Value | +|-----|-------| +| Content-Type | application/json | +| Cache-Control | no-cache | +| X-API-Version | 1.0 | + +## Inputs and Outputs + + + +
    +
  • + data (JSON, optional): The JSON data to send in the response body +
  • +
  • + status (number, optional): HTTP status code (default: 200) +
  • +
  • + headers (JSON, optional): Additional response headers +
  • +
+
+ +
    +
  • + response: Complete response object containing: +
      +
    • data: The response body data
    • +
    • status: HTTP status code
    • +
    • headers: Response headers
    • +
    +
  • +
+
+
+ +## Variable References + +Use the `` syntax to dynamically insert workflow variables into your response: + +```json +{ + "user": { + "id": "", + "name": "", + "email": "" + }, + "query": "", + "results": "", + "totalFound": "", + "processingTime": "ms" +} +``` + + + Variable names are case-sensitive and must match exactly with the variables available in your workflow. + + +## Example Usage + +Here's an example of how a Response block might be configured for a user search API: + +```yaml +data: | + { + "success": true, + "data": { + "users": "", + "pagination": { + "page": "", + "limit": "", + "total": "" + } + }, + "query": { + "searchTerm": "", + "filters": "" + }, + "timestamp": "" + } +status: 200 +headers: + - key: X-Total-Count + value: + - key: Cache-Control + value: public, max-age=300 +``` + +## Best Practices + +- **Use meaningful status codes**: Choose appropriate HTTP status codes that accurately reflect the outcome of the workflow +- **Structure your responses consistently**: Maintain a consistent JSON structure across all your API endpoints for better developer experience +- **Include relevant metadata**: Add timestamps and version information to help with debugging and monitoring +- **Handle errors gracefully**: Use conditional logic in your workflow to set appropriate error responses with descriptive messages +- **Validate variable references**: Ensure all referenced variables exist and contain the expected data types before the Response block executes \ No newline at end of file diff --git a/apps/docs/content/docs/blocks/workflow.mdx b/apps/docs/content/docs/blocks/workflow.mdx new file mode 100644 index 000000000..f45e0ce41 --- /dev/null +++ b/apps/docs/content/docs/blocks/workflow.mdx @@ -0,0 +1,231 @@ +--- +title: Workflow +description: Execute other workflows as reusable components within your current workflow +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' +import { ThemeImage } from '@/components/ui/theme-image' + +The Workflow block allows you to execute other workflows as reusable components within your current workflow. This powerful feature enables modular design, code reuse, and the creation of complex nested workflows that can be composed from smaller, focused workflows. + + + + + Workflow blocks enable modular design by allowing you to compose complex workflows from smaller, reusable components. + + +## Overview + +The Workflow block serves as a bridge between workflows, enabling you to: + + + + Reuse existing workflows: Execute previously created workflows as components within new workflows + + + Create modular designs: Break down complex processes into smaller, manageable workflows + + + Maintain separation of concerns: Keep different business logic isolated in separate workflows + + + Enable team collaboration: Share and reuse workflows across different projects and team members + + + +## How It Works + +The Workflow block: + +1. Takes a reference to another workflow in your workspace +2. Passes input data from the current workflow to the child workflow +3. Executes the child workflow in an isolated context +4. Returns the results back to the parent workflow for further processing + +## Configuration Options + +### Workflow Selection + +Choose which workflow to execute from a dropdown list of available workflows in your workspace. The list includes: + +- All workflows you have access to in the current workspace +- Workflows shared with you by other team members +- Both enabled and disabled workflows (though only enabled workflows can be executed) + +### Input Data + +Define the data to pass to the child workflow: + +- **Single Variable Input**: Select a variable or block output to pass to the child workflow +- **Variable References**: Use `` to reference workflow variables +- **Block References**: Use `` to reference outputs from previous blocks +- **Automatic Mapping**: The selected data is automatically available as `start.response.input` in the child workflow +- **Optional**: The input field is optional - child workflows can run without input data +- **Type Preservation**: Variable types (strings, numbers, objects, etc.) are preserved when passed to the child workflow + +### Examples of Input References + +- `` - Pass a workflow variable +- `` - Pass the result from a previous block +- `` - Pass the original workflow input +- `` - Pass a specific field from an API response + +### Execution Context + +The child workflow executes with: + +- Its own isolated execution context +- Access to the same workspace resources (API keys, environment variables) +- Proper workspace membership and permission checks +- Independent logging and monitoring + +## Safety and Limitations + +To prevent infinite recursion and ensure system stability, the Workflow block includes several safety mechanisms: + + + **Cycle Detection**: The system automatically detects and prevents circular dependencies between workflows to avoid infinite loops. + + +- **Maximum Depth Limit**: Nested workflows are limited to a maximum depth of 10 levels +- **Cycle Detection**: Automatic detection and prevention of circular workflow dependencies +- **Timeout Protection**: Child workflows inherit timeout settings to prevent indefinite execution +- **Resource Limits**: Memory and execution time limits apply to prevent resource exhaustion + +## Inputs and Outputs + + + +
    +
  • + Workflow ID: The identifier of the workflow to execute +
  • +
  • + Input Variable: Variable or block reference to pass to the child workflow (e.g., `` or ``) +
  • +
+
+ +
    +
  • + Response: The complete output from the child workflow execution +
  • +
  • + Child Workflow Name: The name of the executed child workflow +
  • +
  • + Success Status: Boolean indicating whether the child workflow completed successfully +
  • +
  • + Error Information: Details about any errors that occurred during execution +
  • +
  • + Execution Metadata: Information about execution time, resource usage, and performance +
  • +
+
+
+ +## Example Usage + +Here's an example of how a Workflow block might be used to create a modular customer onboarding process: + +### Parent Workflow: Customer Onboarding +```yaml +# Main customer onboarding workflow +blocks: + - type: workflow + name: "Validate Customer Data" + workflowId: "customer-validation-workflow" + input: "" + + - type: workflow + name: "Setup Customer Account" + workflowId: "account-setup-workflow" + input: "" + + - type: workflow + name: "Send Welcome Email" + workflowId: "welcome-email-workflow" + input: "" +``` + +### Child Workflow: Customer Validation +```yaml +# Reusable customer validation workflow +# Access the input data using: start.response.input +blocks: + - type: function + name: "Validate Email" + code: | + const customerData = start.response.input; + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(customerData.email); + + - type: api + name: "Check Credit Score" + url: "https://api.creditcheck.com/score" + method: "POST" + body: "" +``` + +### Variable Reference Examples + +```yaml +# Using workflow variables +input: "" + +# Using block outputs +input: "" + +# Using nested object properties +input: "" + +# Using array elements (if supported by the resolver) +input: "" +``` + +## Access Control and Permissions + +The Workflow block respects workspace permissions and access controls: + +- **Workspace Membership**: Only workflows within the same workspace can be executed +- **Permission Inheritance**: Child workflows inherit the execution permissions of the parent workflow +- **API Key Access**: Child workflows have access to the same API keys and environment variables as the parent +- **User Context**: The execution maintains the original user context for audit and logging purposes + +## Best Practices + +- **Keep workflows focused**: Design child workflows to handle specific, well-defined tasks +- **Minimize nesting depth**: Avoid deeply nested workflow hierarchies for better maintainability +- **Handle errors gracefully**: Implement proper error handling for child workflow failures +- **Document dependencies**: Clearly document which workflows depend on others +- **Version control**: Consider versioning strategies for workflows that are used as components +- **Test independently**: Ensure child workflows can be tested and validated independently +- **Monitor performance**: Be aware that nested workflows can impact overall execution time + +## Common Patterns + +### Microservice Architecture +Break down complex business processes into smaller, focused workflows that can be developed and maintained independently. + +### Reusable Components +Create library workflows for common operations like data validation, email sending, or API integrations that can be reused across multiple projects. + +### Conditional Execution +Use workflow blocks within conditional logic to execute different business processes based on runtime conditions. + +### Parallel Processing +Combine workflow blocks with parallel execution to run multiple child workflows simultaneously for improved performance. + + + When designing modular workflows, think of each workflow as a function with clear inputs, outputs, and a single responsibility. + \ No newline at end of file diff --git a/apps/docs/content/docs/meta.json b/apps/docs/content/docs/meta.json index 953c887da..6b37a4352 100644 --- a/apps/docs/content/docs/meta.json +++ b/apps/docs/content/docs/meta.json @@ -12,7 +12,10 @@ "---Execution---", "execution", "---Advanced---", - "./variables/index" + "./variables/index", + "---SDKs---", + "./sdks/python", + "./sdks/typescript" ], "defaultOpen": true } diff --git a/apps/docs/content/docs/sdks/python.mdx b/apps/docs/content/docs/sdks/python.mdx new file mode 100644 index 000000000..277080da7 --- /dev/null +++ b/apps/docs/content/docs/sdks/python.mdx @@ -0,0 +1,409 @@ +--- +title: Python SDK +description: The official Python SDK for Sim Studio +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Card, Cards } from 'fumadocs-ui/components/card' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' + +The official Python SDK for Sim Studio allows you to execute workflows programmatically from your Python applications. + + + The Python SDK supports Python 3.8+ and provides synchronous workflow execution. All workflow executions are currently synchronous. + + +## Installation + +Install the SDK using pip: + +```bash +pip install simstudio-sdk +``` + +## Quick Start + +Here's a simple example to get you started: + +```python +from simstudio import SimStudioClient + +# Initialize the client +client = SimStudioClient( + api_key="your-api-key-here", + base_url="https://simstudio.ai" # optional, defaults to https://simstudio.ai +) + +# Execute a workflow +try: + result = client.execute_workflow("workflow-id") + print("Workflow executed successfully:", result) +except Exception as error: + print("Workflow execution failed:", error) +``` + +## API Reference + +### SimStudioClient + +#### Constructor + +```python +SimStudioClient(api_key: str, base_url: str = "https://simstudio.ai") +``` + +**Parameters:** +- `api_key` (str): Your Sim Studio API key +- `base_url` (str, optional): Base URL for the Sim Studio API + +#### Methods + +##### execute_workflow() + +Execute a workflow with optional input data. + +```python +result = client.execute_workflow( + "workflow-id", + input_data={"message": "Hello, world!"}, + timeout=30.0 # 30 seconds +) +``` + +**Parameters:** +- `workflow_id` (str): The ID of the workflow to execute +- `input_data` (dict, optional): Input data to pass to the workflow +- `timeout` (float, optional): Timeout in seconds (default: 30.0) + +**Returns:** `WorkflowExecutionResult` + +##### get_workflow_status() + +Get the status of a workflow (deployment status, etc.). + +```python +status = client.get_workflow_status("workflow-id") +print("Is deployed:", status.is_deployed) +``` + +**Parameters:** +- `workflow_id` (str): The ID of the workflow + +**Returns:** `WorkflowStatus` + +##### validate_workflow() + +Validate that a workflow is ready for execution. + +```python +is_ready = client.validate_workflow("workflow-id") +if is_ready: + # Workflow is deployed and ready + pass +``` + +**Parameters:** +- `workflow_id` (str): The ID of the workflow + +**Returns:** `bool` + +##### execute_workflow_sync() + + + Currently, this method is identical to `execute_workflow()` since all executions are synchronous. This method is provided for future compatibility when asynchronous execution is added. + + +Execute a workflow (currently synchronous, same as `execute_workflow()`). + +```python +result = client.execute_workflow_sync( + "workflow-id", + input_data={"data": "some input"}, + timeout=60.0 +) +``` + +**Parameters:** +- `workflow_id` (str): The ID of the workflow to execute +- `input_data` (dict, optional): Input data to pass to the workflow +- `timeout` (float): Timeout for the initial request in seconds + +**Returns:** `WorkflowExecutionResult` + +##### set_api_key() + +Update the API key. + +```python +client.set_api_key("new-api-key") +``` + +##### set_base_url() + +Update the base URL. + +```python +client.set_base_url("https://my-custom-domain.com") +``` + +##### close() + +Close the underlying HTTP session. + +```python +client.close() +``` + +## Data Classes + +### WorkflowExecutionResult + +```python +@dataclass +class WorkflowExecutionResult: + success: bool + output: Optional[Any] = None + error: Optional[str] = None + logs: Optional[List[Any]] = None + metadata: Optional[Dict[str, Any]] = None + trace_spans: Optional[List[Any]] = None + total_duration: Optional[float] = None +``` + +### WorkflowStatus + +```python +@dataclass +class WorkflowStatus: + is_deployed: bool + deployed_at: Optional[str] = None + is_published: bool = False + needs_redeployment: bool = False +``` + +### SimStudioError + +```python +class SimStudioError(Exception): + def __init__(self, message: str, code: Optional[str] = None, status: Optional[int] = None): + super().__init__(message) + self.code = code + self.status = status +``` + +## Examples + +### Basic Workflow Execution + + + + Set up the SimStudioClient with your API key. + + + Check if the workflow is deployed and ready for execution. + + + Run the workflow with your input data. + + + Process the execution result and handle any errors. + + + +```python +import os +from simstudio import SimStudioClient + +client = SimStudioClient(api_key=os.getenv("SIMSTUDIO_API_KEY")) + +def run_workflow(): + try: + # Check if workflow is ready + is_ready = client.validate_workflow("my-workflow-id") + if not is_ready: + raise Exception("Workflow is not deployed or ready") + + # Execute the workflow + result = client.execute_workflow( + "my-workflow-id", + input_data={ + "message": "Process this data", + "user_id": "12345" + } + ) + + if result.success: + print("Output:", result.output) + print("Duration:", result.metadata.get("duration") if result.metadata else None) + else: + print("Workflow failed:", result.error) + + except Exception as error: + print("Error:", error) + +run_workflow() +``` + +### Error Handling + +Handle different types of errors that may occur during workflow execution: + +```python +from simstudio import SimStudioClient, SimStudioError +import os + +client = SimStudioClient(api_key=os.getenv("SIMSTUDIO_API_KEY")) + +def execute_with_error_handling(): + try: + result = client.execute_workflow("workflow-id") + return result + except SimStudioError as error: + if error.code == "UNAUTHORIZED": + print("Invalid API key") + elif error.code == "TIMEOUT": + print("Workflow execution timed out") + elif error.code == "USAGE_LIMIT_EXCEEDED": + print("Usage limit exceeded") + elif error.code == "INVALID_JSON": + print("Invalid JSON in request body") + else: + print(f"Workflow error: {error}") + raise + except Exception as error: + print(f"Unexpected error: {error}") + raise +``` + +### Context Manager Usage + +Use the client as a context manager to automatically handle resource cleanup: + +```python +from simstudio import SimStudioClient +import os + +# Using context manager to automatically close the session +with SimStudioClient(api_key=os.getenv("SIMSTUDIO_API_KEY")) as client: + result = client.execute_workflow("workflow-id") + print("Result:", result) +# Session is automatically closed here +``` + +### Batch Workflow Execution + +Execute multiple workflows efficiently: + +```python +from simstudio import SimStudioClient +import os + +client = SimStudioClient(api_key=os.getenv("SIMSTUDIO_API_KEY")) + +def execute_workflows_batch(workflow_data_pairs): + """Execute multiple workflows with different input data.""" + results = [] + + for workflow_id, input_data in workflow_data_pairs: + try: + # Validate workflow before execution + if not client.validate_workflow(workflow_id): + print(f"Skipping {workflow_id}: not deployed") + continue + + result = client.execute_workflow(workflow_id, input_data) + results.append({ + "workflow_id": workflow_id, + "success": result.success, + "output": result.output, + "error": result.error + }) + + except Exception as error: + results.append({ + "workflow_id": workflow_id, + "success": False, + "error": str(error) + }) + + return results + +# Example usage +workflows = [ + ("workflow-1", {"type": "analysis", "data": "sample1"}), + ("workflow-2", {"type": "processing", "data": "sample2"}), +] + +results = execute_workflows_batch(workflows) +for result in results: + print(f"Workflow {result['workflow_id']}: {'Success' if result['success'] else 'Failed'}") +``` + +### Environment Configuration + +Configure the client using environment variables: + + + + ```python + import os + from simstudio import SimStudioClient + + # Development configuration + client = SimStudioClient( + api_key=os.getenv("SIMSTUDIO_API_KEY"), + base_url=os.getenv("SIMSTUDIO_BASE_URL", "https://simstudio.ai") + ) + ``` + + + ```python + import os + from simstudio import SimStudioClient + + # Production configuration with error handling + api_key = os.getenv("SIMSTUDIO_API_KEY") + if not api_key: + raise ValueError("SIMSTUDIO_API_KEY environment variable is required") + + client = SimStudioClient( + api_key=api_key, + base_url=os.getenv("SIMSTUDIO_BASE_URL", "https://simstudio.ai") + ) + ``` + + + +## Getting Your API Key + + + + Navigate to [Sim Studio](https://simstudio.ai) and log in to your account. + + + Navigate to the workflow you want to execute programmatically. + + + Click on "Deploy" to deploy your workflow if it hasn't been deployed yet. + + + During the deployment process, select or create an API key. + + + Copy the API key to use in your Python application. + + + + + Keep your API key secure and never commit it to version control. Use environment variables or secure configuration management. + + +## Requirements + +- Python 3.8+ +- requests >= 2.25.0 + +## License + +Apache-2.0 \ No newline at end of file diff --git a/apps/docs/content/docs/sdks/typescript.mdx b/apps/docs/content/docs/sdks/typescript.mdx new file mode 100644 index 000000000..6fb4bf4f7 --- /dev/null +++ b/apps/docs/content/docs/sdks/typescript.mdx @@ -0,0 +1,598 @@ +--- +title: TypeScript/JavaScript SDK +description: The official TypeScript/JavaScript SDK for Sim Studio +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Card, Cards } from 'fumadocs-ui/components/card' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' + +The official TypeScript/JavaScript SDK for Sim Studio allows you to execute workflows programmatically from your Node.js applications, web applications, and other JavaScript environments. + + + The TypeScript SDK provides full type safety and supports both Node.js and browser environments. All workflow executions are currently synchronous. + + +## Installation + +Install the SDK using your preferred package manager: + + + + ```bash + npm install simstudio-ts-sdk + ``` + + + ```bash + yarn add simstudio-ts-sdk + ``` + + + ```bash + bun add simstudio-ts-sdk + ``` + + + +## Quick Start + +Here's a simple example to get you started: + +```typescript +import { SimStudioClient } from 'simstudio-ts-sdk'; + +// Initialize the client +const client = new SimStudioClient({ + apiKey: 'your-api-key-here', + baseUrl: 'https://simstudio.ai' // optional, defaults to https://simstudio.ai +}); + +// Execute a workflow +try { + const result = await client.executeWorkflow('workflow-id'); + console.log('Workflow executed successfully:', result); +} catch (error) { + console.error('Workflow execution failed:', error); +} +``` + +## API Reference + +### SimStudioClient + +#### Constructor + +```typescript +new SimStudioClient(config: SimStudioConfig) +``` + +**Configuration:** +- `config.apiKey` (string): Your Sim Studio API key +- `config.baseUrl` (string, optional): Base URL for the Sim Studio API (defaults to `https://simstudio.ai`) + +#### Methods + +##### executeWorkflow() + +Execute a workflow with optional input data. + +```typescript +const result = await client.executeWorkflow('workflow-id', { + input: { message: 'Hello, world!' }, + timeout: 30000 // 30 seconds +}); +``` + +**Parameters:** +- `workflowId` (string): The ID of the workflow to execute +- `options` (ExecutionOptions, optional): + - `input` (any): Input data to pass to the workflow + - `timeout` (number): Timeout in milliseconds (default: 30000) + +**Returns:** `Promise` + +##### getWorkflowStatus() + +Get the status of a workflow (deployment status, etc.). + +```typescript +const status = await client.getWorkflowStatus('workflow-id'); +console.log('Is deployed:', status.isDeployed); +``` + +**Parameters:** +- `workflowId` (string): The ID of the workflow + +**Returns:** `Promise` + +##### validateWorkflow() + +Validate that a workflow is ready for execution. + +```typescript +const isReady = await client.validateWorkflow('workflow-id'); +if (isReady) { + // Workflow is deployed and ready +} +``` + +**Parameters:** +- `workflowId` (string): The ID of the workflow + +**Returns:** `Promise` + +##### executeWorkflowSync() + + + Currently, this method is identical to `executeWorkflow()` since all executions are synchronous. This method is provided for future compatibility when asynchronous execution is added. + + +Execute a workflow (currently synchronous, same as `executeWorkflow()`). + +```typescript +const result = await client.executeWorkflowSync('workflow-id', { + input: { data: 'some input' }, + timeout: 60000 +}); +``` + +**Parameters:** +- `workflowId` (string): The ID of the workflow to execute +- `options` (ExecutionOptions, optional): + - `input` (any): Input data to pass to the workflow + - `timeout` (number): Timeout for the initial request in milliseconds + +**Returns:** `Promise` + +##### setApiKey() + +Update the API key. + +```typescript +client.setApiKey('new-api-key'); +``` + +##### setBaseUrl() + +Update the base URL. + +```typescript +client.setBaseUrl('https://my-custom-domain.com'); +``` + +## Types + +### WorkflowExecutionResult + +```typescript +interface WorkflowExecutionResult { + success: boolean; + output?: any; + error?: string; + logs?: any[]; + metadata?: { + duration?: number; + executionId?: string; + [key: string]: any; + }; + traceSpans?: any[]; + totalDuration?: number; +} +``` + +### WorkflowStatus + +```typescript +interface WorkflowStatus { + isDeployed: boolean; + deployedAt?: string; + isPublished: boolean; + needsRedeployment: boolean; +} +``` + +### SimStudioError + +```typescript +class SimStudioError extends Error { + code?: string; + status?: number; +} +``` + +## Examples + +### Basic Workflow Execution + + + + Set up the SimStudioClient with your API key. + + + Check if the workflow is deployed and ready for execution. + + + Run the workflow with your input data. + + + Process the execution result and handle any errors. + + + +```typescript +import { SimStudioClient } from 'simstudio-ts-sdk'; + +const client = new SimStudioClient({ + apiKey: process.env.SIMSTUDIO_API_KEY! +}); + +async function runWorkflow() { + try { + // Check if workflow is ready + const isReady = await client.validateWorkflow('my-workflow-id'); + if (!isReady) { + throw new Error('Workflow is not deployed or ready'); + } + + // Execute the workflow + const result = await client.executeWorkflow('my-workflow-id', { + input: { + message: 'Process this data', + userId: '12345' + } + }); + + if (result.success) { + console.log('Output:', result.output); + console.log('Duration:', result.metadata?.duration); + } else { + console.error('Workflow failed:', result.error); + } + } catch (error) { + console.error('Error:', error); + } +} + +runWorkflow(); +``` + +### Error Handling + +Handle different types of errors that may occur during workflow execution: + +```typescript +import { SimStudioClient, SimStudioError } from 'simstudio-ts-sdk'; + +const client = new SimStudioClient({ + apiKey: process.env.SIMSTUDIO_API_KEY! +}); + +async function executeWithErrorHandling() { + try { + const result = await client.executeWorkflow('workflow-id'); + return result; + } catch (error) { + if (error instanceof SimStudioError) { + switch (error.code) { + case 'UNAUTHORIZED': + console.error('Invalid API key'); + break; + case 'TIMEOUT': + console.error('Workflow execution timed out'); + break; + case 'USAGE_LIMIT_EXCEEDED': + console.error('Usage limit exceeded'); + break; + case 'INVALID_JSON': + console.error('Invalid JSON in request body'); + break; + default: + console.error('Workflow error:', error.message); + } + } else { + console.error('Unexpected error:', error); + } + throw error; + } +} +``` + +### Environment Configuration + +Configure the client using environment variables: + + + + ```typescript + import { SimStudioClient } from 'simstudio-ts-sdk'; + + // Development configuration + const apiKey = process.env.SIMSTUDIO_API_KEY; + if (!apiKey) { + throw new Error('SIMSTUDIO_API_KEY environment variable is required'); + } + + const client = new SimStudioClient({ + apiKey, + baseUrl: process.env.SIMSTUDIO_BASE_URL // optional + }); + ``` + + + ```typescript + import { SimStudioClient } from 'simstudio-ts-sdk'; + + // Production configuration with validation + const apiKey = process.env.SIMSTUDIO_API_KEY; + if (!apiKey) { + throw new Error('SIMSTUDIO_API_KEY environment variable is required'); + } + + const client = new SimStudioClient({ + apiKey, + baseUrl: process.env.SIMSTUDIO_BASE_URL || 'https://simstudio.ai' + }); + ``` + + + +### Node.js Express Integration + +Integrate with an Express.js server: + +```typescript +import express from 'express'; +import { SimStudioClient } from 'simstudio-ts-sdk'; + +const app = express(); +const client = new SimStudioClient({ + apiKey: process.env.SIMSTUDIO_API_KEY! +}); + +app.use(express.json()); + +app.post('/execute-workflow', async (req, res) => { + try { + const { workflowId, input } = req.body; + + const result = await client.executeWorkflow(workflowId, { + input, + timeout: 60000 + }); + + res.json({ + success: true, + data: result + }); + } catch (error) { + console.error('Workflow execution error:', error); + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +app.listen(3000, () => { + console.log('Server running on port 3000'); +}); +``` + +### Next.js API Route + +Use with Next.js API routes: + +```typescript +// pages/api/workflow.ts or app/api/workflow/route.ts +import { NextApiRequest, NextApiResponse } from 'next'; +import { SimStudioClient } from 'simstudio-ts-sdk'; + +const client = new SimStudioClient({ + apiKey: process.env.SIMSTUDIO_API_KEY! +}); + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + try { + const { workflowId, input } = req.body; + + const result = await client.executeWorkflow(workflowId, { + input, + timeout: 30000 + }); + + res.status(200).json(result); + } catch (error) { + console.error('Error executing workflow:', error); + res.status(500).json({ + error: 'Failed to execute workflow' + }); + } +} +``` + +### Browser Usage + +Use in the browser (with proper CORS configuration): + +```typescript +import { SimStudioClient } from 'simstudio-ts-sdk'; + +// Note: In production, use a proxy server to avoid exposing API keys +const client = new SimStudioClient({ + apiKey: 'your-public-api-key', // Use with caution in browser + baseUrl: 'https://simstudio.ai' +}); + +async function executeClientSideWorkflow() { + try { + const result = await client.executeWorkflow('workflow-id', { + input: { + userInput: 'Hello from browser' + } + }); + + console.log('Workflow result:', result); + + // Update UI with result + document.getElementById('result')!.textContent = + JSON.stringify(result.output, null, 2); + } catch (error) { + console.error('Error:', error); + } +} + +// Attach to button click +document.getElementById('executeBtn')?.addEventListener('click', executeClientSideWorkflow); +``` + + + When using the SDK in the browser, be careful not to expose sensitive API keys. Consider using a backend proxy or public API keys with limited permissions. + + +### React Hook Example + +Create a custom React hook for workflow execution: + +```typescript +import { useState, useCallback } from 'react'; +import { SimStudioClient, WorkflowExecutionResult } from 'simstudio-ts-sdk'; + +const client = new SimStudioClient({ + apiKey: process.env.NEXT_PUBLIC_SIMSTUDIO_API_KEY! +}); + +interface UseWorkflowResult { + result: WorkflowExecutionResult | null; + loading: boolean; + error: Error | null; + executeWorkflow: (workflowId: string, input?: any) => Promise; +} + +export function useWorkflow(): UseWorkflowResult { + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const executeWorkflow = useCallback(async (workflowId: string, input?: any) => { + setLoading(true); + setError(null); + setResult(null); + + try { + const workflowResult = await client.executeWorkflow(workflowId, { + input, + timeout: 30000 + }); + setResult(workflowResult); + } catch (err) { + setError(err instanceof Error ? err : new Error('Unknown error')); + } finally { + setLoading(false); + } + }, []); + + return { + result, + loading, + error, + executeWorkflow + }; +} + +// Usage in component +function WorkflowComponent() { + const { result, loading, error, executeWorkflow } = useWorkflow(); + + const handleExecute = () => { + executeWorkflow('my-workflow-id', { + message: 'Hello from React!' + }); + }; + + return ( +
+ + + {error &&
Error: {error.message}
} + {result && ( +
+

Result:

+
{JSON.stringify(result, null, 2)}
+
+ )} +
+ ); +} +``` + +## Getting Your API Key + + + + Navigate to [Sim Studio](https://simstudio.ai) and log in to your account. + + + Navigate to the workflow you want to execute programmatically. + + + Click on "Deploy" to deploy your workflow if it hasn't been deployed yet. + + + During the deployment process, select or create an API key. + + + Copy the API key to use in your TypeScript/JavaScript application. + + + + + Keep your API key secure and never commit it to version control. Use environment variables or secure configuration management. + + +## Requirements + +- Node.js 16+ +- TypeScript 5.0+ (for TypeScript projects) + +## TypeScript Support + +The SDK is written in TypeScript and provides full type safety: + +```typescript +import { + SimStudioClient, + WorkflowExecutionResult, + WorkflowStatus, + SimStudioError +} from 'simstudio-ts-sdk'; + +// Type-safe client initialization +const client: SimStudioClient = new SimStudioClient({ + apiKey: process.env.SIMSTUDIO_API_KEY! +}); + +// Type-safe workflow execution +const result: WorkflowExecutionResult = await client.executeWorkflow('workflow-id', { + input: { + message: 'Hello, TypeScript!' + } +}); + +// Type-safe status checking +const status: WorkflowStatus = await client.getWorkflowStatus('workflow-id'); +``` + +## License + +Apache-2.0 \ No newline at end of file diff --git a/apps/docs/content/docs/tools/google_calendar.mdx b/apps/docs/content/docs/tools/google_calendar.mdx index e539292d9..1bac14004 100644 --- a/apps/docs/content/docs/tools/google_calendar.mdx +++ b/apps/docs/content/docs/tools/google_calendar.mdx @@ -90,7 +90,7 @@ In Sim Studio, the Google Calendar integration enables your agents to programmat ## Usage Instructions -Integrate Google Calendar functionality to create, read, update, and list calendar events within your workflow. Automate scheduling, check availability, and manage events using OAuth authentication. +Integrate Google Calendar functionality to create, read, update, and list calendar events within your workflow. Automate scheduling, check availability, and manage events using OAuth authentication. Email invitations are sent asynchronously and delivery depends on recipients @@ -180,6 +180,38 @@ Create events from natural language text | --------- | ---- | | `content` | string | +### `google_calendar_invite` + +Invite attendees to an existing Google Calendar event + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessToken` | string | Yes | Access token for Google Calendar API | +| `calendarId` | string | No | Calendar ID \(defaults to primary\) | +| `eventId` | string | Yes | Event ID to invite attendees to | +| `attendees` | array | Yes | Array of attendee email addresses to invite | +| `sendUpdates` | string | No | How to send updates to attendees: all, externalOnly, or none | +| `replaceExisting` | boolean | No | Whether to replace existing attendees or add to them \(defaults to false\) | + +#### Output + +| Parameter | Type | +| --------- | ---- | +| `metadata` | string | +| `htmlLink` | string | +| `status` | string | +| `summary` | string | +| `description` | string | +| `location` | string | +| `start` | string | +| `end` | string | +| `attendees` | string | +| `creator` | string | +| `organizer` | string | +| `content` | string | + ## Block Configuration diff --git a/apps/docs/content/docs/tools/huggingface.mdx b/apps/docs/content/docs/tools/huggingface.mdx new file mode 100644 index 000000000..837884ed4 --- /dev/null +++ b/apps/docs/content/docs/tools/huggingface.mdx @@ -0,0 +1,127 @@ +--- +title: Hugging Face +description: Use Hugging Face Inference API +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + + + + + + + `} +/> + +{/* MANUAL-CONTENT-START:intro */} +[HuggingFace](https://huggingface.co/) is a leading AI platform that provides access to thousands of pre-trained machine learning models and powerful inference capabilities. With its extensive model hub and robust API, HuggingFace offers comprehensive tools for both research and production AI applications. +With HuggingFace, you can: + +Access pre-trained models: Utilize models for text generation, translation, image processing, and more +Generate AI completions: Create content using state-of-the-art language models through the Inference API +Natural language processing: Process and analyze text with specialized NLP models +Deploy at scale: Host and serve models for production applications +Customize models: Fine-tune existing models for specific use cases + +In Sim Studio, the HuggingFace integration enables your agents to programmatically generate completions using the HuggingFace Inference API. This allows for powerful automation scenarios such as content generation, text analysis, code completion, and creative writing. Your agents can generate completions with natural language prompts, access specialized models for different tasks, and integrate AI-generated content into workflows. This integration bridges the gap between your AI workflows and machine learning capabilities, enabling seamless AI-powered automation with one of the world's most comprehensive ML platforms. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Generate completions using Hugging Face Inference API with access to various open-source models. Leverage cutting-edge AI models for chat completions, content generation, and AI-powered conversations with customizable parameters. + + + +## Tools + +### `huggingface_chat` + +Generate completions using Hugging Face Inference API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Hugging Face API token | +| `provider` | string | Yes | The provider to use for the API request \(e.g., novita, cerebras, etc.\) | +| `model` | string | Yes | Model to use for chat completions \(e.g., deepseek/deepseek-v3-0324\) | +| `content` | string | Yes | The user message content to send to the model | +| `systemPrompt` | string | No | System prompt to guide the model behavior | +| `maxTokens` | number | No | Maximum number of tokens to generate | +| `temperature` | number | No | Sampling temperature \(0-2\). Higher values make output more random | +| `stream` | boolean | No | Whether to stream the response | + +#### Output + +| Parameter | Type | +| --------- | ---- | +| `content` | string | +| `model` | string | +| `usage` | string | +| `completion_tokens` | string | +| `total_tokens` | string | + + + +## Block Configuration + +### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `systemPrompt` | string | No | System Prompt - Enter system prompt to guide the model behavior... | + + + +### Outputs + +| Output | Type | Description | +| ------ | ---- | ----------- | +| `response` | object | Output from response | +| ↳ `content` | string | content of the response | +| ↳ `model` | string | model of the response | +| ↳ `usage` | json | usage of the response | + + +## Notes + +- Category: `tools` +- Type: `huggingface` diff --git a/apps/docs/content/docs/tools/knowledge.mdx b/apps/docs/content/docs/tools/knowledge.mdx index 5da46bc00..3424c6242 100644 --- a/apps/docs/content/docs/tools/knowledge.mdx +++ b/apps/docs/content/docs/tools/knowledge.mdx @@ -1,6 +1,6 @@ --- title: Knowledge -description: Search knowledge +description: Use vector search --- import { BlockInfoCard } from "@/components/ui/block-info-card" @@ -49,7 +49,7 @@ In Sim Studio, the Knowledge Base block enables your agents to perform intellige ## Usage Instructions -Perform semantic vector search across your knowledge base to find the most relevant content. Uses advanced AI embeddings to understand meaning and context, returning the most similar documents to your search query. +Perform semantic vector search across one or more knowledge bases or upload new chunks to documents. Uses advanced AI embeddings to understand meaning and context for search operations. @@ -57,13 +57,13 @@ Perform semantic vector search across your knowledge base to find the most relev ### `knowledge_search` -Search for similar content in a knowledge base using vector similarity +Search for similar content in one or more knowledge bases using vector similarity #### Input | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `knowledgeBaseId` | string | Yes | ID of the knowledge base to search in | +| `knowledgeBaseIds` | string | Yes | ID of the knowledge base to search in, or comma-separated IDs for multiple knowledge bases | | `query` | string | Yes | Search query text | | `topK` | number | No | Number of most similar results to return \(1-100\) | @@ -73,10 +73,32 @@ Search for similar content in a knowledge base using vector similarity | --------- | ---- | | `results` | string | | `query` | string | -| `knowledgeBaseId` | string | -| `topK` | string | | `totalResults` | string | -| `message` | string | + +### `knowledge_upload_chunk` + +Upload a new chunk to a document in a knowledge base + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `knowledgeBaseId` | string | Yes | ID of the knowledge base containing the document | +| `documentId` | string | Yes | ID of the document to upload the chunk to | +| `content` | string | Yes | Content of the chunk to upload | + +#### Output + +| Parameter | Type | +| --------- | ---- | +| `data` | string | +| `chunkIndex` | string | +| `content` | string | +| `contentLength` | string | +| `tokenCount` | string | +| `enabled` | string | +| `createdAt` | string | +| `updatedAt` | string | @@ -86,7 +108,7 @@ Search for similar content in a knowledge base using vector similarity | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `knowledgeBaseId` | string | Yes | Knowledge Base - Select knowledge base | +| `operation` | string | Yes | Operation | @@ -97,10 +119,7 @@ Search for similar content in a knowledge base using vector similarity | `response` | object | Output from response | | ↳ `results` | json | results of the response | | ↳ `query` | string | query of the response | -| ↳ `knowledgeBaseId` | string | knowledgeBaseId of the response | -| ↳ `topK` | number | topK of the response | | ↳ `totalResults` | number | totalResults of the response | -| ↳ `message` | string | message of the response | ## Notes diff --git a/apps/docs/content/docs/tools/meta.json b/apps/docs/content/docs/tools/meta.json index 380fb990b..803328796 100644 --- a/apps/docs/content/docs/tools/meta.json +++ b/apps/docs/content/docs/tools/meta.json @@ -19,6 +19,7 @@ "google_search", "google_sheets", "guesty", + "huggingface", "image_generator", "jina", "jira", diff --git a/apps/docs/public/static/dark/response-dark.png b/apps/docs/public/static/dark/response-dark.png new file mode 100644 index 000000000..e52919887 Binary files /dev/null and b/apps/docs/public/static/dark/response-dark.png differ diff --git a/apps/docs/public/static/dark/workflow-dark.png b/apps/docs/public/static/dark/workflow-dark.png new file mode 100644 index 000000000..6a03a4999 Binary files /dev/null and b/apps/docs/public/static/dark/workflow-dark.png differ diff --git a/apps/docs/public/static/light/response-light.png b/apps/docs/public/static/light/response-light.png new file mode 100644 index 000000000..4503b967f Binary files /dev/null and b/apps/docs/public/static/light/response-light.png differ diff --git a/apps/docs/public/static/light/workflow-light.png b/apps/docs/public/static/light/workflow-light.png new file mode 100644 index 000000000..b27376fef Binary files /dev/null and b/apps/docs/public/static/light/workflow-light.png differ diff --git a/apps/sim/.env.example b/apps/sim/.env.example deleted file mode 100644 index fc42b3b54..000000000 --- a/apps/sim/.env.example +++ /dev/null @@ -1,16 +0,0 @@ -# Database (Required) -DATABASE_URL="postgresql://postgres:password@localhost:5432/postgres" - -# Authentication (Required) -BETTER_AUTH_SECRET=your_secret_key # Use `openssl rand -hex 32` to generate, or visit https://www.better-auth.com/docs/installation -BETTER_AUTH_URL=http://localhost:3000 - -## Security (Required) -ENCRYPTION_KEY=your_encryption_key # Use `openssl rand -hex 32` to generate - -# Email Provider (Optional) -# RESEND_API_KEY= # Uncomment and add your key from https://resend.com to send actual emails - # If left commented out, emails will be logged to console instead - -# Freestyle API Key (Required for sandboxed code execution for functions/custom-tools) -# FREESTYLE_API_KEY= # Uncomment and add your key from https://docs.freestyle.sh/Getting-Started/run diff --git a/apps/sim/app/(auth)/login/login-form.test.tsx b/apps/sim/app/(auth)/login/login-form.test.tsx index 481d54633..00bf49df2 100644 --- a/apps/sim/app/(auth)/login/login-form.test.tsx +++ b/apps/sim/app/(auth)/login/login-form.test.tsx @@ -2,7 +2,7 @@ * @vitest-environment jsdom */ -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { useRouter, useSearchParams } from 'next/navigation' import { beforeEach, describe, expect, it, vi } from 'vitest' import { client } from '@/lib/auth-client' @@ -104,7 +104,10 @@ describe('LoginPage', () => { it('should show loading state during form submission', async () => { const mockSignIn = vi.mocked(client.signIn.email) mockSignIn.mockImplementation( - () => new Promise((resolve) => resolve({ data: { user: { id: '1' } }, error: null })) + () => + new Promise((resolve) => + setTimeout(() => resolve({ data: { user: { id: '1' } }, error: null }), 100) + ) ) render() @@ -113,12 +116,16 @@ describe('LoginPage', () => { const passwordInput = screen.getByPlaceholderText(/enter your password/i) const submitButton = screen.getByRole('button', { name: /sign in/i }) - fireEvent.change(emailInput, { target: { value: 'test@example.com' } }) - fireEvent.change(passwordInput, { target: { value: 'password123' } }) - fireEvent.click(submitButton) + await act(async () => { + fireEvent.change(emailInput, { target: { value: 'test@example.com' } }) + fireEvent.change(passwordInput, { target: { value: 'password123' } }) + fireEvent.click(submitButton) + }) - expect(screen.getByText('Signing in...')).toBeInTheDocument() - expect(submitButton).toBeDisabled() + await waitFor(() => { + expect(screen.getByText('Signing in...')).toBeInTheDocument() + expect(submitButton).toBeDisabled() + }) }) }) diff --git a/apps/sim/app/(auth)/login/login-form.tsx b/apps/sim/app/(auth)/login/login-form.tsx index 548d20b05..566aa18cd 100644 --- a/apps/sim/app/(auth)/login/login-form.tsx +++ b/apps/sim/app/(auth)/login/login-form.tsx @@ -5,7 +5,13 @@ import { Eye, EyeOff } from 'lucide-react' import Link from 'next/link' import { useRouter, useSearchParams } from 'next/navigation' import { Button } from '@/components/ui/button' -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { client } from '@/lib/auth-client' @@ -494,11 +500,11 @@ export default function LoginPage({ Reset Password + + Enter your email address and we'll send you a link to reset your password. +
-
- Enter your email address and we'll send you a link to reset your password. -
+ > + {isStreaming ? ( + <> + + + + ) : ( + <> + + + )} -
+ - - - - + + ) diff --git a/apps/sim/app/chat/[subdomain]/components/input/voice-input.tsx b/apps/sim/app/chat/[subdomain]/components/input/voice-input.tsx index 2eda962ca..3092457ec 100644 --- a/apps/sim/app/chat/[subdomain]/components/input/voice-input.tsx +++ b/apps/sim/app/chat/[subdomain]/components/input/voice-input.tsx @@ -43,6 +43,7 @@ interface VoiceInputProps { isListening?: boolean disabled?: boolean large?: boolean + minimal?: boolean } export function VoiceInput({ @@ -50,6 +51,7 @@ export function VoiceInput({ isListening = false, disabled = false, large = false, + minimal = false, }: VoiceInputProps) { const [isSupported, setIsSupported] = useState(false) @@ -68,6 +70,24 @@ export function VoiceInput({ return null } + if (minimal) { + return ( + + + + ) + } + if (large) { return (
@@ -93,21 +113,22 @@ export function VoiceInput({ return (
- {/* Voice Button */} + {/* Voice Button - Now matches send button styling */} - + +
) diff --git a/apps/sim/app/chat/[subdomain]/components/voice-interface/components/particles.tsx b/apps/sim/app/chat/[subdomain]/components/voice-interface/components/particles.tsx index 806f8be3e..ab698f9be 100644 --- a/apps/sim/app/chat/[subdomain]/components/voice-interface/components/particles.tsx +++ b/apps/sim/app/chat/[subdomain]/components/voice-interface/components/particles.tsx @@ -402,37 +402,37 @@ export function ParticlesVisualization({ avgLevel: number ) => { if (isMuted) { - // Muted: dim gray-blue - uniforms.u_red.value = 0.4 - uniforms.u_green.value = 0.4 - uniforms.u_blue.value = 0.6 + // Muted: dim purple-gray + uniforms.u_red.value = 0.25 + uniforms.u_green.value = 0.1 + uniforms.u_blue.value = 0.5 } else if (isProcessingInterruption) { - // Interruption: bright orange/yellow - uniforms.u_red.value = 1.0 - uniforms.u_green.value = 0.7 - uniforms.u_blue.value = 0.2 - } else if (isPlayingAudio) { - // AI speaking: bright blue-purple + // Interruption: bright purple uniforms.u_red.value = 0.6 - uniforms.u_green.value = 0.4 - uniforms.u_blue.value = 1.0 + uniforms.u_green.value = 0.2 + uniforms.u_blue.value = 0.9 + } else if (isPlayingAudio) { + // AI speaking: brand purple (#701FFC) + uniforms.u_red.value = 0.44 + uniforms.u_green.value = 0.12 + uniforms.u_blue.value = 0.99 } else if (isListening && avgLevel > 10) { - // User speaking: bright green-blue with intensity-based variation + // User speaking: lighter purple with intensity-based variation const intensity = Math.min(avgLevel / 50, 1) - uniforms.u_red.value = 0.2 + intensity * 0.3 - uniforms.u_green.value = 0.8 + intensity * 0.2 - uniforms.u_blue.value = 0.6 + intensity * 0.4 + uniforms.u_red.value = 0.35 + intensity * 0.15 + uniforms.u_green.value = 0.1 + intensity * 0.1 + uniforms.u_blue.value = 0.8 + intensity * 0.2 } else if (isStreaming) { - // AI thinking: pulsing purple + // AI thinking: pulsing brand purple const pulse = (Math.sin(elapsedTime * 2) + 1) / 2 - uniforms.u_red.value = 0.7 + pulse * 0.3 - uniforms.u_green.value = 0.3 - uniforms.u_blue.value = 0.9 + pulse * 0.1 + uniforms.u_red.value = 0.35 + pulse * 0.15 + uniforms.u_green.value = 0.08 + pulse * 0.08 + uniforms.u_blue.value = 0.95 + pulse * 0.05 } else { - // Default idle: soft blue-purple - uniforms.u_red.value = 0.8 - uniforms.u_green.value = 0.6 - uniforms.u_blue.value = 1.0 + // Default idle: soft brand purple + uniforms.u_red.value = 0.4 + uniforms.u_green.value = 0.15 + uniforms.u_blue.value = 0.9 } } diff --git a/apps/sim/app/w/[id]/components/control-bar/components/deployment-controls/deployment-controls.tsx b/apps/sim/app/w/[id]/components/control-bar/components/deployment-controls/deployment-controls.tsx index 1dc67e4df..588cc5ec1 100644 --- a/apps/sim/app/w/[id]/components/control-bar/components/deployment-controls/deployment-controls.tsx +++ b/apps/sim/app/w/[id]/components/control-bar/components/deployment-controls/deployment-controls.tsx @@ -1,10 +1,11 @@ 'use client' -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { Loader2, Rocket } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' +import type { WorkspaceUserPermissions } from '@/hooks/use-user-permissions' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowState } from '@/stores/workflows/workflow/types' import { DeployModal } from '../deploy-modal/deploy-modal' @@ -16,6 +17,7 @@ interface DeploymentControlsProps { deployedState: WorkflowState | null isLoadingDeployedState: boolean refetchDeployedState: () => Promise + userPermissions: WorkspaceUserPermissions } export function DeploymentControls({ @@ -25,6 +27,7 @@ export function DeploymentControls({ deployedState, isLoadingDeployedState, refetchDeployedState, + userPermissions, }: DeploymentControlsProps) { const deploymentStatus = useWorkflowRegistry((state) => state.getWorkflowDeploymentStatus(activeWorkflowId) @@ -52,6 +55,31 @@ export function DeploymentControls({ } catch (error) {} } + const canDeploy = userPermissions.canAdmin + const isDisabled = isDeploying || !canDeploy + + const handleDeployClick = useCallback(() => { + if (canDeploy) { + setIsModalOpen(true) + } + }, [canDeploy, setIsModalOpen]) + + const getTooltipText = () => { + if (!canDeploy) { + return 'Admin permissions required to deploy workflows' + } + if (isDeploying) { + return 'Deploying...' + } + if (isDeployed && workflowNeedsRedeployment) { + return 'Workflow changes detected' + } + if (isDeployed) { + return 'Deployment Settings' + } + return 'Deploy as API' + } + return ( <> @@ -60,9 +88,13 @@ export function DeploymentControls({
- - {isDeploying - ? 'Deploying...' - : isDeployed && workflowNeedsRedeployment - ? 'Workflow changes detected' - : isDeployed - ? 'Deployment Settings' - : 'Deploy as API'} - + {getTooltipText()} a + b.charCodeAt(0), 0)) + : connectionId + + // Use the numeric ID to select a color pair from our palette + const colorPair = APP_COLORS[numericId % APP_COLORS.length] + + // Add a slight rotation to the gradient based on connection ID for variety + const rotation = (numericId * 25) % 360 + + return `linear-gradient(${rotation}deg, ${colorPair.from}, ${colorPair.to})` +} + +export function UserAvatar({ + connectionId, + name, + color, + tooltipContent, + size = 'md', + index = 0, +}: AvatarProps) { + // Generate a deterministic gradient for this user based on connection ID + // Or use the provided color if available + const backgroundStyle = useMemo(() => { + if (color) { + // If a color is provided, create a gradient with it + const baseColor = color + const lighterShade = color.startsWith('#') + ? `${color}dd` // Add transparency for a lighter shade effect + : color + const darkerShade = color.startsWith('#') ? color : color + + return `linear-gradient(135deg, ${lighterShade}, ${darkerShade})` + } + // Otherwise, generate a gradient based on connectionId + return generateGradient(connectionId) + }, [connectionId, color]) + + // Determine avatar size + const sizeClass = { + sm: 'h-5 w-5 text-[10px]', + md: 'h-7 w-7 text-xs', + lg: 'h-9 w-9 text-sm', + }[size] + + const initials = name ? name.charAt(0).toUpperCase() : '?' + + const avatarElement = ( +
+ {initials} +
+ ) + + // If tooltip content is provided, wrap in tooltip + if (tooltipContent) { + return ( + + {avatarElement} + + {tooltipContent} + + + ) + } + + return avatarElement +} diff --git a/apps/sim/app/w/[id]/components/control-bar/components/user-avatar-stack/user-avatar-stack.tsx b/apps/sim/app/w/[id]/components/control-bar/components/user-avatar-stack/user-avatar-stack.tsx new file mode 100644 index 000000000..2489fda06 --- /dev/null +++ b/apps/sim/app/w/[id]/components/control-bar/components/user-avatar-stack/user-avatar-stack.tsx @@ -0,0 +1,99 @@ +'use client' + +import { useMemo } from 'react' +import { usePresence } from '../../../../hooks/use-presence' +import { UserAvatar } from './components/user-avatar/user-avatar' + +interface User { + connectionId: string | number + name?: string + color?: string + info?: string +} + +interface UserAvatarStackProps { + users?: User[] + maxVisible?: number + size?: 'sm' | 'md' | 'lg' + className?: string +} + +export function UserAvatarStack({ + users: propUsers, + maxVisible = 3, + size = 'md', + className = '', +}: UserAvatarStackProps) { + // Use presence data if no users are provided via props + const { users: presenceUsers } = usePresence() + const users = propUsers || presenceUsers + + // Memoize the processed users to avoid unnecessary re-renders + const { visibleUsers, overflowCount } = useMemo(() => { + if (users.length === 0) { + return { visibleUsers: [], overflowCount: 0 } + } + + const visible = users.slice(0, maxVisible) + const overflow = Math.max(0, users.length - maxVisible) + + return { + visibleUsers: visible, + overflowCount: overflow, + } + }, [users, maxVisible]) + + // Don't render anything if there are no users + if (users.length === 0) { + return null + } + + // Determine spacing based on size + const spacingClass = { + sm: '-space-x-1', + md: '-space-x-1.5', + lg: '-space-x-2', + }[size] + + return ( +
+ {/* Render visible user avatars */} + {visibleUsers.map((user, index) => ( + +
{user.name}
+ {user.info &&
{user.info}
} +
+ ) : null + } + /> + ))} + + {/* Render overflow indicator if there are more users */} + {overflowCount > 0 && ( + +
+ {overflowCount} more user{overflowCount > 1 ? 's' : ''} +
+
{users.length} total online
+ + } + /> + )} + + ) +} diff --git a/apps/sim/app/w/[id]/components/control-bar/control-bar.tsx b/apps/sim/app/w/[id]/components/control-bar/control-bar.tsx index 9dbcd5aea..5da2dc64d 100644 --- a/apps/sim/app/w/[id]/components/control-bar/control-bar.tsx +++ b/apps/sim/app/w/[id]/components/control-bar/control-bar.tsx @@ -40,13 +40,13 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { useSession } from '@/lib/auth-client' import { createLogger } from '@/lib/logs/console-logger' import { cn } from '@/lib/utils' +import { useUserPermissionsContext } from '@/app/w/components/providers/workspace-permissions-provider' import { useExecutionStore } from '@/stores/execution/store' import { useNotificationStore } from '@/stores/notifications/store' import { usePanelStore } from '@/stores/panel/store' import { useGeneralStore } from '@/stores/settings/general/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' -import { mergeSubblockState } from '@/stores/workflows/utils' import { useWorkflowStore } from '@/stores/workflows/workflow/store' import type { WorkflowState } from '@/stores/workflows/workflow/types' import { @@ -58,6 +58,7 @@ import { DeploymentControls } from './components/deployment-controls/deployment- import { HistoryDropdownItem } from './components/history-dropdown-item/history-dropdown-item' import { MarketplaceModal } from './components/marketplace-modal/marketplace-modal' import { NotificationDropdownItem } from './components/notification-dropdown-item/notification-dropdown-item' +import { UserAvatarStack } from './components/user-avatar-stack/user-avatar-stack' const logger = createLogger('ControlBar') @@ -72,11 +73,15 @@ let usageDataCache = { // Predefined run count options const RUN_COUNT_OPTIONS = [1, 5, 10, 25, 50, 100] +interface ControlBarProps { + hasValidationErrors?: boolean +} + /** * Control bar for managing workflows - handles editing, deletion, deployment, * history, notifications and execution. */ -export function ControlBar() { +export function ControlBar({ hasValidationErrors = false }: ControlBarProps) { const router = useRouter() const { data: session } = useSession() @@ -95,6 +100,7 @@ export function ControlBar() { workflows, updateWorkflow, activeWorkflowId, + activeWorkspaceId, removeWorkflow, duplicateWorkflow, setDeploymentStatus, @@ -103,6 +109,12 @@ export function ControlBar() { const { isExecuting, handleRunWorkflow } = useWorkflowExecution() const { setActiveTab } = usePanelStore() + // Get current workflow and workspace ID for permissions + const currentWorkflow = activeWorkflowId ? workflows[activeWorkflowId] : null + + // User permissions - use stable activeWorkspaceId from registry instead of deriving from currentWorkflow + const userPermissions = useUserPermissionsContext() + // Debug mode state const { isDebugModeEnabled, toggleDebugMode } = useGeneralStore() const { isDebugging, pendingBlocks, handleStepDebug, handleCancelDebug, handleResumeDebug } = @@ -148,19 +160,19 @@ export function ControlBar() { limit: number } | null>(null) + // Shared condition for keyboard shortcut and button disabled state + const isWorkflowBlocked = isExecuting || isMultiRunning || isCancelling || hasValidationErrors + // Register keyboard shortcut for running workflow - useKeyboardShortcuts( - () => { - if (!isExecuting && !isMultiRunning && !isCancelling) { - if (isDebugModeEnabled) { - handleRunWorkflow() - } else { - handleMultipleRuns() - } + useKeyboardShortcuts(() => { + if (!isWorkflowBlocked) { + if (isDebugModeEnabled) { + handleRunWorkflow() + } else { + handleMultipleRuns() } - }, - isExecuting || isMultiRunning || isCancelling - ) + } + }, isWorkflowBlocked) // Get the marketplace data from the workflow registry if available const getMarketplaceData = () => { @@ -279,28 +291,6 @@ export function ControlBar() { activeWorkflowId ? state.workflowValues[activeWorkflowId] : null ) - /** - * Normalize blocks for semantic comparison - only compare what matters functionally - * Ignores: IDs, positions, dimensions, metadata that don't affect workflow logic - * Compares: type, name, subBlock values - */ - const normalizeBlocksForComparison = (blocks: Record) => { - if (!blocks) return [] - - return Object.values(blocks) - .map((block: any) => ({ - type: block.type, - name: block.name, - subBlocks: block.subBlocks || {}, - })) - .sort((a, b) => { - const typeA = a.type || '' - const typeB = b.type || '' - if (typeA !== typeB) return typeA.localeCompare(typeB) - return (a.name || '').localeCompare(b.name || '') - }) - } - useEffect(() => { if (!activeWorkflowId || !deployedState) { setChangeDetected(false) @@ -311,20 +301,25 @@ export function ControlBar() { return } - const currentMergedState = mergeSubblockState(currentBlocks, activeWorkflowId) - - const deployedBlocks = deployedState?.blocks - if (!deployedBlocks) { - setChangeDetected(false) - return + // Use the workflow status API to get accurate change detection + // This uses the same logic as the deployment API (reading from normalized tables) + const checkForChanges = async () => { + try { + const response = await fetch(`/api/workflows/${activeWorkflowId}/status`) + if (response.ok) { + const data = await response.json() + setChangeDetected(data.needsRedeployment || false) + } else { + logger.error('Failed to fetch workflow status:', response.status, response.statusText) + setChangeDetected(false) + } + } catch (error) { + logger.error('Error fetching workflow status:', error) + setChangeDetected(false) + } } - const normalizedCurrentBlocks = normalizeBlocksForComparison(currentMergedState) - const normalizedDeployedBlocks = normalizeBlocksForComparison(deployedBlocks) - - const hasChanges = - JSON.stringify(normalizedCurrentBlocks) !== JSON.stringify(normalizedDeployedBlocks) - setChangeDetected(hasChanges) + checkForChanges() }, [activeWorkflowId, deployedState, currentBlocks, subBlockValues, isLoadingDeployedState]) useEffect(() => { @@ -382,20 +377,18 @@ export function ControlBar() { * Workflow name handlers */ const handleNameClick = () => { - if (activeWorkflowId) { - setEditedName(workflows[activeWorkflowId].name) - setIsEditing(true) - } + if (!userPermissions.canEdit) return + setIsEditing(true) + setEditedName(activeWorkflowId ? workflows[activeWorkflowId]?.name || '' : '') } const handleNameSubmit = () => { - if (activeWorkflowId) { - const trimmedName = editedName.trim() - if (trimmedName && trimmedName !== workflows[activeWorkflowId].name) { - updateWorkflow(activeWorkflowId, { name: trimmedName }) - } - setIsEditing(false) + if (!userPermissions.canEdit) return + + if (editedName.trim() && activeWorkflowId) { + updateWorkflow(activeWorkflowId, { name: editedName.trim() }) } + setIsEditing(false) } const handleNameKeyDown = (e: React.KeyboardEvent) => { @@ -407,23 +400,36 @@ export function ControlBar() { } /** - * Workflow deletion handler + * Handle deleting the current workflow */ const handleDeleteWorkflow = () => { - if (!activeWorkflowId) return + if (!activeWorkflowId || !userPermissions.canEdit) return - // Get remaining workflow IDs - const remainingIds = Object.keys(workflows).filter((id) => id !== activeWorkflowId) + const workflowIds = Object.keys(workflows) + const currentIndex = workflowIds.indexOf(activeWorkflowId) - // Navigate before removing the workflow to avoid any state inconsistencies - if (remainingIds.length > 0) { - router.push(`/w/${remainingIds[0]}`) + // Find the next workflow to navigate to + let nextWorkflowId = null + if (workflowIds.length > 1) { + // Try next workflow, then previous, then any other + if (currentIndex < workflowIds.length - 1) { + nextWorkflowId = workflowIds[currentIndex + 1] + } else if (currentIndex > 0) { + nextWorkflowId = workflowIds[currentIndex - 1] + } else { + nextWorkflowId = workflowIds.find((id) => id !== activeWorkflowId) || null + } + } + + // Navigate to the next workflow or home + if (nextWorkflowId) { + router.push(`/w/${nextWorkflowId}`) } else { router.push('/') } // Remove the workflow from the registry - removeWorkflow(activeWorkflowId) + useWorkflowRegistry.getState().removeWorkflow(activeWorkflowId) } // /** @@ -443,8 +449,19 @@ export function ControlBar() { // setIsMarketplaceModalOpen(true) // } + // Helper function to open subscription settings + const openSubscriptionSettings = () => { + if (typeof window !== 'undefined') { + window.dispatchEvent( + new CustomEvent('open-settings', { + detail: { tab: 'subscription' }, + }) + ) + } + } + /** - * Handle multiple workflow runs + * Handle running workflow multiple times */ const handleMultipleRuns = async () => { if (isExecuting || isMultiRunning || runCount <= 0) return @@ -553,92 +570,124 @@ export function ControlBar() { /** * Handle duplicating the current workflow */ - const handleDuplicateWorkflow = () => { - if (!activeWorkflowId) return + const handleDuplicateWorkflow = async () => { + if (!activeWorkflowId || !userPermissions.canEdit) return - // Duplicate the workflow and get the new ID - const newWorkflowId = duplicateWorkflow(activeWorkflowId) - - if (newWorkflowId) { - // Navigate to the new workflow - router.push(`/w/${newWorkflowId}`) - } + // Duplicate the workflow - no automatic navigation + await duplicateWorkflow(activeWorkflowId) } /** * Render workflow name section (editable/non-editable) */ - const renderWorkflowName = () => ( -
- {isEditing ? ( - setEditedName(e.target.value)} - onBlur={handleNameSubmit} - onKeyDown={handleNameKeyDown} - className='w-[200px] border-none bg-transparent p-0 font-medium text-sm outline-none' - /> - ) : ( -

- {activeWorkflowId ? workflows[activeWorkflowId]?.name : 'Workflow'} -

- )} - {mounted && ( -

- Saved{' '} - {formatDistanceToNow(lastSaved || Date.now(), { - addSuffix: true, - })} -

- )} -
- ) + const renderWorkflowName = () => { + const canEdit = userPermissions.canEdit + + return ( +
+
+ {isEditing ? ( + setEditedName(e.target.value)} + onBlur={handleNameSubmit} + onKeyDown={handleNameKeyDown} + className='w-[200px] border-none bg-transparent p-0 font-medium text-sm outline-none' + /> + ) : ( + + +

+ {activeWorkflowId ? workflows[activeWorkflowId]?.name : 'Workflow'} +

+
+ {!canEdit && ( + Edit permissions required to rename workflows + )} +
+ )} + {mounted && ( +

+ Saved{' '} + {formatDistanceToNow(lastSaved || Date.now(), { + addSuffix: true, + })} +

+ )} +
+ +
+ ) + } /** * Render delete workflow button with confirmation dialog */ - const renderDeleteButton = () => ( - - - - - - - - Delete Workflow - + const renderDeleteButton = () => { + const canEdit = userPermissions.canEdit + const hasMultipleWorkflows = Object.keys(workflows).length > 1 + const isDisabled = !canEdit || !hasMultipleWorkflows - - - Delete Workflow - - Are you sure you want to delete this workflow? This action cannot be undone. - - - - Cancel - - Delete - - - - - ) + const getTooltipText = () => { + if (!canEdit) return 'Admin permission required to delete workflows' + if (!hasMultipleWorkflows) return 'Cannot delete the last workflow' + return 'Delete Workflow' + } + + if (isDisabled) { + return ( + + +
+ +
+
+ {getTooltipText()} +
+ ) + } + + return ( + + + + + + + + {getTooltipText()} + + + + + Delete Workflow + + Are you sure you want to delete this workflow? This action cannot be undone. + + + + Cancel + + Delete + + + + + ) + } /** * Render deploy button with tooltip @@ -651,6 +700,7 @@ export function ControlBar() { deployedState={deployedState} isLoadingDeployedState={isLoadingDeployedState} refetchDeployedState={fetchDeployedState} + userPermissions={userPermissions} /> ) @@ -801,50 +851,73 @@ export function ControlBar() { /** * Render workflow duplicate button */ - const renderDuplicateButton = () => ( - - - - - Duplicate Workflow - - ) + const renderDuplicateButton = () => { + const canEdit = userPermissions.canEdit + + return ( + + + {canEdit ? ( + + ) : ( +
+ +
+ )} +
+ + {canEdit ? 'Duplicate Workflow' : 'Admin permission required to duplicate workflows'} + +
+ ) + } /** * Render auto-layout button */ const renderAutoLayoutButton = () => { const handleAutoLayoutClick = () => { - if (isExecuting || isMultiRunning || isDebugging) { + if (isExecuting || isMultiRunning || isDebugging || !userPermissions.canEdit) { return } window.dispatchEvent(new CustomEvent('trigger-auto-layout')) } + const isDisabled = isExecuting || isMultiRunning || isDebugging || !userPermissions.canEdit + return ( - + {isDisabled ? ( +
+ +
+ ) : ( + + )}
- Auto Layout + + {!userPermissions.canEdit + ? 'Admin permission required to use auto-layout' + : 'Auto Layout'} +
) } @@ -914,7 +987,12 @@ export function ControlBar() { * Render debug mode toggle button */ const renderDebugModeToggle = () => { + const canDebug = userPermissions.canRead // Debug mode now requires only read permissions + const isDisabled = isExecuting || isMultiRunning || !canDebug + const handleToggleDebugMode = () => { + if (!canDebug) return + if (isDebugModeEnabled) { if (!isExecuting) { useExecutionStore.getState().setIsDebugging(false) @@ -927,137 +1005,73 @@ export function ControlBar() { return ( - + {isDisabled ? ( +
+ +
+ ) : ( + + )}
- {isDebugModeEnabled ? 'Disable Debug Mode' : 'Enable Debug Mode'} + {!canDebug + ? 'Read permission required to use debug mode' + : isDebugModeEnabled + ? 'Disable Debug Mode' + : 'Enable Debug Mode'}
) } - // Helper function to open subscription settings - const openSubscriptionSettings = () => { - if (typeof window !== 'undefined') { - window.dispatchEvent( - new CustomEvent('open-settings', { - detail: { tab: 'subscription' }, - }) - ) - } - } - /** * Render run workflow button with multi-run dropdown and cancel button */ - const renderRunButton = () => ( -
- {showRunProgress && isMultiRunning && ( -
- -

- {completedRuns}/{runCount} runs -

-
- )} + const renderRunButton = () => { + const canRun = userPermissions.canRead // Running only requires read permissions + const isLoadingPermissions = userPermissions.isLoading + const isButtonDisabled = isWorkflowBlocked || (!canRun && !isLoadingPermissions) - {/* Show how many blocks have been executed in debug mode if debugging */} - {isDebugging && ( -
-
- Debugging Mode + return ( +
+ {showRunProgress && isMultiRunning && ( +
+ +

+ {completedRuns}/{runCount} runs +

-
- )} + )} - {renderDebugControls()} + {/* Show how many blocks have been executed in debug mode if debugging */} + {isDebugging && ( +
+
+ Debugging Mode +
+
+ )} -
- {/* Main Run/Debug Button */} - - - - - - {usageExceeded ? ( -
-

Usage Limit Exceeded

-

- You've used {usageData?.currentUsage.toFixed(2)}$ of {usageData?.limit}$. Upgrade - your plan to continue. -

-
- ) : ( - <> - {isDebugModeEnabled - ? 'Debug Workflow' - : runCount === 1 - ? 'Run Workflow' - : `Run Workflow ${runCount} times`} - - )} -
-
- - {/* Dropdown Trigger - Only show when not in debug mode and not multi-running */} - {!isDebugModeEnabled && !isMultiRunning && ( - - +
+ {/* Main Run/Debug Button */} + + - - - {RUN_COUNT_OPTIONS.map((count) => ( - setRunCount(count)} - className={cn('justify-center', runCount === count && 'bg-muted')} - > - {count} - - ))} - - - )} - - {/* Cancel Button - Only show when multi-running */} - {isMultiRunning && ( - - - - {runCount > 1 ? 'Cancel Runs' : 'Cancel Run'} + + {hasValidationErrors ? ( +
+

Workflow Has Errors

+

+ Nested subflows are not supported. Remove subflow blocks from inside other + subflow blocks. +

+
+ ) : !canRun && !isLoadingPermissions ? ( + 'Read permission required to run workflows' + ) : usageExceeded ? ( +
+

Usage Limit Exceeded

+

+ You've used {usageData?.currentUsage.toFixed(2)}$ of {usageData?.limit}$. + Upgrade your plan to continue. +

+
+ ) : !canRun && !isLoadingPermissions ? ( + 'Read permissions required to run workflows' + ) : ( + <> + {isDebugModeEnabled + ? 'Debug Workflow' + : runCount === 1 + ? 'Run Workflow' + : `Run Workflow ${runCount} times`} + + )} +
- )} + {renderDebugControls()} + + {/* Dropdown Trigger - Only show when not in debug mode and not multi-running */} + {!isDebugModeEnabled && !isMultiRunning && ( + + + + + + {RUN_COUNT_OPTIONS.map((count) => ( + setRunCount(count)} + className={cn('justify-center', runCount === count && 'bg-muted')} + > + {count} + + ))} + + + )} + + {/* Cancel Button - Only show when multi-running */} + {isMultiRunning && ( + + + + + Cancel Runs + + )} +
-
- ) + ) + } return (
diff --git a/apps/sim/app/w/[id]/components/loop-node/components/loop-badges.tsx b/apps/sim/app/w/[id]/components/loop-node/components/loop-badges.tsx index 123a70a14..a73f9cc39 100644 --- a/apps/sim/app/w/[id]/components/loop-node/components/loop-badges.tsx +++ b/apps/sim/app/w/[id]/components/loop-node/components/loop-badges.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import { ChevronDown } from 'lucide-react' import { highlight, languages } from 'prismjs' import Editor from 'react-simple-code-editor' @@ -7,6 +7,7 @@ import { Input } from '@/components/ui/input' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown' import { cn } from '@/lib/utils' +import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { useWorkflowStore } from '@/stores/workflows/workflow/store' import 'prismjs/components/prism-javascript' import 'prismjs/themes/prism.css' @@ -39,11 +40,25 @@ export function LoopBadges({ nodeId, data }: LoopBadgesProps) { // Check if this is preview mode const isPreview = data?.isPreview || false - // State - const [loopType, setLoopType] = useState(data?.loopType || 'for') - const [iterations, setIterations] = useState(data?.count || 5) - const [inputValue, setInputValue] = useState((data?.count || 5).toString()) - const [editorValue, setEditorValue] = useState('') + // Get loop configuration from the workflow store (single source of truth) + const { loops } = useWorkflowStore() + const loopConfig = loops[nodeId] + + // Use loop config as primary source, fallback to data for backward compatibility + const configIterations = loopConfig?.iterations ?? data?.count ?? 5 + const configLoopType = loopConfig?.loopType ?? data?.loopType ?? 'for' + const configCollection = loopConfig?.forEachItems ?? data?.collection ?? '' + + // Derive values directly from props - no useState needed for synchronized data + const loopType = configLoopType + const iterations = configIterations + const collectionString = + typeof configCollection === 'string' ? configCollection : JSON.stringify(configCollection) || '' + + // Use actual values directly for display, temporary state only for active editing + const [tempInputValue, setTempInputValue] = useState(null) + const inputValue = tempInputValue ?? iterations.toString() + const editorValue = collectionString const [typePopoverOpen, setTypePopoverOpen] = useState(false) const [configPopoverOpen, setConfigPopoverOpen] = useState(false) const [showTagDropdown, setShowTagDropdown] = useState(false) @@ -51,62 +66,23 @@ export function LoopBadges({ nodeId, data }: LoopBadgesProps) { const textareaRef = useRef(null) const editorContainerRef = useRef(null) - // Get store methods - const updateNodeData = useCallback( - (updates: Partial) => { - if (isPreview) return // Don't update in preview mode - - useWorkflowStore.setState((state) => ({ - blocks: { - ...state.blocks, - [nodeId]: { - ...state.blocks[nodeId], - data: { - ...state.blocks[nodeId].data, - ...updates, - }, - }, - }, - })) - }, - [nodeId, isPreview] - ) - - const updateLoopType = useWorkflowStore((state) => state.updateLoopType) - const updateLoopCount = useWorkflowStore((state) => state.updateLoopCount) - const updateLoopCollection = useWorkflowStore((state) => state.updateLoopCollection) - - // Initialize editor value from data when it changes - useEffect(() => { - if (data?.loopType && data.loopType !== loopType) { - setLoopType(data.loopType) - } - if (data?.count && data.count !== iterations) { - setIterations(data.count) - setInputValue(data.count.toString()) - } - - if (loopType === 'forEach' && data?.collection) { - if (typeof data.collection === 'string') { - setEditorValue(data.collection) - } else if (Array.isArray(data.collection) || typeof data.collection === 'object') { - setEditorValue(JSON.stringify(data.collection)) - } - } else if (loopType === 'for') { - setEditorValue('') - } - }, [data?.loopType, data?.count, data?.collection, loopType, iterations]) + // Get collaborative functions + const { + collaborativeUpdateLoopType, + collaborativeUpdateLoopCount, + collaborativeUpdateLoopCollection, + } = useCollaborativeWorkflow() // Handle loop type change const handleLoopTypeChange = useCallback( (newType: 'for' | 'forEach') => { if (isPreview) return // Don't allow changes in preview mode - setLoopType(newType) - updateLoopType(nodeId, newType) + // Update the collaborative state - this will cause the component to re-render with new derived values + collaborativeUpdateLoopType(nodeId, newType) setTypePopoverOpen(false) }, - [nodeId, updateLoopType, isPreview] + [nodeId, collaborativeUpdateLoopType, isPreview] ) // Handle iterations input change @@ -118,9 +94,9 @@ export function LoopBadges({ nodeId, data }: LoopBadgesProps) { const numValue = Number.parseInt(sanitizedValue) if (!Number.isNaN(numValue)) { - setInputValue(Math.min(100, numValue).toString()) + setTempInputValue(Math.min(100, numValue).toString()) } else { - setInputValue(sanitizedValue) + setTempInputValue(sanitizedValue) } }, [isPreview] @@ -134,22 +110,21 @@ export function LoopBadges({ nodeId, data }: LoopBadgesProps) { if (!Number.isNaN(value)) { const newValue = Math.min(100, Math.max(1, value)) - setIterations(newValue) - updateLoopCount(nodeId, newValue) - setInputValue(newValue.toString()) - } else { - setInputValue(iterations.toString()) + // Update the collaborative state - this will cause iterations to be derived from props + collaborativeUpdateLoopCount(nodeId, newValue) } + // Clear temporary input state to show the actual value + setTempInputValue(null) setConfigPopoverOpen(false) - }, [inputValue, iterations, nodeId, updateLoopCount, isPreview]) + }, [inputValue, nodeId, collaborativeUpdateLoopCount, isPreview]) // Handle editor change with tag dropdown support const handleEditorChange = useCallback( (value: string) => { if (isPreview) return // Don't allow changes in preview mode - setEditorValue(value) - updateLoopCollection(nodeId, value) + // Update collaborative state directly - no local state needed + collaborativeUpdateLoopCollection(nodeId, value) // Get the textarea element from the editor const textarea = editorContainerRef.current?.querySelector('textarea') @@ -163,7 +138,7 @@ export function LoopBadges({ nodeId, data }: LoopBadgesProps) { setShowTagDropdown(triggerCheck.show) } }, - [nodeId, updateLoopCollection, isPreview] + [nodeId, collaborativeUpdateLoopCollection, isPreview] ) // Handle tag selection @@ -171,8 +146,8 @@ export function LoopBadges({ nodeId, data }: LoopBadgesProps) { (newValue: string) => { if (isPreview) return // Don't allow changes in preview mode - setEditorValue(newValue) - updateLoopCollection(nodeId, newValue) + // Update collaborative state directly - no local state needed + collaborativeUpdateLoopCollection(nodeId, newValue) setShowTagDropdown(false) // Focus back on the editor after a short delay @@ -183,7 +158,7 @@ export function LoopBadges({ nodeId, data }: LoopBadgesProps) { } }, 0) }, - [nodeId, updateLoopCollection, isPreview] + [nodeId, collaborativeUpdateLoopCollection, isPreview] ) return ( diff --git a/apps/sim/app/w/[id]/components/loop-node/loop-node.tsx b/apps/sim/app/w/[id]/components/loop-node/loop-node.tsx index ae0bb8674..0131e04b7 100644 --- a/apps/sim/app/w/[id]/components/loop-node/loop-node.tsx +++ b/apps/sim/app/w/[id]/components/loop-node/loop-node.tsx @@ -6,7 +6,7 @@ import { StartIcon } from '@/components/icons' import { Button } from '@/components/ui/button' import { Card } from '@/components/ui/card' import { cn } from '@/lib/utils' -import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { LoopBadges } from './components/loop-badges' // Add these styles to your existing global CSS file or create a separate CSS module @@ -69,7 +69,7 @@ const LoopNodeStyles: React.FC = () => { export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => { const { getNodes } = useReactFlow() - const removeBlock = useWorkflowStore((state) => state.removeBlock) + const { collaborativeRemoveBlock } = useCollaborativeWorkflow() const blockRef = useRef(null) // Check if this is preview mode @@ -94,7 +94,7 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => { const getNestedStyles = () => { // Base styles const styles: Record = { - backgroundColor: 'transparent', + backgroundColor: 'rgba(0, 0, 0, 0.02)', } // Apply nested styles @@ -123,7 +123,8 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => { 'z-[20]', data?.state === 'valid', nestingLevel > 0 && - `border border-[0.5px] ${nestingLevel % 2 === 0 ? 'border-slate-300/60' : 'border-slate-400/60'}` + `border border-[0.5px] ${nestingLevel % 2 === 0 ? 'border-slate-300/60' : 'border-slate-400/60'}`, + data?.hasNestedError && 'border-2 border-red-500 bg-red-50/50' )} style={{ width: data.width || 500, @@ -170,7 +171,7 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => { size='sm' onClick={(e) => { e.stopPropagation() - removeBlock(id) + collaborativeRemoveBlock(id) }} className='absolute top-2 right-2 z-20 text-gray-500 opacity-0 transition-opacity duration-200 hover:text-red-600 group-hover:opacity-100' style={{ pointerEvents: 'auto' }} diff --git a/apps/sim/app/w/[id]/components/notifications/notifications.tsx b/apps/sim/app/w/[id]/components/notifications/notifications.tsx index d572da25c..fd8e18cb0 100644 --- a/apps/sim/app/w/[id]/components/notifications/notifications.tsx +++ b/apps/sim/app/w/[id]/components/notifications/notifications.tsx @@ -134,7 +134,10 @@ function DeleteApiConfirmation({ Cancel - + Delete diff --git a/apps/sim/app/w/[id]/components/panel/components/chat/chat.tsx b/apps/sim/app/w/[id]/components/panel/components/chat/chat.tsx index 5c719500c..9bb91ad2a 100644 --- a/apps/sim/app/w/[id]/components/panel/components/chat/chat.tsx +++ b/apps/sim/app/w/[id]/components/panel/components/chat/chat.tsx @@ -5,9 +5,7 @@ import { ArrowUp } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { ScrollArea } from '@/components/ui/scroll-area' -import { buildTraceSpans } from '@/lib/logs/trace-spans' -import type { BlockLog } from '@/executor/types' -import { calculateCost } from '@/providers/utils' +import type { BlockLog, ExecutionResult } from '@/executor/types' import { useExecutionStore } from '@/stores/execution/store' import { useChatStore } from '@/stores/panel/chat/store' import { useConsoleStore } from '@/stores/panel/console/store' @@ -113,189 +111,182 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) { // Check if we got a streaming response if (result && 'stream' in result && result.stream instanceof ReadableStream) { - // Generate a unique ID for the message - const messageId = crypto.randomUUID() + const messageIdMap = new Map() - // Create a content buffer to collect initial content - let initialContent = '' - let fullContent = '' // Store the complete content for updating logs later - let hasAddedMessage = false - const executionResult = (result as any).execution // Store the execution result with type assertion - - try { - // Process the stream - const reader = result.stream.getReader() - const decoder = new TextDecoder() - - console.log('Starting to read from stream') + const reader = result.stream.getReader() + const decoder = new TextDecoder() + const processStream = async () => { while (true) { - try { - const { done, value } = await reader.read() - if (done) { - console.log('Stream complete') - break - } - - // Decode and append chunk - const chunk = decoder.decode(value, { stream: true }) // Use stream option - - if (chunk) { - initialContent += chunk - fullContent += chunk - - // Only add the message to UI once we have some actual content to show - if (!hasAddedMessage && initialContent.trim().length > 0) { - // Add message with initial content - cast to any to bypass type checking for id - addMessage({ - content: initialContent, - workflowId: activeWorkflowId, - type: 'workflow', - isStreaming: true, - id: messageId, - } as any) - hasAddedMessage = true - } else if (hasAddedMessage) { - // Append to existing message - appendMessageContent(messageId, chunk) - } - } - } catch (streamError) { - console.error('Error reading from stream:', streamError) - // Break the loop on error + const { done, value } = await reader.read() + if (done) { + // Finalize all streaming messages + messageIdMap.forEach((id) => finalizeMessageStream(id)) break } - } - // If we never added a message (no content received), add it now - if (!hasAddedMessage && initialContent.trim().length > 0) { - addMessage({ - content: initialContent, - workflowId: activeWorkflowId, - type: 'workflow', - id: messageId, - } as any) - } + const chunk = decoder.decode(value) + const lines = chunk.split('\n\n') - // Update logs with the full streaming content if available - if (executionResult && fullContent.trim().length > 0) { - try { - // Format the final content properly to match what's shown for manual executions - // Include all the markdown and formatting from the streamed response - const formattedContent = fullContent + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const json = JSON.parse(line.substring(6)) + const { blockId, chunk: contentChunk, event, data } = json - // Calculate cost based on token usage if available - let costData: any + if (event === 'final' && data) { + const result = data as ExecutionResult + const nonStreamingLogs = + result.logs?.filter((log) => !messageIdMap.has(log.blockId)) || [] - if (executionResult.output?.response?.tokens) { - const tokens = executionResult.output.response.tokens - const model = executionResult.output?.response?.model || 'gpt-4o' - const cost = calculateCost( - model, - tokens.prompt || 0, - tokens.completion || 0, - false // Don't use cached input for chat responses - ) - costData = { ...cost, model } as any - } - - // Build trace spans and total duration before persisting - const { traceSpans, totalDuration } = buildTraceSpans(executionResult as any) - - // Create a completed execution ID - const completedExecutionId = - executionResult.metadata?.executionId || crypto.randomUUID() - - // Import the workflow execution hook for direct access to the workflow service - const workflowExecutionApi = await fetch(`/api/workflows/${activeWorkflowId}/log`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - executionId: completedExecutionId, - result: { - ...executionResult, - output: { - ...executionResult.output, - response: { - ...executionResult.output?.response, - content: formattedContent, - model: executionResult.output?.response?.model, - tokens: executionResult.output?.response?.tokens, - toolCalls: executionResult.output?.response?.toolCalls, - providerTiming: executionResult.output?.response?.providerTiming, - cost: costData || executionResult.output?.response?.cost, - }, - }, - cost: costData, - // Update the message to include the formatted content - logs: (executionResult.logs || []).map((log: BlockLog) => { - // Check if this is the streaming block by comparing with the selected output IDs - // Selected output IDs typically include the block ID we are streaming from - const isStreamingBlock = selectedOutputs.some( - (outputId) => - outputId === log.blockId || outputId.startsWith(`${log.blockId}_`) + if (nonStreamingLogs.length > 0) { + const outputsToRender = selectedOutputs.filter((outputId) => + nonStreamingLogs.some((log) => log.blockId === outputId.split('.')[0]) ) - if (isStreamingBlock && log.blockType === 'agent' && log.output?.response) { - return { - ...log, - output: { - ...log.output, - response: { - ...log.output.response, - content: formattedContent, - providerTiming: log.output.response.providerTiming, - cost: costData || log.output.response.cost, - }, - }, + for (const outputId of outputsToRender) { + const blockIdForOutput = outputId.split('.')[0] + const path = outputId.substring(blockIdForOutput.length + 1) + const log = nonStreamingLogs.find((l) => l.blockId === blockIdForOutput) + + if (log) { + let outputValue: any = log.output + if (path) { + const pathParts = path.split('.') + for (const part of pathParts) { + if ( + outputValue && + typeof outputValue === 'object' && + part in outputValue + ) { + outputValue = outputValue[part] + } else { + outputValue = undefined + break + } + } + } + if (outputValue !== undefined) { + addMessage({ + content: + typeof outputValue === 'string' + ? outputValue + : `\`\`\`json\n${JSON.stringify(outputValue, null, 2)}\n\`\`\``, + workflowId: activeWorkflowId, + type: 'workflow', + }) + } } } - return log - }), - metadata: { - ...executionResult.metadata, - source: 'chat', - completedAt: new Date().toISOString(), - isStreamingComplete: true, - cost: costData || executionResult.metadata?.cost, - providerTiming: executionResult.output?.response?.providerTiming, - }, - traceSpans: traceSpans, - totalDuration: totalDuration, - }, - }), - }) - - if (!workflowExecutionApi.ok) { - console.error('Failed to log complete streaming execution') + } + } else if (blockId && contentChunk) { + if (!messageIdMap.has(blockId)) { + const newMessageId = crypto.randomUUID() + messageIdMap.set(blockId, newMessageId) + addMessage({ + id: newMessageId, + content: contentChunk, + workflowId: activeWorkflowId, + type: 'workflow', + isStreaming: true, + }) + } else { + const existingMessageId = messageIdMap.get(blockId) + if (existingMessageId) { + appendMessageContent(existingMessageId, contentChunk) + } + } + } else if (blockId && event === 'end') { + const existingMessageId = messageIdMap.get(blockId) + if (existingMessageId) { + finalizeMessageStream(existingMessageId) + } + } + } catch (e) { + console.error('Error parsing stream data:', e) + } } - } catch (logError) { - console.error('Error logging complete streaming execution:', logError) } } - } catch (error) { - console.error('Error processing stream:', error) + } - // If there's an error and we haven't added a message yet, add an error message - if (!hasAddedMessage) { - addMessage({ - content: 'Error: Failed to process the streaming response.', - workflowId: activeWorkflowId, - type: 'workflow', - id: messageId, - } as any) - } else { - // Otherwise append the error to the existing message - appendMessageContent(messageId, '\n\nError: Failed to process the streaming response.') - } - } finally { - console.log('Finalizing stream') - if (hasAddedMessage) { - finalizeMessageStream(messageId) + processStream().catch((e) => console.error('Error processing stream:', e)) + } else if (result && 'success' in result && result.success && 'logs' in result) { + const finalOutputs: any[] = [] + + if (selectedOutputs && selectedOutputs.length > 0) { + for (const outputId of selectedOutputs) { + // Find the log that corresponds to the start of the outputId + const log = result.logs?.find( + (l: BlockLog) => l.blockId === outputId || outputId.startsWith(`${l.blockId}_`) + ) + + if (log) { + let output = log.output + // Check if there is a path to traverse + if (outputId.length > log.blockId.length) { + const path = outputId.substring(log.blockId.length + 1) + if (path) { + const pathParts = path.split('.') + let current = output + for (const part of pathParts) { + if (current && typeof current === 'object' && part in current) { + current = current[part] + } else { + current = undefined + break + } + } + output = current + } + } + if (output !== undefined) { + finalOutputs.push(output) + } + } } } + + // If no specific outputs could be resolved, fall back to the final workflow output + if (finalOutputs.length === 0 && result.output) { + finalOutputs.push(result.output) + } + + // Add a new message for each resolved output + finalOutputs.forEach((output) => { + let content = '' + if (typeof output === 'string') { + content = output + } else if (output && typeof output === 'object') { + // Handle cases where output is { response: ... } + const outputObj = output as Record + const response = outputObj.response + if (response) { + if (typeof response.content === 'string') { + content = response.content + } else { + // Pretty print for better readability + content = `\`\`\`json\n${JSON.stringify(response, null, 2)}\n\`\`\`` + } + } else { + content = `\`\`\`json\n${JSON.stringify(output, null, 2)}\n\`\`\`` + } + } + + if (content) { + addMessage({ + content, + workflowId: activeWorkflowId, + type: 'workflow', + }) + } + }) + } else if (result && 'success' in result && !result.success) { + addMessage({ + content: `Error: ${'error' in result ? result.error : 'Workflow execution failed.'}`, + workflowId: activeWorkflowId, + type: 'workflow', + }) } } diff --git a/apps/sim/app/w/[id]/components/panel/components/console/components/console-entry/console-entry.tsx b/apps/sim/app/w/[id]/components/panel/components/console/components/console-entry/console-entry.tsx index de1364368..99adc458e 100644 --- a/apps/sim/app/w/[id]/components/panel/components/console/components/console-entry/console-entry.tsx +++ b/apps/sim/app/w/[id]/components/panel/components/console/components/console-entry/console-entry.tsx @@ -4,7 +4,6 @@ import { AlertCircle, AlertTriangle, Calendar, - CheckCircle2, ChevronDown, ChevronUp, Clock, @@ -66,14 +65,6 @@ export function ConsoleEntry({ entry, consoleWidth }: ConsoleEntryProps) { const BlockIcon = blockConfig?.icon - const _statusIcon = entry.error ? ( - - ) : entry.warning ? ( - - ) : ( - - ) - // Helper function to check if data has nested objects or arrays const hasNestedStructure = (data: any): boolean => { if (data === null || typeof data !== 'object') return false @@ -93,9 +84,9 @@ export function ConsoleEntry({ entry, consoleWidth }: ConsoleEntryProps) { return (
!entry.error && !entry.warning && setIsExpanded(!isExpanded)} + onClick={() => !entry.error && !entry.warning && entry.success && setIsExpanded(!isExpanded)} >
- {format(new Date(entry.startedAt), 'HH:mm:ss')} + {entry.startedAt ? format(new Date(entry.startedAt), 'HH:mm:ss') : 'N/A'}
- Duration: {entry.durationMs}ms + Duration: {entry.durationMs ?? 0}ms
diff --git a/apps/sim/app/w/[id]/components/parallel-node/components/parallel-badges.tsx b/apps/sim/app/w/[id]/components/parallel-node/components/parallel-badges.tsx index 9b95a0771..de57ce420 100644 --- a/apps/sim/app/w/[id]/components/parallel-node/components/parallel-badges.tsx +++ b/apps/sim/app/w/[id]/components/parallel-node/components/parallel-badges.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import { ChevronDown } from 'lucide-react' import { highlight, languages } from 'prismjs' import Editor from 'react-simple-code-editor' @@ -7,6 +7,7 @@ import { Input } from '@/components/ui/input' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown' import { cn } from '@/lib/utils' +import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { useWorkflowStore } from '@/stores/workflows/workflow/store' import 'prismjs/components/prism-javascript' import 'prismjs/themes/prism.css' @@ -39,13 +40,29 @@ export function ParallelBadges({ nodeId, data }: ParallelBadgesProps) { // Check if this is preview mode const isPreview = data?.isPreview || false - // State - const [parallelType, setParallelType] = useState<'count' | 'collection'>( - data?.parallelType || 'collection' - ) - const [iterations, setIterations] = useState(data?.count || 5) - const [inputValue, setInputValue] = useState((data?.count || 5).toString()) - const [editorValue, setEditorValue] = useState('') + // Get parallel configuration from the workflow store (single source of truth) + const { parallels } = useWorkflowStore() + const parallelConfig = parallels[nodeId] + + // Use parallel config as primary source, fallback to data for backward compatibility + const configCount = parallelConfig?.count ?? data?.count ?? 5 + const configDistribution = parallelConfig?.distribution ?? data?.collection ?? '' + // For parallel type, use the block's parallelType data property as the source of truth + // Don't infer it from whether distribution exists, as that causes unwanted switching + const configParallelType = data?.parallelType || 'collection' + + // Derive values directly from props - no useState needed for synchronized data + const parallelType = configParallelType + const iterations = configCount + const distributionString = + typeof configDistribution === 'string' + ? configDistribution + : JSON.stringify(configDistribution) || '' + + // Use actual values directly for display, temporary state only for active editing + const [tempInputValue, setTempInputValue] = useState(null) + const inputValue = tempInputValue ?? iterations.toString() + const editorValue = distributionString const [typePopoverOpen, setTypePopoverOpen] = useState(false) const [configPopoverOpen, setConfigPopoverOpen] = useState(false) const [showTagDropdown, setShowTagDropdown] = useState(false) @@ -53,78 +70,24 @@ export function ParallelBadges({ nodeId, data }: ParallelBadgesProps) { const editorContainerRef = useRef(null) const textareaRef = useRef(null) - // Get store methods - const updateParallelCount = useWorkflowStore((state) => state.updateParallelCount) - const updateParallelCollection = useWorkflowStore((state) => state.updateParallelCollection) - - // Update node data to include parallel type - const updateNodeData = useCallback( - (updates: Partial) => { - if (isPreview) return // Don't update in preview mode - - useWorkflowStore.setState((state) => ({ - blocks: { - ...state.blocks, - [nodeId]: { - ...state.blocks[nodeId], - data: { - ...state.blocks[nodeId].data, - ...updates, - }, - }, - }, - })) - }, - [nodeId, isPreview] - ) - - // Initialize state from data when it changes - useEffect(() => { - if (data?.parallelType && data.parallelType !== parallelType) { - setParallelType(data.parallelType) - } - if (data?.count && data.count !== iterations) { - setIterations(data.count) - setInputValue(data.count.toString()) - } - - if (data?.collection) { - if (typeof data.collection === 'string') { - setEditorValue(data.collection) - } else if (Array.isArray(data.collection) || typeof data.collection === 'object') { - setEditorValue(JSON.stringify(data.collection)) - } - } - }, [data?.parallelType, data?.count, data?.collection, parallelType, iterations]) + // Get collaborative functions + const { + collaborativeUpdateParallelCount, + collaborativeUpdateParallelCollection, + collaborativeUpdateParallelType, + } = useCollaborativeWorkflow() // Handle parallel type change const handleParallelTypeChange = useCallback( (newType: 'count' | 'collection') => { if (isPreview) return // Don't allow changes in preview mode - setParallelType(newType) - updateNodeData({ parallelType: newType }) - - // Reset values based on type - if (newType === 'count') { - updateParallelCollection(nodeId, '') - updateParallelCount(nodeId, iterations) - } else { - updateParallelCount(nodeId, 1) - updateParallelCollection(nodeId, editorValue || '[]') - } + // Use single collaborative function that handles all the state changes atomically + collaborativeUpdateParallelType(nodeId, newType) setTypePopoverOpen(false) }, - [ - nodeId, - iterations, - editorValue, - updateNodeData, - updateParallelCount, - updateParallelCollection, - isPreview, - ] + [nodeId, collaborativeUpdateParallelType, isPreview] ) // Handle iterations input change @@ -136,9 +99,9 @@ export function ParallelBadges({ nodeId, data }: ParallelBadgesProps) { const numValue = Number.parseInt(sanitizedValue) if (!Number.isNaN(numValue)) { - setInputValue(Math.min(20, numValue).toString()) + setTempInputValue(Math.min(20, numValue).toString()) } else { - setInputValue(sanitizedValue) + setTempInputValue(sanitizedValue) } }, [isPreview] @@ -152,22 +115,21 @@ export function ParallelBadges({ nodeId, data }: ParallelBadgesProps) { if (!Number.isNaN(value)) { const newValue = Math.min(20, Math.max(1, value)) - setIterations(newValue) - updateParallelCount(nodeId, newValue) - setInputValue(newValue.toString()) - } else { - setInputValue(iterations.toString()) + // Update the collaborative state - this will cause iterations to be derived from props + collaborativeUpdateParallelCount(nodeId, newValue) } + // Clear temporary input state to show the actual value + setTempInputValue(null) setConfigPopoverOpen(false) - }, [inputValue, iterations, nodeId, updateParallelCount, isPreview]) + }, [inputValue, nodeId, collaborativeUpdateParallelCount, isPreview]) // Handle editor change and check for tag trigger const handleEditorChange = useCallback( (value: string) => { if (isPreview) return // Don't allow changes in preview mode - setEditorValue(value) - updateParallelCollection(nodeId, value) + // Update collaborative state directly - no local state needed + collaborativeUpdateParallelCollection(nodeId, value) // Get the textarea element and cursor position const textarea = editorContainerRef.current?.querySelector('textarea') @@ -181,7 +143,7 @@ export function ParallelBadges({ nodeId, data }: ParallelBadgesProps) { setShowTagDropdown(tagTrigger.show) } }, - [nodeId, updateParallelCollection, isPreview] + [nodeId, collaborativeUpdateParallelCollection, isPreview] ) // Handle tag selection @@ -189,8 +151,8 @@ export function ParallelBadges({ nodeId, data }: ParallelBadgesProps) { (newValue: string) => { if (isPreview) return // Don't allow changes in preview mode - setEditorValue(newValue) - updateParallelCollection(nodeId, newValue) + // Update collaborative state directly - no local state needed + collaborativeUpdateParallelCollection(nodeId, newValue) setShowTagDropdown(false) // Focus back on the editor after selection @@ -201,7 +163,7 @@ export function ParallelBadges({ nodeId, data }: ParallelBadgesProps) { } }, 0) }, - [nodeId, updateParallelCollection, isPreview] + [nodeId, collaborativeUpdateParallelCollection, isPreview] ) // Handle key events diff --git a/apps/sim/app/w/[id]/components/parallel-node/parallel-config.ts b/apps/sim/app/w/[id]/components/parallel-node/parallel-config.ts index a1a79eb29..fe00c935b 100644 --- a/apps/sim/app/w/[id]/components/parallel-node/parallel-config.ts +++ b/apps/sim/app/w/[id]/components/parallel-node/parallel-config.ts @@ -6,7 +6,7 @@ export const ParallelTool = { name: 'Parallel', description: 'Parallel Execution', icon: SplitIcon, - bgColor: '#8BC34A', + bgColor: '#FEE12B', data: { label: 'Parallel', parallelType: 'collection' as 'collection' | 'count', diff --git a/apps/sim/app/w/[id]/components/parallel-node/parallel-node.test.tsx b/apps/sim/app/w/[id]/components/parallel-node/parallel-node.test.tsx index 260a69179..a6bf26b5f 100644 --- a/apps/sim/app/w/[id]/components/parallel-node/parallel-node.test.tsx +++ b/apps/sim/app/w/[id]/components/parallel-node/parallel-node.test.tsx @@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { useWorkflowStore } from '@/stores/workflows/workflow/store' import { ParallelNodeComponent } from './parallel-node' -// Mock dependencies that don't need DOM vi.mock('@/stores/workflows/workflow/store', () => ({ useWorkflowStore: vi.fn(), })) @@ -16,7 +15,6 @@ vi.mock('@/lib/logs/console-logger', () => ({ })), })) -// Mock ReactFlow components and hooks vi.mock('reactflow', () => ({ Handle: ({ id, type, position }: any) => ({ id, type, position }), Position: { @@ -32,7 +30,6 @@ vi.mock('reactflow', () => ({ memo: (component: any) => component, })) -// Mock React hooks vi.mock('react', async () => { const actual = await vi.importActual('react') return { @@ -43,7 +40,6 @@ vi.mock('react', async () => { } }) -// Mock UI components vi.mock('@/components/ui/button', () => ({ Button: ({ children, onClick, ...props }: any) => ({ children, onClick, ...props }), })) @@ -52,15 +48,21 @@ vi.mock('@/components/ui/card', () => ({ Card: ({ children, ...props }: any) => ({ children, ...props }), })) -vi.mock('@/components/icons', () => ({ - StartIcon: ({ className }: any) => ({ className }), +vi.mock('@/blocks/registry', () => ({ + getBlock: vi.fn(() => ({ + name: 'Mock Block', + description: 'Mock block description', + icon: () => null, + subBlocks: [], + outputs: {}, + })), + getAllBlocks: vi.fn(() => ({})), })) vi.mock('@/lib/utils', () => ({ cn: (...classes: any[]) => classes.filter(Boolean).join(' '), })) -// Mock the ParallelBadges component vi.mock('./components/parallel-badges', () => ({ ParallelBadges: ({ parallelId }: any) => ({ parallelId }), })) @@ -87,8 +89,6 @@ describe('ParallelNodeComponent', () => { beforeEach(() => { vi.clearAllMocks() - // Mock useWorkflowStore - ;(useWorkflowStore as any).mockImplementation((selector: any) => { const state = { removeBlock: mockRemoveBlock, @@ -96,54 +96,33 @@ describe('ParallelNodeComponent', () => { return selector(state) }) - // Mock getNodes mockGetNodes.mockReturnValue([]) }) describe('Component Definition and Structure', () => { - it('should be defined as a function component', () => { + it.concurrent('should be defined as a function component', () => { expect(ParallelNodeComponent).toBeDefined() expect(typeof ParallelNodeComponent).toBe('function') }) - it('should have correct display name', () => { + it.concurrent('should have correct display name', () => { expect(ParallelNodeComponent.displayName).toBe('ParallelNodeComponent') }) - it('should be a memoized component', () => { - // Since we mocked memo to return the component as-is, we can verify it exists + it.concurrent('should be a memoized component', () => { expect(ParallelNodeComponent).toBeDefined() }) }) describe('Props Validation and Type Safety', () => { - it('should accept NodeProps interface', () => { - // Test that the component accepts the correct prop types - const validProps = { - id: 'test-id', - type: 'parallelNode' as const, - data: { - width: 400, - height: 300, - state: 'valid' as const, - }, - selected: false, - zIndex: 1, - isConnectable: true, - xPos: 0, - yPos: 0, - dragging: false, - } - - // This tests that TypeScript compilation succeeds with these props + it.concurrent('should accept NodeProps interface', () => { expect(() => { - // We're not calling the component, just verifying the types const _component: typeof ParallelNodeComponent = ParallelNodeComponent expect(_component).toBeDefined() }).not.toThrow() }) - it('should handle different data configurations', () => { + it.concurrent('should handle different data configurations', () => { const configurations = [ { width: 500, height: 300, state: 'valid' }, { width: 800, height: 600, state: 'invalid' }, @@ -162,11 +141,9 @@ describe('ParallelNodeComponent', () => { }) describe('Store Integration', () => { - it('should integrate with workflow store', () => { - // Test that the component uses the store correctly + it.concurrent('should integrate with workflow store', () => { expect(useWorkflowStore).toBeDefined() - // Verify the store selector function works const mockState = { removeBlock: mockRemoveBlock } const selector = vi.fn((state) => state.removeBlock) @@ -177,19 +154,17 @@ describe('ParallelNodeComponent', () => { expect(selector(mockState)).toBe(mockRemoveBlock) }) - it('should handle removeBlock function', () => { + it.concurrent('should handle removeBlock function', () => { expect(mockRemoveBlock).toBeDefined() expect(typeof mockRemoveBlock).toBe('function') - // Test calling removeBlock mockRemoveBlock('test-id') expect(mockRemoveBlock).toHaveBeenCalledWith('test-id') }) }) describe('Component Logic Tests', () => { - it('should handle nesting level calculation logic', () => { - // Test the nesting level calculation logic (same as loop node) + it.concurrent('should handle nesting level calculation logic', () => { const testCases = [ { nodes: [], parentId: undefined, expectedLevel: 0 }, { nodes: [{ id: 'parent', data: {} }], parentId: 'parent', expectedLevel: 1 }, @@ -206,7 +181,6 @@ describe('ParallelNodeComponent', () => { testCases.forEach(({ nodes, parentId, expectedLevel }) => { mockGetNodes.mockReturnValue(nodes) - // Simulate the nesting level calculation logic let level = 0 let currentParentId = parentId @@ -221,8 +195,7 @@ describe('ParallelNodeComponent', () => { }) }) - it('should handle nested styles generation for parallel nodes', () => { - // Test the nested styles logic with parallel-specific colors + it.concurrent('should handle nested styles generation for parallel nodes', () => { const testCases = [ { nestingLevel: 0, state: 'valid', expectedBg: 'rgba(254,225,43,0.05)' }, { nestingLevel: 0, state: 'invalid', expectedBg: 'transparent' }, @@ -231,7 +204,6 @@ describe('ParallelNodeComponent', () => { ] testCases.forEach(({ nestingLevel, state, expectedBg }) => { - // Simulate the getNestedStyles logic for parallel nodes const styles: Record = { backgroundColor: state === 'valid' ? 'rgba(254,225,43,0.05)' : 'transparent', } @@ -248,14 +220,13 @@ describe('ParallelNodeComponent', () => { }) describe('Parallel-Specific Features', () => { - it('should handle parallel execution states', () => { + it.concurrent('should handle parallel execution states', () => { const parallelStates = ['valid', 'invalid', 'executing', 'completed', 'pending'] parallelStates.forEach((state) => { const data = { width: 500, height: 300, state } expect(data.state).toBe(state) - // Test parallel-specific state handling const isExecuting = state === 'executing' const isCompleted = state === 'completed' @@ -264,8 +235,7 @@ describe('ParallelNodeComponent', () => { }) }) - it('should handle parallel node color scheme', () => { - // Test that parallel nodes use yellow color scheme + it.concurrent('should handle parallel node color scheme', () => { const parallelColors = { background: 'rgba(254,225,43,0.05)', ring: '#FEE12B', @@ -277,8 +247,7 @@ describe('ParallelNodeComponent', () => { expect(parallelColors.startIcon).toBe('#FEE12B') }) - it('should differentiate from loop node styling', () => { - // Ensure parallel nodes have different styling than loop nodes + it.concurrent('should differentiate from loop node styling', () => { const loopColors = { background: 'rgba(34,197,94,0.05)', ring: '#2FB3FF', @@ -298,7 +267,7 @@ describe('ParallelNodeComponent', () => { }) describe('Component Configuration', () => { - it('should handle different dimensions', () => { + it.concurrent('should handle different dimensions', () => { const dimensionTests = [ { width: 500, height: 300 }, { width: 800, height: 600 }, @@ -313,7 +282,7 @@ describe('ParallelNodeComponent', () => { }) }) - it('should handle different states', () => { + it.concurrent('should handle different states', () => { const stateTests = ['valid', 'invalid', 'pending', 'executing', 'completed'] stateTests.forEach((state) => { @@ -324,12 +293,11 @@ describe('ParallelNodeComponent', () => { }) describe('Event Handling Logic', () => { - it('should handle delete button click logic', () => { + it.concurrent('should handle delete button click logic', () => { const mockEvent = { stopPropagation: vi.fn(), } - // Simulate the delete button click handler const handleDelete = (e: any, nodeId: string) => { e.stopPropagation() mockRemoveBlock(nodeId) @@ -341,19 +309,18 @@ describe('ParallelNodeComponent', () => { expect(mockRemoveBlock).toHaveBeenCalledWith('test-id') }) - it('should handle event propagation prevention', () => { + it.concurrent('should handle event propagation prevention', () => { const mockEvent = { stopPropagation: vi.fn(), } - // Test that stopPropagation is called mockEvent.stopPropagation() expect(mockEvent.stopPropagation).toHaveBeenCalled() }) }) describe('Component Data Handling', () => { - it('should handle missing data properties gracefully', () => { + it.concurrent('should handle missing data properties gracefully', () => { const testCases = [ undefined, {}, @@ -375,7 +342,7 @@ describe('ParallelNodeComponent', () => { }) }) - it('should handle parent ID relationships', () => { + it.concurrent('should handle parent ID relationships', () => { const testCases = [ { parentId: undefined, hasParent: false }, { parentId: 'parent-1', hasParent: true }, @@ -390,7 +357,7 @@ describe('ParallelNodeComponent', () => { }) describe('Handle Configuration', () => { - it('should have correct handle IDs for parallel nodes', () => { + it.concurrent('should have correct handle IDs for parallel nodes', () => { const handleIds = { startSource: 'parallel-start-source', endSource: 'parallel-end-source', @@ -402,7 +369,7 @@ describe('ParallelNodeComponent', () => { expect(handleIds.endSource).not.toContain('loop') }) - it('should handle different handle positions', () => { + it.concurrent('should handle different handle positions', () => { const positions = { left: 'left', right: 'right', @@ -418,7 +385,7 @@ describe('ParallelNodeComponent', () => { }) describe('Edge Cases and Error Handling', () => { - it('should handle circular parent references', () => { + it.concurrent('should handle circular parent references', () => { // Test circular reference prevention const nodes = [ { id: 'node1', data: { parentId: 'node2' } }, @@ -456,7 +423,7 @@ describe('ParallelNodeComponent', () => { expect(visited.has('node2')).toBe(true) }) - it('should handle complex circular reference chains', () => { + it.concurrent('should handle complex circular reference chains', () => { // Test more complex circular reference scenarios const nodes = [ { id: 'node1', data: { parentId: 'node2' } }, @@ -489,7 +456,7 @@ describe('ParallelNodeComponent', () => { expect(visited.size).toBe(3) }) - it('should handle self-referencing nodes', () => { + it.concurrent('should handle self-referencing nodes', () => { // Test node that references itself const nodes = [ { id: 'node1', data: { parentId: 'node1' } }, // Self-reference @@ -520,7 +487,7 @@ describe('ParallelNodeComponent', () => { expect(visited.has('node1')).toBe(true) }) - it('should handle extreme values', () => { + it.concurrent('should handle extreme values', () => { const extremeValues = [ { width: Number.MAX_SAFE_INTEGER, height: Number.MAX_SAFE_INTEGER }, { width: -1, height: -1 }, @@ -538,7 +505,7 @@ describe('ParallelNodeComponent', () => { }) }) - it('should handle negative position values', () => { + it.concurrent('should handle negative position values', () => { const positions = [ { xPos: -100, yPos: -200 }, { xPos: 0, yPos: 0 }, @@ -556,7 +523,7 @@ describe('ParallelNodeComponent', () => { }) describe('Component Comparison with Loop Node', () => { - it('should have similar structure to loop node but different type', () => { + it.concurrent('should have similar structure to loop node but different type', () => { expect(defaultProps.type).toBe('parallelNode') expect(defaultProps.id).toContain('parallel') @@ -565,7 +532,7 @@ describe('ParallelNodeComponent', () => { expect(defaultProps.id).not.toContain('loop') }) - it('should handle the same prop structure as loop node', () => { + it.concurrent('should handle the same prop structure as loop node', () => { // Test that parallel node accepts the same prop structure as loop node const sharedPropStructure = { id: 'test-parallel', @@ -594,8 +561,7 @@ describe('ParallelNodeComponent', () => { expect(sharedPropStructure.data.height).toBe(300) }) - it('should maintain consistency with loop node interface', () => { - // Both components should accept the same base props + it.concurrent('should maintain consistency with loop node interface', () => { const baseProps = [ 'id', 'type', diff --git a/apps/sim/app/w/[id]/components/parallel-node/parallel-node.tsx b/apps/sim/app/w/[id]/components/parallel-node/parallel-node.tsx index d5b1cbd4e..688296190 100644 --- a/apps/sim/app/w/[id]/components/parallel-node/parallel-node.tsx +++ b/apps/sim/app/w/[id]/components/parallel-node/parallel-node.tsx @@ -6,7 +6,7 @@ import { StartIcon } from '@/components/icons' import { Button } from '@/components/ui/button' import { Card } from '@/components/ui/card' import { cn } from '@/lib/utils' -import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { ParallelBadges } from './components/parallel-badges' const ParallelNodeStyles: React.FC = () => { @@ -86,6 +86,7 @@ const ParallelNodeStyles: React.FC = () => { export const ParallelNodeComponent = memo(({ data, selected, id }: NodeProps) => { const { getNodes } = useReactFlow() + const { collaborativeRemoveBlock } = useCollaborativeWorkflow() const blockRef = useRef(null) // Check if this is preview mode @@ -111,7 +112,7 @@ export const ParallelNodeComponent = memo(({ data, selected, id }: NodeProps) => const getNestedStyles = () => { // Base styles const styles: Record = { - backgroundColor: 'transparent', + backgroundColor: 'rgba(0, 0, 0, 0.02)', } // Apply nested styles @@ -140,7 +141,8 @@ export const ParallelNodeComponent = memo(({ data, selected, id }: NodeProps) => 'z-[20]', data?.state === 'valid', nestingLevel > 0 && - `border border-[0.5px] ${nestingLevel % 2 === 0 ? 'border-slate-300/60' : 'border-slate-400/60'}` + `border border-[0.5px] ${nestingLevel % 2 === 0 ? 'border-slate-300/60' : 'border-slate-400/60'}`, + data?.hasNestedError && 'border-2 border-red-500 bg-red-50/50' )} style={{ width: data.width || 500, @@ -187,7 +189,7 @@ export const ParallelNodeComponent = memo(({ data, selected, id }: NodeProps) => size='sm' onClick={(e) => { e.stopPropagation() - useWorkflowStore.getState().removeBlock(id) + collaborativeRemoveBlock(id) }} className='absolute top-2 right-2 z-20 text-gray-500 opacity-0 transition-opacity duration-200 hover:text-red-600 group-hover:opacity-100' style={{ pointerEvents: 'auto' }} diff --git a/apps/sim/app/w/[id]/components/skeleton-loading/skeleton-loading.tsx b/apps/sim/app/w/[id]/components/skeleton-loading/skeleton-loading.tsx new file mode 100644 index 000000000..141700a1e --- /dev/null +++ b/apps/sim/app/w/[id]/components/skeleton-loading/skeleton-loading.tsx @@ -0,0 +1,205 @@ +'use client' + +import { Bell, Bug, ChevronDown, Copy, History, Layers, Play, Rocket, Trash2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { useSidebarStore } from '@/stores/sidebar/store' + +// Skeleton Components +const SkeletonControlBar = () => { + return ( +
+ {/* Left Section - Workflow Name Skeleton */} +
+ {/* Workflow name skeleton */} + + {/* "Saved X time ago" skeleton */} + +
+ + {/* Middle Section */} +
+ + {/* Right Section - Action Buttons with Real Icons */} +
+ {/* Delete Button */} + + + {/* History Button */} + + + {/* Notifications Button */} + + + {/* Duplicate Button */} + + + {/* Auto Layout Button */} + + + {/* Debug Mode Button */} + + + {/* Deploy Button */} + + + {/* Run Button with Dropdown */} +
+ {/* Main Run Button */} + + + {/* Dropdown Trigger */} + +
+
+
+ ) +} + +const SkeletonPanelComponent = () => { + return ( +
+ {/* Panel skeleton */} +
+ {/* Tab headers skeleton */} +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ + {/* Content skeleton */} +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+
+ ) +} + +const SkeletonNodes = () => { + return [ + // Starter node skeleton + { + id: 'skeleton-starter', + type: 'workflowBlock', + position: { x: 100, y: 100 }, + data: { + type: 'skeleton', + config: { name: '', description: '', bgColor: '#9CA3AF' }, + name: '', + isActive: false, + isPending: false, + isSkeleton: true, + }, + dragHandle: '.workflow-drag-handle', + }, + // Additional skeleton nodes + { + id: 'skeleton-node-1', + type: 'workflowBlock', + position: { x: 500, y: 100 }, + data: { + type: 'skeleton', + config: { name: '', description: '', bgColor: '#9CA3AF' }, + name: '', + isActive: false, + isPending: false, + isSkeleton: true, + }, + dragHandle: '.workflow-drag-handle', + }, + { + id: 'skeleton-node-2', + type: 'workflowBlock', + position: { x: 300, y: 300 }, + data: { + type: 'skeleton', + config: { name: '', description: '', bgColor: '#9CA3AF' }, + name: '', + isActive: false, + isPending: false, + isSkeleton: true, + }, + dragHandle: '.workflow-drag-handle', + }, + ] +} + +interface SkeletonLoadingProps { + showSkeleton: boolean + isSidebarCollapsed: boolean + children: React.ReactNode +} + +export function SkeletonLoading({ + showSkeleton, + isSidebarCollapsed, + children, +}: SkeletonLoadingProps) { + const { mode, isExpanded } = useSidebarStore() + + return ( +
+
+ {/* Skeleton Control Bar */} +
+ +
+ + {/* Real Control Bar */} +
+ {children} +
+
+ + {/* Real content will be rendered by children - sidebar will show its own loading state */} +
+ ) +} + +export function SkeletonPanelWrapper({ showSkeleton }: { showSkeleton: boolean }) { + return ( +
+ +
+ ) +} + +export { SkeletonNodes, SkeletonPanelComponent } diff --git a/apps/sim/app/w/[id]/components/toolbar/components/toolbar-block/toolbar-block.tsx b/apps/sim/app/w/[id]/components/toolbar/components/toolbar-block/toolbar-block.tsx index 2220ea7c9..b625fa404 100644 --- a/apps/sim/app/w/[id]/components/toolbar/components/toolbar-block/toolbar-block.tsx +++ b/apps/sim/app/w/[id]/components/toolbar/components/toolbar-block/toolbar-block.tsx @@ -1,19 +1,26 @@ import { useCallback } from 'react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' import type { BlockConfig } from '@/blocks/types' export type ToolbarBlockProps = { config: BlockConfig + disabled?: boolean } -export function ToolbarBlock({ config }: ToolbarBlockProps) { +export function ToolbarBlock({ config, disabled = false }: ToolbarBlockProps) { const handleDragStart = (e: React.DragEvent) => { + if (disabled) { + e.preventDefault() + return + } e.dataTransfer.setData('application/json', JSON.stringify({ type: config.type })) e.dataTransfer.effectAllowed = 'move' } // Handle click to add block const handleClick = useCallback(() => { - if (config.type === 'connectionBlock') return + if (config.type === 'connectionBlock' || disabled) return // Dispatch a custom event to be caught by the workflow component const event = new CustomEvent('add-block-from-toolbar', { @@ -22,23 +29,30 @@ export function ToolbarBlock({ config }: ToolbarBlockProps) { }, }) window.dispatchEvent(event) - }, [config.type]) + }, [config.type, disabled]) - return ( + const blockContent = (
@@ -47,4 +61,15 @@ export function ToolbarBlock({ config }: ToolbarBlockProps) {
) + + if (disabled) { + return ( + + {blockContent} + Edit permissions required to add blocks + + ) + } + + return blockContent } diff --git a/apps/sim/app/w/[id]/components/toolbar/components/toolbar-loop-block/toolbar-loop-block.tsx b/apps/sim/app/w/[id]/components/toolbar/components/toolbar-loop-block/toolbar-loop-block.tsx index d07ca5e57..6097e6442 100644 --- a/apps/sim/app/w/[id]/components/toolbar/components/toolbar-loop-block/toolbar-loop-block.tsx +++ b/apps/sim/app/w/[id]/components/toolbar/components/toolbar-loop-block/toolbar-loop-block.tsx @@ -1,9 +1,19 @@ import { useCallback } from 'react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' import { LoopTool } from '../../../loop-node/loop-config' +type LoopToolbarItemProps = { + disabled?: boolean +} + // Custom component for the Loop Tool -export default function LoopToolbarItem() { +export default function LoopToolbarItem({ disabled = false }: LoopToolbarItemProps) { const handleDragStart = (e: React.DragEvent) => { + if (disabled) { + e.preventDefault() + return + } // Only send the essential data for the loop node const simplifiedData = { type: 'loop', @@ -13,30 +23,45 @@ export default function LoopToolbarItem() { } // Handle click to add loop block - const handleClick = useCallback((e: React.MouseEvent) => { - // Dispatch a custom event to be caught by the workflow component - const event = new CustomEvent('add-block-from-toolbar', { - detail: { - type: 'loop', - clientX: e.clientX, - clientY: e.clientY, - }, - }) - window.dispatchEvent(event) - }, []) + const handleClick = useCallback( + (e: React.MouseEvent) => { + if (disabled) return - return ( + // Dispatch a custom event to be caught by the workflow component + const event = new CustomEvent('add-block-from-toolbar', { + detail: { + type: 'loop', + clientX: e.clientX, + clientY: e.clientY, + }, + }) + window.dispatchEvent(event) + }, + [disabled] + ) + + const blockContent = (
- +

{LoopTool.name}

@@ -44,4 +69,15 @@ export default function LoopToolbarItem() {
) + + if (disabled) { + return ( + + {blockContent} + Edit permissions required to add blocks + + ) + } + + return blockContent } diff --git a/apps/sim/app/w/[id]/components/toolbar/components/toolbar-parallel-block/toolbar-parallel-block.tsx b/apps/sim/app/w/[id]/components/toolbar/components/toolbar-parallel-block/toolbar-parallel-block.tsx index 9f277ba67..08c732dac 100644 --- a/apps/sim/app/w/[id]/components/toolbar/components/toolbar-parallel-block/toolbar-parallel-block.tsx +++ b/apps/sim/app/w/[id]/components/toolbar/components/toolbar-parallel-block/toolbar-parallel-block.tsx @@ -1,9 +1,19 @@ import { useCallback } from 'react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' import { ParallelTool } from '../../../parallel-node/parallel-config' +type ParallelToolbarItemProps = { + disabled?: boolean +} + // Custom component for the Parallel Tool -export default function ParallelToolbarItem() { +export default function ParallelToolbarItem({ disabled = false }: ParallelToolbarItemProps) { const handleDragStart = (e: React.DragEvent) => { + if (disabled) { + e.preventDefault() + return + } // Only send the essential data for the parallel node const simplifiedData = { type: 'parallel', @@ -13,31 +23,46 @@ export default function ParallelToolbarItem() { } // Handle click to add parallel block - const handleClick = useCallback((e: React.MouseEvent) => { - // Dispatch a custom event to be caught by the workflow component - const event = new CustomEvent('add-block-from-toolbar', { - detail: { - type: 'parallel', - clientX: e.clientX, - clientY: e.clientY, - }, - bubbles: true, - }) - window.dispatchEvent(event) - }, []) + const handleClick = useCallback( + (e: React.MouseEvent) => { + if (disabled) return - return ( + // Dispatch a custom event to be caught by the workflow component + const event = new CustomEvent('add-block-from-toolbar', { + detail: { + type: 'parallel', + clientX: e.clientX, + clientY: e.clientY, + }, + bubbles: true, + }) + window.dispatchEvent(event) + }, + [disabled] + ) + + const blockContent = (
- +

{ParallelTool.name}

@@ -45,4 +70,15 @@ export default function ParallelToolbarItem() {
) + + if (disabled) { + return ( + + {blockContent} + Edit permissions required to add blocks + + ) + } + + return blockContent } diff --git a/apps/sim/app/w/[id]/components/toolbar/toolbar.tsx b/apps/sim/app/w/[id]/components/toolbar/toolbar.tsx index a5b6d6a17..8d86f8407 100644 --- a/apps/sim/app/w/[id]/components/toolbar/toolbar.tsx +++ b/apps/sim/app/w/[id]/components/toolbar/toolbar.tsx @@ -1,25 +1,69 @@ 'use client' -import { useMemo, useState } from 'react' +import React, { useCallback, useMemo, useState } from 'react' import { PanelLeftClose, PanelRight, Search } from 'lucide-react' +import { useParams } from 'next/navigation' import { Input } from '@/components/ui/input' import { ScrollArea } from '@/components/ui/scroll-area' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { useUserPermissionsContext } from '@/app/w/components/providers/workspace-permissions-provider' import { getAllBlocks, getBlocksByCategory } from '@/blocks' import type { BlockCategory } from '@/blocks/types' import { useSidebarStore } from '@/stores/sidebar/store' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { ToolbarBlock } from './components/toolbar-block/toolbar-block' import LoopToolbarItem from './components/toolbar-loop-block/toolbar-loop-block' import ParallelToolbarItem from './components/toolbar-parallel-block/toolbar-parallel-block' import { ToolbarTabs } from './components/toolbar-tabs/toolbar-tabs' -export function Toolbar() { +interface ToolbarButtonProps { + onClick: () => void + className: string + children: React.ReactNode + tooltipContent: string + tooltipSide?: 'left' | 'right' | 'top' | 'bottom' +} + +const ToolbarButton = React.memo( + ({ onClick, className, children, tooltipContent, tooltipSide = 'right' }) => ( + + + + + {tooltipContent} + + ) +) + +ToolbarButton.displayName = 'ToolbarButton' + +export const Toolbar = React.memo(() => { + const params = useParams() + const workflowId = params?.id as string + + // Get the workspace ID from the workflow registry + const { activeWorkspaceId, workflows } = useWorkflowRegistry() + + const currentWorkflow = useMemo( + () => (workflowId ? workflows[workflowId] : null), + [workflowId, workflows] + ) + + const workspaceId = currentWorkflow?.workspaceId || activeWorkspaceId + + const userPermissions = useUserPermissionsContext() + const [activeTab, setActiveTab] = useState('blocks') const [searchQuery, setSearchQuery] = useState('') const { mode, isExpanded } = useSidebarStore() + // In hover mode, act as if sidebar is always collapsed for layout purposes - const isSidebarCollapsed = - mode === 'expanded' ? !isExpanded : mode === 'collapsed' || mode === 'hover' + const isSidebarCollapsed = useMemo( + () => (mode === 'expanded' ? !isExpanded : mode === 'collapsed' || mode === 'hover'), + [mode, isExpanded] + ) // State to track if toolbar is open - independent of sidebar state const [isToolbarOpen, setIsToolbarOpen] = useState(true) @@ -38,21 +82,34 @@ export function Toolbar() { }) }, [searchQuery, activeTab]) + const handleOpenToolbar = useCallback(() => { + setIsToolbarOpen(true) + }, []) + + const handleCloseToolbar = useCallback(() => { + setIsToolbarOpen(false) + }, []) + + const handleSearchChange = useCallback((e: React.ChangeEvent) => { + setSearchQuery(e.target.value) + }, []) + + const handleTabChange = useCallback((tab: BlockCategory) => { + setActiveTab(tab) + }, []) + // Show toolbar button when it's closed, regardless of sidebar state if (!isToolbarOpen) { return ( - - - - - Open Toolbar - + + + Open Toolbar + ) } @@ -68,7 +125,7 @@ export function Toolbar() { placeholder='Search...' className='rounded-md pl-9' value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} + onChange={handleSearchChange} autoComplete='off' autoCorrect='off' autoCapitalize='off' @@ -79,7 +136,7 @@ export function Toolbar() { {!searchQuery && (
- +
)} @@ -87,12 +144,12 @@ export function Toolbar() {
{blocks.map((block) => ( - + ))} {activeTab === 'blocks' && !searchQuery && ( <> - - + + )}
@@ -100,20 +157,19 @@ export function Toolbar() {
- - - - - Close Toolbar - + + + Close Toolbar +
) -} +}) + +Toolbar.displayName = 'Toolbar' diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/action-bar/action-bar.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/action-bar/action-bar.tsx index 7a626eab2..1e7c5e7e9 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/action-bar/action-bar.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/action-bar/action-bar.tsx @@ -2,16 +2,17 @@ import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, Copy, Trash2 } from 'lu import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' +import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { useWorkflowStore } from '@/stores/workflows/workflow/store' interface ActionBarProps { blockId: string blockType: string + disabled?: boolean } -export function ActionBar({ blockId, blockType }: ActionBarProps) { - const removeBlock = useWorkflowStore((state) => state.removeBlock) - const toggleBlockEnabled = useWorkflowStore((state) => state.toggleBlockEnabled) +export function ActionBar({ blockId, blockType, disabled = false }: ActionBarProps) { + const { collaborativeRemoveBlock, collaborativeToggleBlockEnabled } = useCollaborativeWorkflow() const toggleBlockHandles = useWorkflowStore((state) => state.toggleBlockHandles) const duplicateBlock = useWorkflowStore((state) => state.duplicateBlock) const isEnabled = useWorkflowStore((state) => state.blocks[blockId]?.enabled ?? true) @@ -52,48 +53,19 @@ export function ActionBar({ blockId, blockType }: ActionBarProps) { - {isEnabled ? 'Disable Block' : 'Enable Block'} - - - {!isStarterBlock && ( - - - - - Duplicate Block - - )} - - - - - - {horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports'} + {disabled ? 'Read-only mode' : isEnabled ? 'Disable Block' : 'Enable Block'} @@ -103,13 +75,71 @@ export function ActionBar({ blockId, blockType }: ActionBarProps) { + + + {disabled ? 'Read-only mode' : 'Duplicate Block'} + + + )} + + + + + + + {disabled ? 'Read-only mode' : horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports'} + + + + {!isStarterBlock && ( + + + - Delete Block + + {disabled ? 'Read-only mode' : 'Delete Block'} + )}
diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/connection-blocks/connection-blocks.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/connection-blocks/connection-blocks.tsx index 10accd8d6..baf322f53 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/connection-blocks/connection-blocks.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/connection-blocks/connection-blocks.tsx @@ -1,10 +1,12 @@ import { Card } from '@/components/ui/card' +import { cn } from '@/lib/utils' import { type ConnectedBlock, useBlockConnections } from '@/app/w/[id]/hooks/use-block-connections' import { useSubBlockStore } from '@/stores/workflows/subblock/store' interface ConnectionBlocksProps { blockId: string setIsConnecting: (isConnecting: boolean) => void + isDisabled?: boolean } interface ResponseField { @@ -13,7 +15,11 @@ interface ResponseField { description?: string } -export function ConnectionBlocks({ blockId, setIsConnecting }: ConnectionBlocksProps) { +export function ConnectionBlocks({ + blockId, + setIsConnecting, + isDisabled = false, +}: ConnectionBlocksProps) { const { incomingConnections, hasIncomingConnections } = useBlockConnections(blockId) if (!hasIncomingConnections) return null @@ -23,6 +29,11 @@ export function ConnectionBlocks({ blockId, setIsConnecting }: ConnectionBlocksP connection: ConnectedBlock, field?: ResponseField ) => { + if (isDisabled) { + e.preventDefault() + return + } + e.stopPropagation() // Prevent parent drag handlers from firing setIsConnecting(true) e.dataTransfer.setData( @@ -127,10 +138,15 @@ export function ConnectionBlocks({ blockId, setIsConnecting }: ConnectionBlocksP return ( handleDragStart(e, connection, field)} onDragEnd={handleDragEnd} - className='group flex w-max cursor-grab items-center rounded-lg border bg-card p-2 shadow-sm transition-colors hover:bg-accent/50 active:cursor-grabbing' + className={cn( + 'group flex w-max items-center rounded-lg border bg-card p-2 shadow-sm transition-colors', + !isDisabled + ? 'cursor-grab hover:bg-accent/50 active:cursor-grabbing' + : 'cursor-not-allowed opacity-60' + )} >
{displayName} diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/checkbox-list.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/checkbox-list.tsx index 842462abf..72f0deb46 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/checkbox-list.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/checkbox-list.tsx @@ -11,6 +11,7 @@ interface CheckboxListProps { layout?: 'full' | 'half' isPreview?: boolean subBlockValues?: Record + disabled?: boolean } export function CheckboxList({ @@ -21,6 +22,7 @@ export function CheckboxList({ layout, isPreview = false, subBlockValues, + disabled = false, }: CheckboxListProps) { return (
@@ -35,8 +37,8 @@ export function CheckboxList({ const value = isPreview ? previewValue : storeValue const handleChange = (checked: boolean) => { - // Only update store when not in preview mode - if (!isPreview) { + // Only update store when not in preview mode or disabled + if (!isPreview && !disabled) { setStoreValue(checked) } } @@ -47,7 +49,7 @@ export function CheckboxList({ id={`${blockId}-${option.id}`} checked={Boolean(value)} onCheckedChange={handleChange} - disabled={isPreview} + disabled={isPreview || disabled} />
+ + {/* Show value input for non-container types OR container types using variables */} + {(!isContainer || isObjectVariable) && ( +
+ +
+ )} + + {/* Show object variable input for object types */} + {isContainer && !isObjectVariable && ( +
+ ) => + onUpdateProperty(property.id, updates) + } + onAddArrayItem={onAddArrayItem} + onRemoveArrayItem={onRemoveArrayItem} + onUpdateArrayItem={onUpdateArrayItem} + placeholder='Use or define properties below' + onObjectVariableChange={(newValue: string) => { + if (newValue.startsWith('<')) { + onUpdateProperty(property.id, { value: newValue }) + } else if (newValue === '') { + onUpdateProperty(property.id, { value: [] }) + } + }} + /> +
+ )} +
+ + {isContainer && !property.collapsed && !isObjectVariable && ( +
+ {Array.isArray(property.value) && property.value.length > 0 ? ( + property.value.map((childProp: JSONProperty) => ( + + )) + ) : ( +
+

No properties

+ +
+ )} +
+ )} +
+ ) +} diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/response/components/value-input.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/response/components/value-input.tsx new file mode 100644 index 000000000..6109affb3 --- /dev/null +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/response/components/value-input.tsx @@ -0,0 +1,300 @@ +import { useRef, useState } from 'react' +import { Plus, Trash } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { checkEnvVarTrigger, EnvVarDropdown } from '@/components/ui/env-var-dropdown' +import { Input } from '@/components/ui/input' +import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown' +import { createLogger } from '@/lib/logs/console-logger' +import type { JSONProperty } from '../response-format' + +const logger = createLogger('ValueInput') + +interface ValueInputProps { + property: JSONProperty + blockId: string + isPreview: boolean + onUpdateProperty: (id: string, updates: Partial) => void + onAddArrayItem: (arrayPropId: string) => void + onRemoveArrayItem: (arrayPropId: string, index: number) => void + onUpdateArrayItem: (arrayPropId: string, index: number, newValue: any) => void + placeholder?: string + onObjectVariableChange?: (newValue: string) => void +} + +export function ValueInput({ + property, + blockId, + isPreview, + onUpdateProperty, + onAddArrayItem, + onRemoveArrayItem, + onUpdateArrayItem, + placeholder, + onObjectVariableChange, +}: ValueInputProps) { + const [showEnvVars, setShowEnvVars] = useState(false) + const [showTags, setShowTags] = useState(false) + const [searchTerm, setSearchTerm] = useState('') + const [cursorPosition, setCursorPosition] = useState(0) + const [activeSourceBlockId, setActiveSourceBlockId] = useState(null) + + const inputRefs = useRef<{ [key: string]: HTMLInputElement | null }>({}) + + const findPropertyById = (props: JSONProperty[], id: string): JSONProperty | null => { + for (const prop of props) { + if (prop.id === id) return prop + if (prop.type === 'object' && Array.isArray(prop.value)) { + const found = findPropertyById(prop.value, id) + if (found) return found + } + } + return null + } + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault() + } + + const handleDrop = (e: React.DragEvent, propId: string) => { + if (isPreview) return + e.preventDefault() + + try { + const data = JSON.parse(e.dataTransfer.getData('application/json')) + if (data.type !== 'connectionBlock') return + + const input = inputRefs.current[propId] + const dropPosition = input?.selectionStart ?? 0 + + const currentValue = property.value?.toString() ?? '' + const newValue = `${currentValue.slice(0, dropPosition)}<${currentValue.slice(dropPosition)}` + + input?.focus() + + Promise.resolve().then(() => { + onUpdateProperty(property.id, { value: newValue }) + setCursorPosition(dropPosition + 1) + setShowTags(true) + + if (data.connectionData?.sourceBlockId) { + setActiveSourceBlockId(data.connectionData.sourceBlockId) + } + + setTimeout(() => { + if (input) { + input.selectionStart = dropPosition + 1 + input.selectionEnd = dropPosition + 1 + } + }, 0) + }) + } catch (error) { + logger.error('Failed to parse drop data:', { error }) + } + } + + const getPlaceholder = () => { + if (placeholder) return placeholder + + switch (property.type) { + case 'number': + return '42 or ' + case 'boolean': + return 'true/false or ' + case 'array': + return '["item1", "item2"] or ' + case 'object': + return '{...} or ' + default: + return 'Enter text or ' + } + } + + const handleInputChange = (e: React.ChangeEvent) => { + const newValue = e.target.value + const cursorPos = e.target.selectionStart || 0 + + if (onObjectVariableChange) { + onObjectVariableChange(newValue.trim()) + } else { + onUpdateProperty(property.id, { value: newValue }) + } + + if (!isPreview) { + const tagTrigger = checkTagTrigger(newValue, cursorPos) + const envVarTrigger = checkEnvVarTrigger(newValue, cursorPos) + + setShowTags(tagTrigger.show) + setShowEnvVars(envVarTrigger.show) + setSearchTerm(envVarTrigger.searchTerm || '') + setCursorPosition(cursorPos) + } + } + + const handleTagSelect = (newValue: string) => { + if (onObjectVariableChange) { + onObjectVariableChange(newValue) + } else { + onUpdateProperty(property.id, { value: newValue }) + } + setShowTags(false) + } + + const handleEnvVarSelect = (newValue: string) => { + if (onObjectVariableChange) { + onObjectVariableChange(newValue) + } else { + onUpdateProperty(property.id, { value: newValue }) + } + setShowEnvVars(false) + } + + const isArrayVariable = + property.type === 'array' && + typeof property.value === 'string' && + property.value.trim().startsWith('<') && + property.value.trim().includes('>') + + // Handle array type with individual items + if (property.type === 'array' && !isArrayVariable && Array.isArray(property.value)) { + return ( +
+
+ { + inputRefs.current[`${property.id}-array-variable`] = el + }} + value={typeof property.value === 'string' ? property.value : ''} + onChange={(e) => { + const newValue = e.target.value.trim() + if (newValue.startsWith('<') || newValue.startsWith('[')) { + onUpdateProperty(property.id, { value: newValue }) + } else if (newValue === '') { + onUpdateProperty(property.id, { value: [] }) + } + + const cursorPos = e.target.selectionStart || 0 + if (!isPreview) { + const tagTrigger = checkTagTrigger(newValue, cursorPos) + const envVarTrigger = checkEnvVarTrigger(newValue, cursorPos) + + setShowTags(tagTrigger.show) + setShowEnvVars(envVarTrigger.show) + setSearchTerm(envVarTrigger.searchTerm || '') + setCursorPosition(cursorPos) + } + }} + onDragOver={handleDragOver} + onDrop={(e) => handleDrop(e, `${property.id}-array-variable`)} + placeholder='Use or define items below' + disabled={isPreview} + className='h-7 text-xs' + /> + {!isPreview && showTags && ( + setShowTags(false)} + /> + )} + {!isPreview && showEnvVars && ( + setShowEnvVars(false)} + /> + )} +
+ + {property.value.length > 0 && ( + <> +
Array Items:
+ {property.value.map((item: any, index: number) => ( +
+
+ { + inputRefs.current[`${property.id}-array-${index}`] = el + }} + value={item || ''} + onChange={(e) => onUpdateArrayItem(property.id, index, e.target.value)} + onDragOver={handleDragOver} + onDrop={(e) => handleDrop(e, `${property.id}-array-${index}`)} + placeholder={`Item ${index + 1}`} + disabled={isPreview} + className='h-7 text-xs' + /> +
+ +
+ ))} + + )} + + +
+ ) + } + + // Handle regular input for all other types + return ( +
+ { + inputRefs.current[property.id] = el + }} + value={property.value || ''} + onChange={handleInputChange} + onDragOver={handleDragOver} + onDrop={(e) => handleDrop(e, property.id)} + placeholder={getPlaceholder()} + disabled={isPreview} + className='h-7 text-xs' + /> + {!isPreview && showTags && ( + setShowTags(false)} + /> + )} + {!isPreview && showEnvVars && ( + setShowEnvVars(false)} + /> + )} +
+ ) +} diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/response/response-format.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/response/response-format.tsx new file mode 100644 index 000000000..edef012cc --- /dev/null +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/response/response-format.tsx @@ -0,0 +1,326 @@ +import { useState } from 'react' +import { Code, Eye, Plus } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' +import { useSubBlockValue } from '../../hooks/use-sub-block-value' +import { PropertyRenderer } from './components/property-renderer' + +export interface JSONProperty { + id: string + key: string + type: 'string' | 'number' | 'boolean' | 'object' | 'array' + value: any + collapsed?: boolean +} + +interface ResponseFormatProps { + blockId: string + subBlockId: string + isPreview?: boolean + previewValue?: JSONProperty[] | null +} + +const TYPE_ICONS = { + string: 'Aa', + number: '123', + boolean: 'T/F', + object: '{}', + array: '[]', +} + +const TYPE_COLORS = { + string: 'text-green-600 dark:text-green-400', + number: 'text-blue-600 dark:text-blue-400', + boolean: 'text-purple-600 dark:text-purple-400', + object: 'text-orange-600 dark:text-orange-400', + array: 'text-pink-600 dark:text-pink-400', +} + +const DEFAULT_PROPERTY: JSONProperty = { + id: crypto.randomUUID(), + key: 'message', + type: 'string', + value: '', + collapsed: false, +} + +export function ResponseFormat({ + blockId, + subBlockId, + isPreview = false, + previewValue, +}: ResponseFormatProps) { + const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId) + const [showPreview, setShowPreview] = useState(false) + + const value = isPreview ? previewValue : storeValue + const properties: JSONProperty[] = value || [DEFAULT_PROPERTY] + + const isVariableReference = (value: any): boolean => { + return typeof value === 'string' && value.trim().startsWith('<') && value.trim().includes('>') + } + + const findPropertyById = (props: JSONProperty[], id: string): JSONProperty | null => { + for (const prop of props) { + if (prop.id === id) return prop + if (prop.type === 'object' && Array.isArray(prop.value)) { + const found = findPropertyById(prop.value, id) + if (found) return found + } + } + return null + } + + const generateJSON = (props: JSONProperty[]): any => { + const result: any = {} + + for (const prop of props) { + if (!prop.key.trim()) return + + let value = prop.value + + if (prop.type === 'object') { + if (Array.isArray(prop.value)) { + value = generateJSON(prop.value) + } else if (typeof prop.value === 'string' && isVariableReference(prop.value)) { + value = prop.value + } else { + value = {} // Default empty object for non-array, non-variable values + } + } else if (prop.type === 'array' && Array.isArray(prop.value)) { + value = prop.value.map((item: any) => { + if (typeof item === 'object' && item.type) { + if (item.type === 'object' && Array.isArray(item.value)) { + return generateJSON(item.value) + } + if (item.type === 'array' && Array.isArray(item.value)) { + return item.value.map((subItem: any) => + typeof subItem === 'object' && subItem.type ? subItem.value : subItem + ) + } + return item.value + } + return item + }) + } else if (prop.type === 'number' && !isVariableReference(value)) { + value = Number.isNaN(Number(value)) ? value : Number(value) + } else if (prop.type === 'boolean' && !isVariableReference(value)) { + const strValue = String(value).toLowerCase().trim() + value = strValue === 'true' || strValue === '1' || strValue === 'yes' || strValue === 'on' + } + + result[prop.key] = value + } + + return result + } + + const updateProperties = (newProperties: JSONProperty[]) => { + if (isPreview) return + setStoreValue(newProperties) + } + + const updateProperty = (id: string, updates: Partial) => { + const updateRecursive = (props: JSONProperty[]): JSONProperty[] => { + return props.map((prop) => { + if (prop.id === id) { + const updated = { ...prop, ...updates } + + if (updates.type && updates.type !== prop.type) { + if (updates.type === 'object') { + updated.value = [] + } else if (updates.type === 'array') { + updated.value = [] + } else if (updates.type === 'boolean') { + updated.value = 'false' + } else if (updates.type === 'number') { + updated.value = '0' + } else { + updated.value = '' + } + } + + return updated + } + + if (prop.type === 'object' && Array.isArray(prop.value)) { + return { ...prop, value: updateRecursive(prop.value) } + } + + return prop + }) + } + + updateProperties(updateRecursive(properties)) + } + + const addProperty = (parentId?: string) => { + const newProp: JSONProperty = { + id: crypto.randomUUID(), + key: '', + type: 'string', + value: '', + collapsed: false, + } + + if (parentId) { + const addToParent = (props: JSONProperty[]): JSONProperty[] => { + return props.map((prop) => { + if (prop.id === parentId && prop.type === 'object') { + return { ...prop, value: [...(prop.value || []), newProp] } + } + if (prop.type === 'object' && Array.isArray(prop.value)) { + return { ...prop, value: addToParent(prop.value) } + } + return prop + }) + } + updateProperties(addToParent(properties)) + } else { + updateProperties([...properties, newProp]) + } + } + + const removeProperty = (id: string) => { + const removeRecursive = (props: JSONProperty[]): JSONProperty[] => { + return props + .filter((prop) => prop.id !== id) + .map((prop) => { + if (prop.type === 'object' && Array.isArray(prop.value)) { + return { ...prop, value: removeRecursive(prop.value) } + } + return prop + }) + } + + const newProperties = removeRecursive(properties) + updateProperties( + newProperties.length > 0 + ? newProperties + : [ + { + id: crypto.randomUUID(), + key: '', + type: 'string', + value: '', + collapsed: false, + }, + ] + ) + } + + const addArrayItem = (arrayPropId: string) => { + const addItem = (props: JSONProperty[]): JSONProperty[] => { + return props.map((prop) => { + if (prop.id === arrayPropId && prop.type === 'array') { + return { ...prop, value: [...(prop.value || []), ''] } + } + if (prop.type === 'object' && Array.isArray(prop.value)) { + return { ...prop, value: addItem(prop.value) } + } + return prop + }) + } + updateProperties(addItem(properties)) + } + + const removeArrayItem = (arrayPropId: string, index: number) => { + const removeItem = (props: JSONProperty[]): JSONProperty[] => { + return props.map((prop) => { + if (prop.id === arrayPropId && prop.type === 'array') { + const newValue = [...(prop.value || [])] + newValue.splice(index, 1) + return { ...prop, value: newValue } + } + if (prop.type === 'object' && Array.isArray(prop.value)) { + return { ...prop, value: removeItem(prop.value) } + } + return prop + }) + } + updateProperties(removeItem(properties)) + } + + const updateArrayItem = (arrayPropId: string, index: number, newValue: any) => { + const updateItem = (props: JSONProperty[]): JSONProperty[] => { + return props.map((prop) => { + if (prop.id === arrayPropId && prop.type === 'array') { + const updatedValue = [...(prop.value || [])] + updatedValue[index] = newValue + return { ...prop, value: updatedValue } + } + if (prop.type === 'object' && Array.isArray(prop.value)) { + return { ...prop, value: updateItem(prop.value) } + } + return prop + }) + } + updateProperties(updateItem(properties)) + } + + const hasConfiguredProperties = properties.some((prop) => prop.key.trim()) + + return ( +
+
+ +
+ + +
+
+ + {showPreview && ( +
+
+            {JSON.stringify(generateJSON(properties), null, 2)}
+          
+
+ )} + +
+ {properties.map((prop) => ( + + ))} +
+ + {!hasConfiguredProperties && ( +
+

Build your JSON response format

+

+ Use <variable.name> in values or drag variables from above +

+
+ )} +
+ ) +} diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/schedule/components/schedule-modal.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/schedule/components/schedule-modal.tsx index 6c3d0d927..f71b26046 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/schedule/components/schedule-modal.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/schedule/components/schedule-modal.tsx @@ -648,7 +648,10 @@ export function ScheduleModal({ setShowDeleteConfirm(false)}> Cancel - + {isDeleting ? 'Deleting...' : 'Delete Schedule'} diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/schedule/schedule-config.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/schedule/schedule-config.tsx index a088192b7..182b8ae69 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/schedule/schedule-config.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/schedule/schedule-config.tsx @@ -21,6 +21,7 @@ interface ScheduleConfigProps { isConnecting: boolean isPreview?: boolean previewValue?: any | null + disabled?: boolean } export function ScheduleConfig({ @@ -29,6 +30,7 @@ export function ScheduleConfig({ isConnecting, isPreview = false, previewValue, + disabled = false, }: ScheduleConfigProps) { const [error, setError] = useState(null) const [scheduleId, setScheduleId] = useState(null) @@ -137,7 +139,7 @@ export function ScheduleConfig({ } const handleOpenModal = () => { - if (isPreview) return + if (isPreview || disabled) return setIsModalOpen(true) } @@ -151,7 +153,7 @@ export function ScheduleConfig({ } const handleSaveSchedule = async (): Promise => { - if (isPreview) return false + if (isPreview || disabled) return false setIsSaving(true) setError(null) @@ -255,7 +257,7 @@ export function ScheduleConfig({ } const handleDeleteSchedule = async (): Promise => { - if (isPreview || !scheduleId) return false + if (isPreview || !scheduleId || disabled) return false setIsDeleting(true) try { @@ -328,7 +330,7 @@ export function ScheduleConfig({ size='icon' className='h-8 w-8 shrink-0' onClick={handleOpenModal} - disabled={isPreview || isDeleting || isConnecting} + disabled={isPreview || isDeleting || isConnecting || disabled} > {isDeleting ? (
@@ -344,7 +346,7 @@ export function ScheduleConfig({ size='sm' className='flex h-10 w-full items-center bg-background font-normal text-sm' onClick={handleOpenModal} - disabled={isPreview || isConnecting || isSaving || isDeleting} + disabled={isPreview || isConnecting || isSaving || isDeleting || disabled} > {isLoading ? (
diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/short-input.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/short-input.tsx index 4aed0f857..76bf854a7 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/short-input.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/short-input.tsx @@ -22,6 +22,7 @@ interface ShortInputProps { onChange?: (value: string) => void isPreview?: boolean previewValue?: string | null + disabled?: boolean } export function ShortInput({ @@ -35,6 +36,7 @@ export function ShortInput({ value: propValue, isPreview = false, previewValue, + disabled = false, }: ShortInputProps) { const [isFocused, setIsFocused] = useState(false) const [showEnvVars, setShowEnvVars] = useState(false) @@ -92,6 +94,12 @@ export function ShortInput({ // Handle input changes const handleChange = (e: React.ChangeEvent) => { + // Don't allow changes if disabled + if (disabled) { + e.preventDefault() + return + } + const newValue = e.target.value const newCursorPosition = e.target.selectionStart ?? 0 @@ -328,7 +336,7 @@ export function ShortInput({ onKeyDown={handleKeyDown} autoComplete='off' style={{ overflowX: 'auto' }} - disabled={isPreview} + disabled={disabled} />
(blockId, subBlockId) @@ -44,7 +46,7 @@ export function SliderInput({ }, [normalizedValue, value, setStoreValue, isPreview]) const handleValueChange = (newValue: number[]) => { - if (!isPreview) { + if (!isPreview && !disabled) { const processedValue = integer ? Math.round(newValue[0]) : newValue[0] setStoreValue(processedValue) } @@ -57,8 +59,8 @@ export function SliderInput({ min={min} max={max} step={integer ? 1 : step} - onValueChange={(value) => setStoreValue(integer ? Math.round(value[0]) : value[0])} - disabled={isPreview} + onValueChange={handleValueChange} + disabled={isPreview || disabled} className='[&_[class*=SliderTrack]]:h-1 [&_[role=slider]]:h-4 [&_[role=slider]]:w-4' />
(blockId, subBlockId) // Use preview value when in preview mode, otherwise use store value const value = isPreview ? previewValue : storeValue - const fields: InputField[] = value || [DEFAULT_FIELD] + const fields: InputField[] = value || [] // Field operations const addField = () => { - if (isPreview) return + if (isPreview || disabled) return const newField: InputField = { ...DEFAULT_FIELD, @@ -58,18 +60,18 @@ export function InputFormat({ } const removeField = (id: string) => { - if (isPreview || fields.length === 1) return + if (isPreview || disabled) return setStoreValue(fields.filter((field: InputField) => field.id !== id)) } // Update handlers const updateField = (id: string, field: keyof InputField, value: any) => { - if (isPreview) return + if (isPreview || disabled) return setStoreValue(fields.map((f: InputField) => (f.id === id ? { ...f, [field]: value } : f))) } const toggleCollapse = (id: string) => { - if (isPreview) return + if (isPreview || disabled) return setStoreValue( fields.map((f: InputField) => (f.id === id ? { ...f, collapsed: !f.collapsed } : f)) ) @@ -104,7 +106,7 @@ export function InputFormat({ variant='ghost' size='icon' onClick={addField} - disabled={isPreview} + disabled={isPreview || disabled} className='h-6 w-6 rounded-full' > @@ -115,7 +117,7 @@ export function InputFormat({ variant='ghost' size='icon' onClick={() => removeField(field.id)} - disabled={isPreview || fields.length === 1} + disabled={isPreview || disabled} className='h-6 w-6 rounded-full text-destructive hover:text-destructive' > @@ -132,96 +134,112 @@ export function InputFormat({ // Main render return (
- {fields.map((field, index) => { - const isUnconfigured = !field.name || field.name.trim() === '' - - return ( -
+

No input fields defined

+ +
+ ) : ( + fields.map((field, index) => { + const isUnconfigured = !field.name || field.name.trim() === '' - {!field.collapsed && ( -
-
- - updateField(field.id, 'name', e.target.value)} - placeholder='firstName' - disabled={isPreview} - className='h-9 placeholder:text-muted-foreground/50' - /> + return ( +
+ {renderFieldHeader(field, index)} + + {!field.collapsed && ( +
+
+ + updateField(field.id, 'name', e.target.value)} + placeholder='firstName' + disabled={isPreview || disabled} + className='h-9 placeholder:text-muted-foreground/50' + /> +
+ +
+ + + + + + + updateField(field.id, 'type', 'string')} + className='cursor-pointer' + > + Aa + String + + updateField(field.id, 'type', 'number')} + className='cursor-pointer' + > + 123 + Number + + updateField(field.id, 'type', 'boolean')} + className='cursor-pointer' + > + 0/1 + Boolean + + updateField(field.id, 'type', 'object')} + className='cursor-pointer' + > + {'{}'} + Object + + updateField(field.id, 'type', 'array')} + className='cursor-pointer' + > + [] + Array + + + +
+ )} +
+ ) + }) + )} -
- - - - - - - updateField(field.id, 'type', 'string')} - className='cursor-pointer' - > - Aa - String - - updateField(field.id, 'type', 'number')} - className='cursor-pointer' - > - 123 - Number - - updateField(field.id, 'type', 'boolean')} - className='cursor-pointer' - > - 0/1 - Boolean - - updateField(field.id, 'type', 'object')} - className='cursor-pointer' - > - {'{}'} - Object - - updateField(field.id, 'type', 'array')} - className='cursor-pointer' - > - [] - Array - - - -
-
- )} -
- ) - })} - - {!hasConfiguredFields && ( + {fields.length > 0 && !hasConfiguredFields && (
Define fields above to enable structured API input
diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/switch.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/switch.tsx index b3bc57f6f..4812957eb 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/switch.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/switch.tsx @@ -9,6 +9,7 @@ interface SwitchProps { value?: boolean isPreview?: boolean previewValue?: boolean | null + disabled?: boolean } export function Switch({ @@ -18,6 +19,7 @@ export function Switch({ value: propValue, isPreview = false, previewValue, + disabled = false, }: SwitchProps) { const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId) @@ -25,8 +27,8 @@ export function Switch({ const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue const handleChange = (checked: boolean) => { - // Only update store when not in preview mode - if (!isPreview) { + // Only update store when not in preview mode and not disabled + if (!isPreview && !disabled) { setStoreValue(checked) } } @@ -37,7 +39,7 @@ export function Switch({ id={`${blockId}-${subBlockId}`} checked={Boolean(value)} onCheckedChange={handleChange} - disabled={isPreview} + disabled={isPreview || disabled} />
- - {/* Danger Zone Section */} -
-
-
- - - - - - -

{TOOLTIPS.resetData}

-
-
-
- - - - - - - Are you absolutely sure? - - This action cannot be undone. This will permanently delete all your workflows, - settings, and stored data. - - - - Cancel - - Reset Data - - - - -
-
) } diff --git a/apps/sim/app/w/components/sidebar/components/settings-modal/components/subscription/components/team-seats-dialog.tsx b/apps/sim/app/w/components/sidebar/components/settings-modal/components/subscription/components/team-seats-dialog.tsx new file mode 100644 index 000000000..4cd3ab874 --- /dev/null +++ b/apps/sim/app/w/components/sidebar/components/settings-modal/components/subscription/components/team-seats-dialog.tsx @@ -0,0 +1,134 @@ +import { useEffect, useState } from 'react' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { env } from '@/lib/env' + +interface TeamSeatsDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + title: string + description: string + currentSeats?: number + initialSeats?: number + isLoading: boolean + onConfirm: (seats: number) => Promise + confirmButtonText: string + showCostBreakdown?: boolean +} + +export function TeamSeatsDialog({ + open, + onOpenChange, + title, + description, + currentSeats, + initialSeats = 1, + isLoading, + onConfirm, + confirmButtonText, + showCostBreakdown = false, +}: TeamSeatsDialogProps) { + const [selectedSeats, setSelectedSeats] = useState(initialSeats) + + useEffect(() => { + if (open) { + setSelectedSeats(initialSeats) + } + }, [open, initialSeats]) + + const costPerSeat = env.TEAM_TIER_COST_LIMIT ?? 40 + const totalMonthlyCost = selectedSeats * costPerSeat + const costChange = currentSeats ? (selectedSeats - currentSeats) * costPerSeat : 0 + + const handleConfirm = async () => { + await onConfirm(selectedSeats) + } + + return ( + + + + {title} + {description} + + +
+ + + +

+ Your team will have {selectedSeats} {selectedSeats === 1 ? 'seat' : 'seats'} with a + total of ${totalMonthlyCost} inference credits per month. +

+ + {showCostBreakdown && currentSeats !== undefined && ( +
+
+ Current seats: + {currentSeats} +
+
+ New seats: + {selectedSeats} +
+
+ Monthly cost change: + + {costChange > 0 ? '+' : ''}${costChange} + +
+
+ )} +
+ + + + + +
+
+ ) +} diff --git a/apps/sim/app/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx b/apps/sim/app/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx index c0a2450d3..406f0db28 100644 --- a/apps/sim/app/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx +++ b/apps/sim/app/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx @@ -2,26 +2,12 @@ import { useEffect, useState } from 'react' import { AlertCircle } from 'lucide-react' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { Label } from '@/components/ui/label' import { Progress } from '@/components/ui/progress' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select' import { Skeleton } from '@/components/ui/skeleton' import { useActiveOrganization, useSession, useSubscription } from '@/lib/auth-client' +import { env } from '@/lib/env' import { createLogger } from '@/lib/logs/console-logger' +import { TeamSeatsDialog } from './components/team-seats-dialog' const logger = createLogger('Subscription') @@ -332,7 +318,7 @@ export function Subscription({ setIsTeamDialogOpen(true) } - const confirmTeamUpgrade = async () => { + const confirmTeamUpgrade = async (selectedSeats?: number) => { if (!session?.user) { setError('You need to be logged in to upgrade your team subscription') return @@ -341,10 +327,12 @@ export function Subscription({ setIsUpgradingTeam(true) setError(null) + const seatsToUse = selectedSeats || seats + try { const result = await subscription.upgrade({ plan: 'team', - seats, + seats: seatsToUse, successUrl: window.location.href, cancelUrl: window.location.href, }) @@ -816,54 +804,19 @@ export function Subscription({
)} - - - - Team Subscription - - Set up a team workspace with collaborative features. Each seat costs $40/month and - gets $40 of inference credits. - - - -
- - - -

- Your team will have {seats} {seats === 1 ? 'seat' : 'seats'} with a total of $ - {seats * 40} inference credits per month. -

-
- - - - - -
-
+ { + setSeats(selectedSeats) + await confirmTeamUpgrade(selectedSeats) + }} + confirmButtonText='Upgrade to Team Plan' + /> )}
diff --git a/apps/sim/app/w/components/sidebar/components/settings-modal/components/team-management/team-management.tsx b/apps/sim/app/w/components/sidebar/components/settings-modal/components/team-management/team-management.tsx index e9fe68601..ea6a05d4e 100644 --- a/apps/sim/app/w/components/sidebar/components/settings-modal/components/team-management/team-management.tsx +++ b/apps/sim/app/w/components/sidebar/components/settings-modal/components/team-management/team-management.tsx @@ -15,8 +15,10 @@ import { Progress } from '@/components/ui/progress' import { Skeleton } from '@/components/ui/skeleton' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { client, useSession } from '@/lib/auth-client' +import { env } from '@/lib/env' import { createLogger } from '@/lib/logs/console-logger' import { checkEnterprisePlan } from '@/lib/subscription/utils' +import { TeamSeatsDialog } from '../subscription/components/team-seats-dialog' const logger = createLogger('TeamManagement') @@ -115,6 +117,10 @@ export function TeamManagement() { [activeOrganization] ) + const [isAddSeatDialogOpen, setIsAddSeatDialogOpen] = useState(false) + const [newSeatCount, setNewSeatCount] = useState(1) + const [isUpdatingSeats, setIsUpdatingSeats] = useState(false) + const loadData = useCallback(async () => { if (!session?.user) return @@ -281,7 +287,7 @@ export function TeamManagement() { } try { - await updateSeats(currentSeats - 1) + await reduceSeats(currentSeats - 1) await refreshOrganization() } catch (err: any) { setError(err.message || 'Failed to reduce seats') @@ -581,7 +587,7 @@ export function TeamManagement() { if (shouldReduceSeats && subscriptionData) { const currentSeats = subscriptionData.seats || 0 if (currentSeats > 1) { - await updateSeats(currentSeats - 1) + await reduceSeats(currentSeats - 1) } } @@ -637,34 +643,68 @@ export function TeamManagement() { ) } - const updateSeats = useCallback( - async (newSeatCount: number) => { - if (!subscriptionData || !activeOrganization) return + // Handle opening the add seat dialog + const handleAddSeatDialog = () => { + if (subscriptionData) { + setNewSeatCount((subscriptionData.seats || 1) + 1) // Default to current seats + 1 + setIsAddSeatDialogOpen(true) + } + } - // Don't allow enterprise users to modify seats - if (checkEnterprisePlan(subscriptionData)) { - setError('Enterprise plan seats can only be modified by contacting support') - return + // Handle reducing seats + const reduceSeats = async (newSeatCount: number) => { + if (!subscriptionData || !activeOrganization) return + + try { + setIsLoading(true) + setError(null) + + const { error } = await client.subscription.upgrade({ + plan: 'team', + referenceId: activeOrganization.id, + subscriptionId: subscriptionData.id, + seats: newSeatCount, + successUrl: window.location.href, + cancelUrl: window.location.href, + }) + if (error) throw new Error(error.message || 'Failed to reduce seats') + } finally { + setIsLoading(false) + } + } + + // Confirm seat addition + const confirmAddSeats = async (selectedSeats?: number) => { + if (!subscriptionData || !activeOrganization) return + + const seatsToUse = selectedSeats || newSeatCount + + try { + setIsUpdatingSeats(true) + setError(null) + + const { error } = await client.subscription.upgrade({ + plan: 'team', + referenceId: activeOrganization.id, + subscriptionId: subscriptionData.id, + seats: seatsToUse, + successUrl: window.location.href, + cancelUrl: window.location.href, + }) + + if (error) { + setError(error.message || 'Failed to update seats') + } else { + // Close the dialog after successful upgrade + setIsAddSeatDialogOpen(false) + await refreshOrganization() } - - try { - setIsLoading(true) - setError(null) - - const { error } = await client.subscription.upgrade({ - plan: 'team', - referenceId: activeOrganization.id, - successUrl: window.location.href, - cancelUrl: window.location.href, - seats: newSeatCount, - }) - if (error) throw new Error(error.message || 'Failed to update seats') - } finally { - setIsLoading(false) - } - }, - [subscriptionData, activeOrganization] - ) + } catch (err: any) { + setError(err.message || 'Failed to update seats') + } finally { + setIsUpdatingSeats(false) + } + } if (isLoading && !activeOrganization && !(hasTeamPlan || hasEnterprisePlan)) { return @@ -935,18 +975,7 @@ export function TeamManagement() {
) } diff --git a/apps/sim/app/w/components/sidebar/components/workspace-header/workspace-header.tsx b/apps/sim/app/w/components/sidebar/components/workspace-header/workspace-header.tsx index 838b8d58f..4f9351d01 100644 --- a/apps/sim/app/w/components/sidebar/components/workspace-header/workspace-header.tsx +++ b/apps/sim/app/w/components/sidebar/components/workspace-header/workspace-header.tsx @@ -1,7 +1,7 @@ 'use client' -import { useEffect, useState } from 'react' -import { ChevronDown, Pencil, Plus, Trash2, X } from 'lucide-react' +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { ChevronDown, Pencil, Trash2, X } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/navigation' import { AgentIcon } from '@/components/icons' @@ -27,9 +27,9 @@ import { } from '@/components/ui/dropdown-menu' import { Input } from '@/components/ui/input' import { Skeleton } from '@/components/ui/skeleton' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useSession } from '@/lib/auth-client' import { cn } from '@/lib/utils' +import { useUserPermissionsContext } from '@/app/w/components/providers/workspace-permissions-provider' import { useSidebarStore } from '@/stores/sidebar/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -53,78 +53,88 @@ interface WorkspaceModalProps { onCreateWorkspace: (name: string) => void } -function WorkspaceModal({ open, onOpenChange, onCreateWorkspace }: WorkspaceModalProps) { - const [workspaceName, setWorkspaceName] = useState('') +const WorkspaceModal = React.memo( + ({ open, onOpenChange, onCreateWorkspace }) => { + const [workspaceName, setWorkspaceName] = useState('') - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault() - if (workspaceName.trim()) { - onCreateWorkspace(workspaceName.trim()) - setWorkspaceName('') + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault() + if (workspaceName.trim()) { + onCreateWorkspace(workspaceName.trim()) + setWorkspaceName('') + onOpenChange(false) + } + }, + [workspaceName, onCreateWorkspace, onOpenChange] + ) + + const handleNameChange = useCallback((e: React.ChangeEvent) => { + setWorkspaceName(e.target.value) + }, []) + + const handleClose = useCallback(() => { onOpenChange(false) - } - } + }, [onOpenChange]) - return ( - - - -
- Create New Workspace - -
-
- -
- -
-
- - setWorkspaceName(e.target.value)} - placeholder='Enter workspace name' - className='w-full' - autoFocus - /> -
-
- -
+ return ( + + + +
+ Create New Workspace +
- -
- -
- ) -} + + +
+
+
+
+ + +
+
+ +
+
+
+
+ + + ) + } +) + +WorkspaceModal.displayName = 'WorkspaceModal' // New WorkspaceEditModal component interface WorkspaceEditModalProps { @@ -134,548 +144,585 @@ interface WorkspaceEditModalProps { workspace: Workspace | null } -function WorkspaceEditModal({ - open, - onOpenChange, - onUpdateWorkspace, - workspace, -}: WorkspaceEditModalProps) { - const [workspaceName, setWorkspaceName] = useState('') +const WorkspaceEditModal = React.memo( + ({ open, onOpenChange, onUpdateWorkspace, workspace }) => { + const [workspaceName, setWorkspaceName] = useState('') - useEffect(() => { - if (workspace && open) { - setWorkspaceName(workspace.name) - } - }, [workspace, open]) + useEffect(() => { + if (workspace && open) { + setWorkspaceName(workspace.name) + } + }, [workspace, open]) - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault() - if (workspace && workspaceName.trim()) { - onUpdateWorkspace(workspace.id, workspaceName.trim()) - setWorkspaceName('') + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault() + if (workspace && workspaceName.trim()) { + onUpdateWorkspace(workspace.id, workspaceName.trim()) + setWorkspaceName('') + onOpenChange(false) + } + }, + [workspace, workspaceName, onUpdateWorkspace, onOpenChange] + ) + + const handleNameChange = useCallback((e: React.ChangeEvent) => { + setWorkspaceName(e.target.value) + }, []) + + const handleClose = useCallback(() => { onOpenChange(false) - } - } + }, [onOpenChange]) - return ( - - - -
- Edit Workspace - -
-
- -
-
-
-
- - setWorkspaceName(e.target.value)} - placeholder='Enter workspace name' - className='w-full' - autoFocus - /> -
-
- -
+ return ( + + + +
+ Edit Workspace +
- -
- -
- ) -} + -export function WorkspaceHeader({ - onCreateWorkflow, - isCollapsed, - onDropdownOpenChange, -}: WorkspaceHeaderProps) { - // Get sidebar store state to check current mode - const { mode, workspaceDropdownOpen, setAnyModalOpen } = useSidebarStore() +
+
+
+
+ + +
+
+ +
+
+
+
+ + + ) + } +) - // Keep local isOpen state in sync with the store (for internal component use) - const [isOpen, setIsOpen] = useState(workspaceDropdownOpen) - const { data: sessionData, isPending } = useSession() - const [plan, setPlan] = useState('Free Plan') - // Use client-side loading instead of isPending to avoid hydration mismatch - const [isClientLoading, setIsClientLoading] = useState(true) - const [workspaces, setWorkspaces] = useState([]) - const [activeWorkspace, setActiveWorkspace] = useState(null) - const [isWorkspacesLoading, setIsWorkspacesLoading] = useState(true) - const [isWorkspaceModalOpen, setIsWorkspaceModalOpen] = useState(false) - const [editingWorkspace, setEditingWorkspace] = useState(null) - const [isEditModalOpen, setIsEditModalOpen] = useState(false) - const [isDeleting, setIsDeleting] = useState(false) - const router = useRouter() +WorkspaceEditModal.displayName = 'WorkspaceEditModal' - // Get workflowRegistry state and actions - const { activeWorkspaceId, setActiveWorkspace: setActiveWorkspaceId } = useWorkflowRegistry() +export const WorkspaceHeader = React.memo( + ({ onCreateWorkflow, isCollapsed, onDropdownOpenChange }) => { + // Get sidebar store state to check current mode + const { mode, workspaceDropdownOpen, setAnyModalOpen } = useSidebarStore() - const userName = sessionData?.user?.name || sessionData?.user?.email || 'User' + // Keep local isOpen state in sync with the store (for internal component use) + const [isOpen, setIsOpen] = useState(workspaceDropdownOpen) + const { data: sessionData, isPending } = useSession() + const [plan, setPlan] = useState('Free Plan') + // Use client-side loading instead of isPending to avoid hydration mismatch + const [isClientLoading, setIsClientLoading] = useState(true) + const [workspaces, setWorkspaces] = useState([]) + const [activeWorkspace, setActiveWorkspace] = useState(null) + const [isWorkspacesLoading, setIsWorkspacesLoading] = useState(true) + const [isWorkspaceModalOpen, setIsWorkspaceModalOpen] = useState(false) + const [editingWorkspace, setEditingWorkspace] = useState(null) + const [isEditModalOpen, setIsEditModalOpen] = useState(false) + const [isDeleting, setIsDeleting] = useState(false) + const router = useRouter() - // Set isClientLoading to false after hydration - useEffect(() => { - setIsClientLoading(false) - }, []) + // Get workflowRegistry state and actions + const { activeWorkspaceId, switchToWorkspace, setActiveWorkspaceId } = useWorkflowRegistry() - useEffect(() => { - // Fetch subscription status if user is logged in - if (sessionData?.user?.id) { - fetch('/api/user/subscription') - .then((res) => res.json()) - .then((data) => { - setPlan(data.isPro ? 'Pro Plan' : 'Free Plan') - }) - .catch((err) => { - console.error('Error fetching subscription status:', err) - }) + // Get user permissions for the active workspace + const userPermissions = useUserPermissionsContext() - // Fetch user's workspaces + const userName = useMemo( + () => sessionData?.user?.name || sessionData?.user?.email || 'User', + [sessionData?.user?.name, sessionData?.user?.email] + ) + + // Set isClientLoading to false after hydration + useEffect(() => { + setIsClientLoading(false) + }, []) + + const fetchSubscriptionStatus = useCallback(async (userId: string) => { + try { + const response = await fetch('/api/user/subscription') + const data = await response.json() + setPlan(data.isPro ? 'Pro Plan' : 'Free Plan') + } catch (err) { + console.error('Error fetching subscription status:', err) + } + }, []) + + const fetchWorkspaces = useCallback(async () => { setIsWorkspacesLoading(true) - fetch('/api/workspaces') - .then((res) => res.json()) - .then((data) => { - if (data.workspaces && Array.isArray(data.workspaces)) { - const fetchedWorkspaces = data.workspaces as Workspace[] - setWorkspaces(fetchedWorkspaces) + try { + const response = await fetch('/api/workspaces') + const data = await response.json() - // Find workspace that matches the active ID from registry or use first workspace + if (data.workspaces && Array.isArray(data.workspaces)) { + const fetchedWorkspaces = data.workspaces as Workspace[] + setWorkspaces(fetchedWorkspaces) + + // Only update workspace if we have a valid activeWorkspaceId from registry + if (activeWorkspaceId) { const matchingWorkspace = fetchedWorkspaces.find( (workspace) => workspace.id === activeWorkspaceId ) - const workspaceToActivate = matchingWorkspace || fetchedWorkspaces[0] - - // If we found a workspace, set it as active and update registry if needed - if (workspaceToActivate) { - setActiveWorkspace(workspaceToActivate) - - // If active workspace in UI doesn't match registry, update registry - if (workspaceToActivate.id !== activeWorkspaceId) { - setActiveWorkspaceId(workspaceToActivate.id) + if (matchingWorkspace) { + setActiveWorkspace(matchingWorkspace) + } else { + // Active workspace not found, fallback to first workspace + const fallbackWorkspace = fetchedWorkspaces[0] + if (fallbackWorkspace) { + setActiveWorkspace(fallbackWorkspace) + setActiveWorkspaceId(fallbackWorkspace.id) } } } - setIsWorkspacesLoading(false) - }) - .catch((err) => { - console.error('Error fetching workspaces:', err) - setIsWorkspacesLoading(false) - }) - } - }, [sessionData?.user?.id, activeWorkspaceId, setActiveWorkspaceId]) - - const switchWorkspace = (workspace: Workspace) => { - // If already on this workspace, do nothing - if (activeWorkspace?.id === workspace.id) { - setIsOpen(false) - return - } - - setActiveWorkspace(workspace) - setIsOpen(false) - - // Update the workflow registry store with the new active workspace - setActiveWorkspaceId(workspace.id) - - // Update URL to include workspace ID - router.push(`/w/${workspace.id}`) - } - - const handleCreateWorkspace = (name: string) => { - setIsWorkspacesLoading(true) - - fetch('/api/workspaces', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ name }), - }) - .then((res) => res.json()) - .then((data) => { - if (data.workspace) { - const newWorkspace = data.workspace as Workspace - setWorkspaces((prev) => [...prev, newWorkspace]) - setActiveWorkspace(newWorkspace) - - // Update the workflow registry store with the new active workspace - setActiveWorkspaceId(newWorkspace.id) - - // Update URL to include new workspace ID - router.push(`/w/${newWorkspace.id}`) + // If no activeWorkspaceId, let loadWorkspaceFromWorkflowId handle workspace selection } + } catch (err) { + console.error('Error fetching workspaces:', err) + } finally { setIsWorkspacesLoading(false) - }) - .catch((err) => { - console.error('Error creating workspace:', err) - setIsWorkspacesLoading(false) - }) - } - - const handleUpdateWorkspace = async (id: string, name: string) => { - // Check if user has permission to update the workspace - const workspace = workspaces.find((w) => w.id === id) - if (!workspace || workspace.role !== 'owner') { - console.error('Permission denied: Only workspace owners can update workspaces') - return - } - - setIsWorkspacesLoading(true) - - try { - const response = await fetch(`/api/workspaces/${id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ name }), - }) - - if (!response.ok) { - throw new Error('Failed to update workspace') } + }, [activeWorkspaceId, setActiveWorkspaceId]) - const { workspace } = await response.json() - - // Update workspaces list - setWorkspaces((prevWorkspaces) => - prevWorkspaces.map((w) => (w.id === workspace.id ? { ...w, name: workspace.name } : w)) - ) - - // If active workspace was updated, update it too - if (activeWorkspace?.id === workspace.id) { - setActiveWorkspace({ ...activeWorkspace, name: workspace.name } as Workspace) + useEffect(() => { + // Fetch subscription status if user is logged in + if (sessionData?.user?.id) { + fetchSubscriptionStatus(sessionData.user.id) + fetchWorkspaces() } - } catch (err) { - console.error('Error updating workspace:', err) - } finally { - setIsWorkspacesLoading(false) - } - } + }, [sessionData?.user?.id, fetchSubscriptionStatus, fetchWorkspaces]) - const handleDeleteWorkspace = async (id: string) => { - // Check if user has permission to delete the workspace - const workspace = workspaces.find((w) => w.id === id) - if (!workspace || workspace.role !== 'owner') { - console.error('Permission denied: Only workspace owners can delete workspaces') - return - } + const switchWorkspace = useCallback( + (workspace: Workspace) => { + // If already on this workspace, do nothing + if (activeWorkspace?.id === workspace.id) { + setIsOpen(false) + return + } - setIsDeleting(true) + setActiveWorkspace(workspace) + setIsOpen(false) - try { - const response = await fetch(`/api/workspaces/${id}`, { - method: 'DELETE', - }) + // Use full workspace switch which now handles localStorage automatically + switchToWorkspace(workspace.id) - if (!response.ok) { - throw new Error('Failed to delete workspace') - } + // Update URL to include workspace ID + router.push(`/w/${workspace.id}`) + }, + [activeWorkspace?.id, switchToWorkspace, router] + ) - // Remove from workspace list - const updatedWorkspaces = workspaces.filter((w) => w.id !== id) - setWorkspaces(updatedWorkspaces) + const handleCreateWorkspace = useCallback( + async (name: string) => { + setIsWorkspacesLoading(true) - // If deleted workspace was active, switch to another workspace - if (activeWorkspace?.id === id && updatedWorkspaces.length > 0) { - // Use the specialized method for handling workspace deletion - const newWorkspaceId = updatedWorkspaces[0].id - useWorkflowRegistry.getState().handleWorkspaceDeletion(newWorkspaceId) - setActiveWorkspace(updatedWorkspaces[0]) - } + try { + const response = await fetch('/api/workspaces', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name }), + }) - setIsOpen(false) - } catch (err) { - console.error('Error deleting workspace:', err) - } finally { - setIsDeleting(false) - } - } + const data = await response.json() - const openEditModal = (workspace: Workspace, e: React.MouseEvent) => { - e.stopPropagation() - // Check if user has permission to edit the workspace - if (workspace.role !== 'owner') { - console.error('Permission denied: Only workspace owners can edit workspaces') - return - } - setEditingWorkspace(workspace) - setIsEditModalOpen(true) - } + if (data.workspace) { + const newWorkspace = data.workspace as Workspace + setWorkspaces((prev) => [...prev, newWorkspace]) + setActiveWorkspace(newWorkspace) - // Determine URL for workspace links - const workspaceUrl = activeWorkspace ? `/w/${activeWorkspace.id}` : '/w' + // Use switchToWorkspace to properly load workflows for the new workspace + // This will clear existing workflows, set loading state, and fetch workflows from DB + switchToWorkspace(newWorkspace.id) - // Notify parent component when dropdown opens/closes - const handleDropdownOpenChange = (open: boolean) => { - setIsOpen(open) - // Inform the parent component about the dropdown state change - if (onDropdownOpenChange) { - onDropdownOpenChange(open) - } - } + // Update URL to include new workspace ID + router.push(`/w/${newWorkspace.id}`) + } + } catch (err) { + console.error('Error creating workspace:', err) + } finally { + setIsWorkspacesLoading(false) + } + }, + [switchToWorkspace, router] + ) - // Special handling for click interactions in hover mode - const handleTriggerClick = (e: React.MouseEvent) => { - // When in hover mode, explicitly prevent bubbling for the trigger - if (mode === 'hover') { - e.stopPropagation() - e.preventDefault() - // Toggle dropdown state - handleDropdownOpenChange(!isOpen) - } - } + const handleUpdateWorkspace = useCallback( + async (id: string, name: string) => { + // For update operations, we need to check permissions for the specific workspace + // Since we can only use hooks at the component level, we'll make the API call + // and let the backend handle the permission check + setIsWorkspacesLoading(true) - // Handle modal open/close state - useEffect(() => { - // Update the modal state in the store - setAnyModalOpen(isWorkspaceModalOpen || isEditModalOpen || isDeleting) - }, [isWorkspaceModalOpen, isEditModalOpen, isDeleting, setAnyModalOpen]) + try { + const response = await fetch(`/api/workspaces/${id}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name }), + }) - return ( -
- {/* Workspace Modal */} - - - {/* Edit Workspace Modal */} - - - -
{ - // In hover mode, prevent clicks on the container from collapsing the sidebar - if (mode === 'hover') { - e.stopPropagation() + if (!response.ok) { + if (response.status === 403) { + console.error( + 'Permission denied: Only users with admin permissions can update workspaces' + ) } - }} - > - {/* Hover background with consistent padding - only when not collapsed */} - {!isCollapsed &&
} + throw new Error('Failed to update workspace') + } - {/* Content with consistent padding */} - {isCollapsed ? ( -
- - - -
- ) : ( -
- -
+ prevWorkspaces.map((w) => + w.id === updatedWorkspace.id ? { ...w, name: updatedWorkspace.name } : w + ) + ) + + // If active workspace was updated, update it too + if (activeWorkspace && activeWorkspace.id === updatedWorkspace.id) { + setActiveWorkspace({ + ...activeWorkspace, + name: updatedWorkspace.name, + }) + } + } catch (err) { + console.error('Error updating workspace:', err) + } finally { + setIsWorkspacesLoading(false) + } + }, + [activeWorkspace] + ) + + const handleDeleteWorkspace = useCallback( + async (id: string) => { + // For delete operations, we need to check permissions for the specific workspace + // Since we can only use hooks at the component level, we'll make the API call + // and let the backend handle the permission check + setIsDeleting(true) + + try { + const response = await fetch(`/api/workspaces/${id}`, { + method: 'DELETE', + }) + + if (!response.ok) { + if (response.status === 403) { + console.error( + 'Permission denied: Only users with admin permissions can delete workspaces' + ) + } + throw new Error('Failed to delete workspace') + } + + // Remove from workspace list + const updatedWorkspaces = workspaces.filter((w) => w.id !== id) + setWorkspaces(updatedWorkspaces) + + // If deleted workspace was active, switch to another workspace + if (activeWorkspace?.id === id && updatedWorkspaces.length > 0) { + // Use the specialized method for handling workspace deletion + const newWorkspaceId = updatedWorkspaces[0].id + useWorkflowRegistry.getState().handleWorkspaceDeletion(newWorkspaceId) + setActiveWorkspace(updatedWorkspaces[0]) + } + + setIsOpen(false) + } catch (err) { + console.error('Error deleting workspace:', err) + } finally { + setIsDeleting(false) + } + }, + [workspaces, activeWorkspace?.id] + ) + + const openEditModal = useCallback( + (workspace: Workspace, e: React.MouseEvent) => { + e.stopPropagation() + // Only show edit/delete options for the active workspace if user has admin permissions + if (activeWorkspace?.id !== workspace.id || !userPermissions.canAdmin) { + return + } + setEditingWorkspace(workspace) + setIsEditModalOpen(true) + }, + [activeWorkspace?.id, userPermissions.canAdmin] + ) + + // Determine URL for workspace links + const workspaceUrl = useMemo( + () => (activeWorkspace ? `/w/${activeWorkspace.id}` : '/w'), + [activeWorkspace] + ) + + // Notify parent component when dropdown opens/closes + const handleDropdownOpenChange = useCallback( + (open: boolean) => { + setIsOpen(open) + // Inform the parent component about the dropdown state change + if (onDropdownOpenChange) { + onDropdownOpenChange(open) + } + }, + [onDropdownOpenChange] + ) + + // Special handling for click interactions in hover mode + const handleTriggerClick = useCallback( + (e: React.MouseEvent) => { + // When in hover mode, explicitly prevent bubbling for the trigger + if (mode === 'hover') { + e.stopPropagation() + e.preventDefault() + // Toggle dropdown state + handleDropdownOpenChange(!isOpen) + } + }, + [mode, isOpen, handleDropdownOpenChange] + ) + + const handleContainerClick = useCallback( + (e: React.MouseEvent) => { + // In hover mode, prevent clicks on the container from collapsing the sidebar + if (mode === 'hover') { + e.stopPropagation() + } + }, + [mode] + ) + + const handleWorkspaceModalOpenChange = useCallback((open: boolean) => { + setIsWorkspaceModalOpen(open) + }, []) + + const handleEditModalOpenChange = useCallback((open: boolean) => { + setIsEditModalOpen(open) + }, []) + + // Handle modal open/close state + useEffect(() => { + // Update the modal state in the store + setAnyModalOpen(isWorkspaceModalOpen || isEditModalOpen || isDeleting) + }, [isWorkspaceModalOpen, isEditModalOpen, isDeleting, setAnyModalOpen]) + + return ( +
+ {/* Workspace Modal */} + + + {/* Edit Workspace Modal */} + + + +
+ {/* Hover background with consistent padding - only when not collapsed */} + {!isCollapsed && ( +
+ )} + + {/* Content with consistent padding */} + {isCollapsed ? ( +
+ -
- { - if (isOpen) e.preventDefault() - }} - > - - + + +
+ ) : ( +
+ +
+
+ { + if (isOpen) e.preventDefault() + }} + > + + + {isClientLoading || isWorkspacesLoading ? ( + + ) : ( +
+ + {activeWorkspace?.name || `${userName}'s Workspace`} + + +
+ )} +
+
+
+
+ )} +
+ +
+
+
+
+ +
+
{isClientLoading || isWorkspacesLoading ? ( - + <> + + + ) : ( -
- + <> + {activeWorkspace?.name || `${userName}'s Workspace`} - -
+ {plan} + )}
- +
+
- {/* Plus button positioned absolutely */} - {!isCollapsed && ( -
- - -
- {isClientLoading ? ( - - ) : ( + + + {/* Workspaces list */} +
+
Workspaces
+ {isWorkspacesLoading ? ( +
+ +
+ ) : ( +
+ {workspaces.map((workspace) => ( + switchWorkspace(workspace)} + > + {workspace.name} + {userPermissions.canAdmin && activeWorkspace?.id === workspace.id && ( +
- )} -
- - New Workflow - + + + + + + + + Delete Workspace + + Are you sure you want to delete "{workspace.name}"? This action + cannot be undone. + + + + e.stopPropagation()}> + Cancel + + { + e.stopPropagation() + handleDeleteWorkspace(workspace.id) + }} + className='bg-destructive text-destructive-foreground hover:bg-destructive/90' + > + Delete + + + + +
+ )} + + ))}
)} + + {/* Create new workspace button */} + setIsWorkspaceModalOpen(true)} + > + + New workspace +
- )} -
- -
-
-
-
- -
-
- {isClientLoading || isWorkspacesLoading ? ( - <> - - - - ) : ( - <> - - {activeWorkspace?.name || `${userName}'s Workspace`} - - {plan} - - )} -
-
-
-
+
+ +
+ ) + } +) - - - {/* Workspaces list */} -
-
Workspaces
- {isWorkspacesLoading ? ( -
- -
- ) : ( -
- {workspaces.map((workspace) => ( - switchWorkspace(workspace)} - > - {workspace.name} - {workspace.role === 'owner' && ( -
- - - - - - - - - Delete Workspace - - Are you sure you want to delete "{workspace.name}"? This action - cannot be undone. - - - - e.stopPropagation()}> - Cancel - - { - e.stopPropagation() - handleDeleteWorkspace(workspace.id) - }} - className='bg-destructive text-destructive-foreground hover:bg-destructive/90' - > - Delete - - - - -
- )} -
- ))} -
- )} - - {/* Create new workspace button */} - setIsWorkspaceModalOpen(true)} - > - + New workspace - -
- - -
- ) -} +WorkspaceHeader.displayName = 'WorkspaceHeader' diff --git a/apps/sim/app/w/components/sidebar/sidebar.tsx b/apps/sim/app/w/components/sidebar/sidebar.tsx index 7f755b764..e8485572e 100644 --- a/apps/sim/app/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/w/components/sidebar/sidebar.tsx @@ -7,19 +7,26 @@ import { usePathname, useRouter } from 'next/navigation' import { Skeleton } from '@/components/ui/skeleton' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useSession } from '@/lib/auth-client' +import { createLogger } from '@/lib/logs/console-logger' import { getKeyboardShortcutText, useGlobalShortcuts } from '@/app/w/hooks/use-keyboard-shortcuts' import { useSidebarStore } from '@/stores/sidebar/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' import { useRegistryLoading } from '../../hooks/use-registry-loading' +import { useUserPermissionsContext } from '../providers/workspace-permissions-provider' +import { CreateMenu } from './components/create-menu/create-menu' +import { FolderTree } from './components/folder-tree/folder-tree' import { HelpModal } from './components/help-modal/help-modal' import { InviteModal } from './components/invite-modal/invite-modal' import { NavSection } from './components/nav-section/nav-section' import { SettingsModal } from './components/settings-modal/settings-modal' import { SidebarControl } from './components/sidebar-control/sidebar-control' -import { WorkflowList } from './components/workflow-list/workflow-list' import { WorkspaceHeader } from './components/workspace-header/workspace-header' +const logger = createLogger('Sidebar') + +const IS_DEV = process.env.NODE_ENV === 'development' + export function Sidebar() { useRegistryLoading() useGlobalShortcuts() @@ -31,61 +38,34 @@ export function Sidebar() { isLoading: workflowsLoading, } = useWorkflowRegistry() const { isPending: sessionLoading } = useSession() + const userPermissions = useUserPermissionsContext() const isLoading = workflowsLoading || sessionLoading const router = useRouter() const pathname = usePathname() + const [showSettings, setShowSettings] = useState(false) const [showHelp, setShowHelp] = useState(false) const [showInviteMembers, setShowInviteMembers] = useState(false) - const [isDevEnvironment, setIsDevEnvironment] = useState(false) - const { - mode, - isExpanded, - toggleExpanded, - setMode, - workspaceDropdownOpen, - setWorkspaceDropdownOpen, - isAnyModalOpen, - setAnyModalOpen, - } = useSidebarStore() + const { mode, workspaceDropdownOpen, setWorkspaceDropdownOpen, isAnyModalOpen, setAnyModalOpen } = + useSidebarStore() const [isHovered, setIsHovered] = useState(false) const [explicitMouseEnter, setExplicitMouseEnter] = useState(false) useEffect(() => { - setIsDevEnvironment(process.env.NODE_ENV === 'development') - }, []) - - // Track when active workspace changes to ensure we refresh the UI - useEffect(() => { - if (activeWorkspaceId) { - // We don't need to do anything here, just force a re-render - // when activeWorkspaceId changes to ensure fresh data - } - }, [activeWorkspaceId]) - - // Update modal state in the store when settings or help modals open/close - useEffect(() => { - setAnyModalOpen(showSettings || showHelp || showInviteMembers) - }, [showSettings, showHelp, showInviteMembers, setAnyModalOpen]) - - // Reset explicit mouse enter state when modal state changes - useEffect(() => { - if (isAnyModalOpen) { + const anyModalIsOpen = showSettings || showHelp || showInviteMembers + setAnyModalOpen(anyModalIsOpen) + if (anyModalIsOpen) { setExplicitMouseEnter(false) } - }, [isAnyModalOpen]) + }, [showSettings, showHelp, showInviteMembers, setAnyModalOpen]) // Separate regular workflows from temporary marketplace workflows const { regularWorkflows, tempWorkflows } = useMemo(() => { const regular: WorkflowMetadata[] = [] const temp: WorkflowMetadata[] = [] - // Only process workflows when not in loading state if (!isLoading) { Object.values(workflows).forEach((workflow) => { - // Include workflows that either: - // 1. Belong to the active workspace, OR - // 2. Don't have a workspace ID (legacy workflows) if (workflow.workspaceId === activeWorkspaceId || !workflow.workspaceId) { if (workflow.marketplaceData?.status === 'temp') { temp.push(workflow) @@ -95,8 +75,8 @@ export function Sidebar() { } }) - // Sort regular workflows by last modified date (newest first) - regular.sort((a, b) => { + // Sort by last modified date (newest first) + const sortByLastModified = (a: WorkflowMetadata, b: WorkflowMetadata) => { const dateA = a.lastModified instanceof Date ? a.lastModified.getTime() @@ -106,45 +86,25 @@ export function Sidebar() { ? b.lastModified.getTime() : new Date(b.lastModified).getTime() return dateB - dateA - }) + } - // Sort temp workflows by last modified date (newest first) - temp.sort((a, b) => { - const dateA = - a.lastModified instanceof Date - ? a.lastModified.getTime() - : new Date(a.lastModified).getTime() - const dateB = - b.lastModified instanceof Date - ? b.lastModified.getTime() - : new Date(b.lastModified).getTime() - return dateB - dateA - }) + regular.sort(sortByLastModified) + temp.sort(sortByLastModified) } return { regularWorkflows: regular, tempWorkflows: temp } }, [workflows, isLoading, activeWorkspaceId]) - // Create workflow - const handleCreateWorkflow = async () => { + // Create workflow handler + const handleCreateWorkflow = async (folderId?: string) => { try { - // Import the isActivelyLoadingFromDB function to check sync status - const { isActivelyLoadingFromDB } = await import('@/stores/workflows/sync') - - // Prevent creating workflows during active DB operations - if (isActivelyLoadingFromDB()) { - console.log('Please wait, syncing in progress...') - return - } - - // Create the workflow and ensure it's associated with the active workspace - const id = createWorkflow({ + const id = await createWorkflow({ workspaceId: activeWorkspaceId || undefined, + folderId: folderId || undefined, }) - router.push(`/w/${id}`) } catch (error) { - console.error('Error creating workflow:', error) + logger.error('Error creating workflow:', error) } } @@ -155,7 +115,7 @@ export function Sidebar() { mode === 'collapsed' || (mode === 'hover' && ((!isHovered && !workspaceDropdownOpen) || isAnyModalOpen || !explicitMouseEnter)) - // Only show overlay effect when in hover mode and actually being hovered or dropdown is open + const showOverlay = mode === 'hover' && ((isHovered && !isAnyModalOpen && explicitMouseEnter) || workspaceDropdownOpen) @@ -165,8 +125,8 @@ export function Sidebar() { className={clsx( 'fixed inset-y-0 left-0 z-10 flex flex-col border-r bg-background transition-all duration-200 sm:flex', isCollapsed ? 'w-14' : 'w-60', - showOverlay ? 'shadow-lg' : '', - mode === 'hover' ? 'main-content-overlay' : '' + showOverlay && 'shadow-lg', + mode === 'hover' && 'main-content-overlay' )} onMouseEnter={() => { if (mode === 'hover' && !isAnyModalOpen) { @@ -179,12 +139,8 @@ export function Sidebar() { setIsHovered(false) } }} - style={{ - // When in hover mode and expanded, position above content without pushing it - position: showOverlay ? 'fixed' : 'fixed', - }} > - {/* Workspace Header - Fixed at top */} + {/* Workspace Header */}
- {/* Main navigation - Fixed at top below header */} - {/*
- - } - href="/w/1" - label="Home" - active={pathname === '/w/1'} - isCollapsed={isCollapsed} - /> - } - href="/w/templates" - label="Templates" - active={pathname === '/w/templates'} - isCollapsed={isCollapsed} - /> - } - href="/w/marketplace" - label="Marketplace" - active={pathname === '/w/marketplace'} - isCollapsed={isCollapsed} - /> - -
*/} - - {/* Scrollable Content Area - Contains Workflows and Logs/Settings */} + {/* Scrollable Content Area */}
{/* Workflows Section */}
-

- {isLoading ? ( - isCollapsed ? ( - '' - ) : ( - - ) - ) : isCollapsed ? ( - '' - ) : ( - 'Workflows' +

+ {isLoading ? : 'Workflows'} +

+ {!isCollapsed && !isLoading && ( + )} -

- +
- {/* Logs and Settings Navigation - Follows workflows */} + {/* Navigation Section */}
- {/* Push the bottom controls down when content is short */}
+ {/* Bottom Controls */} {isCollapsed ? (
- {/* Invite members button */} - {!isDevEnvironment && ( + {!IS_DEV && (
setShowInviteMembers(true)} - className='mx-auto flex h-8 w-8 cursor-pointer items-center justify-center rounded-md font-medium text-muted-foreground text-sm hover:bg-accent/50' + onClick={ + userPermissions.canAdmin ? () => setShowInviteMembers(true) : undefined + } + className={clsx( + 'mx-auto flex h-8 w-8 items-center justify-center rounded-md font-medium text-sm', + userPermissions.canAdmin + ? 'cursor-pointer text-muted-foreground hover:bg-accent/50' + : 'cursor-not-allowed text-muted-foreground/50' + )} >
- Invite Members + + {userPermissions.canAdmin + ? 'Invite Members' + : 'Admin permission required to invite members'} +
)} - {/* Help button */}
Help - {/* Sidebar control */} @@ -323,23 +258,36 @@ export function Sidebar() {
) : ( <> - {/* Invite members bar */} - {!isDevEnvironment && ( + {!IS_DEV && (
-
setShowInviteMembers(true)} - className='flex cursor-pointer items-center rounded-md px-2 py-1.5 font-medium text-muted-foreground text-sm hover:bg-accent/50' - > - - Invite members -
+ + +
setShowInviteMembers(true) : undefined + } + className={clsx( + 'flex items-center rounded-md px-2 py-1.5 font-medium text-sm', + userPermissions.canAdmin + ? 'cursor-pointer text-muted-foreground hover:bg-accent/50' + : 'cursor-not-allowed text-muted-foreground/50' + )} + > + + Invite members +
+
+ + {userPermissions.canAdmin + ? 'Invite new members to this workspace' + : 'Admin permission required to invite members'} + +
)} - {/* Bottom buttons container */}
- {/* Sidebar control on left with tooltip */} @@ -347,7 +295,6 @@ export function Sidebar() { Toggle sidebar - {/* Help button on right with tooltip */}
)} + {/* Modals */} - {!isDevEnvironment && ( - - )} + {!IS_DEV && } ) } diff --git a/apps/sim/app/w/components/workflow-preview/workflow-preview.tsx b/apps/sim/app/w/components/workflow-preview/workflow-preview.tsx index c81e847c3..79abac5ab 100644 --- a/apps/sim/app/w/components/workflow-preview/workflow-preview.tsx +++ b/apps/sim/app/w/components/workflow-preview/workflow-preview.tsx @@ -177,7 +177,7 @@ export function WorkflowPreview({ config: blockConfig, name: block.name, blockState: block, - isReadOnly: true, + canEdit: false, isPreview: true, subBlockValues: subBlocksClone, }, @@ -207,7 +207,7 @@ export function WorkflowPreview({ showSubBlocks, isChild: true, parentId: blockId, - isReadOnly: true, + canEdit: false, isPreview: true, }, draggable: false, diff --git a/apps/sim/app/w/hooks/use-registry-loading.ts b/apps/sim/app/w/hooks/use-registry-loading.ts index 0ca7ba513..207514e18 100644 --- a/apps/sim/app/w/hooks/use-registry-loading.ts +++ b/apps/sim/app/w/hooks/use-registry-loading.ts @@ -1,27 +1,94 @@ 'use client' import { useEffect } from 'react' +import { usePathname, useRouter } from 'next/navigation' +import { createLogger } from '@/lib/logs/console-logger' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +const logger = createLogger('UseRegistryLoading') + /** - * Custom hook to manage workflow registry loading state + * Extract workflow ID from pathname + * @param pathname - Current pathname + * @returns workflow ID if found, null otherwise + */ +function extractWorkflowIdFromPathname(pathname: string): string | null { + try { + const pathSegments = pathname.split('/') + // Check if URL matches pattern /w/{workflowId} + if (pathSegments.length >= 3 && pathSegments[1] === 'w') { + const workflowId = pathSegments[2] + // Basic UUID validation (36 characters, contains hyphens) + if (workflowId && workflowId.length === 36 && workflowId.includes('-')) { + return workflowId + } + } + return null + } catch (error) { + logger.warn('Failed to extract workflow ID from pathname:', error) + return null + } +} + +/** + * Custom hook to manage workflow registry loading state and handle first-time navigation * * This hook initializes the loading state and automatically clears it - * when workflows are loaded or after a timeout + * when workflows are loaded. It also handles smart workspace selection + * and navigation for first-time users. */ export function useRegistryLoading() { - const { workflows, setLoading } = useWorkflowRegistry() + const { workflows, setLoading, isLoading, activeWorkspaceId, loadWorkspaceFromWorkflowId } = + useWorkflowRegistry() + const pathname = usePathname() + const router = useRouter() + // Handle workspace selection from URL useEffect(() => { - // Set loading state initially - setLoading(true) + if (!activeWorkspaceId) { + const workflowIdFromUrl = extractWorkflowIdFromPathname(pathname) + if (workflowIdFromUrl) { + loadWorkspaceFromWorkflowId(workflowIdFromUrl).catch((error) => { + logger.warn('Failed to load workspace from workflow ID:', error) + }) + } + } + }, [activeWorkspaceId, pathname, loadWorkspaceFromWorkflowId]) + + // Handle first-time navigation: if we're at /w and have workflows, navigate to first one + useEffect(() => { + if (!isLoading && activeWorkspaceId && Object.keys(workflows).length > 0) { + const workflowCount = Object.keys(workflows).length + const currentWorkflowId = extractWorkflowIdFromPathname(pathname) + + // If we're at a generic workspace URL (/w, /w/, or /w/workspaceId) without a specific workflow + if ( + !currentWorkflowId && + (pathname === '/w' || pathname === '/w/' || pathname === `/w/${activeWorkspaceId}`) + ) { + const firstWorkflowId = Object.keys(workflows)[0] + logger.info('First-time navigation: redirecting to first workflow:', firstWorkflowId) + router.replace(`/w/${firstWorkflowId}`) + } + } + }, [isLoading, activeWorkspaceId, workflows, pathname, router]) + + // Handle loading states + useEffect(() => { + // Only set loading if we don't have workflows and aren't already loading + if (Object.keys(workflows).length === 0 && !isLoading) { + setLoading(true) + } // If workflows are already loaded, clear loading state - if (Object.keys(workflows).length > 0) { - setTimeout(() => setLoading(false), 300) + if (Object.keys(workflows).length > 0 && isLoading) { + setTimeout(() => setLoading(false), 100) return } + // Only create timeout if we're actually loading + if (!isLoading) return + // Create a timeout to clear loading state after max time const timeout = setTimeout(() => { setLoading(false) @@ -40,5 +107,5 @@ export function useRegistryLoading() { clearTimeout(timeout) clearInterval(checkInterval) } - }, [setLoading, workflows]) + }, [setLoading, workflows, isLoading]) } diff --git a/apps/sim/app/w/knowledge/[id]/[documentId]/components/create-chunk-modal/create-chunk-modal.tsx b/apps/sim/app/w/knowledge/[id]/[documentId]/components/create-chunk-modal/create-chunk-modal.tsx index a231ff90f..fcb04c42b 100644 --- a/apps/sim/app/w/knowledge/[id]/[documentId]/components/create-chunk-modal/create-chunk-modal.tsx +++ b/apps/sim/app/w/knowledge/[id]/[documentId]/components/create-chunk-modal/create-chunk-modal.tsx @@ -1,7 +1,7 @@ 'use client' import { useRef, useState } from 'react' -import { AlertCircle, FileText, Loader2, X } from 'lucide-react' +import { AlertCircle, Loader2, X } from 'lucide-react' import { AlertDialog, AlertDialogAction, @@ -13,7 +13,6 @@ import { AlertDialogTitle, } from '@/components/ui/alert-dialog' import { Button } from '@/components/ui/button' -import { Checkbox } from '@/components/ui/checkbox' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Label } from '@/components/ui/label' import { Textarea } from '@/components/ui/textarea' @@ -38,7 +37,6 @@ export function CreateChunkModal({ onChunkCreated, }: CreateChunkModalProps) { const [content, setContent] = useState('') - const [enabled, setEnabled] = useState(true) const [isCreating, setIsCreating] = useState(false) const [error, setError] = useState(null) const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false) @@ -68,7 +66,7 @@ export function CreateChunkModal({ }, body: JSON.stringify({ content: content.trim(), - enabled, + enabled: true, }), } ) @@ -104,7 +102,6 @@ export function CreateChunkModal({ onOpenChange(false) // Reset form state when modal closes setContent('') - setEnabled(true) setError(null) setShowUnsavedChangesAlert(false) } @@ -148,11 +145,10 @@ export function CreateChunkModal({
-
-
- {/* Document Info */} +
+ {/* Document Info Section - Fixed at top */} +
-

{document?.filename || 'Unknown Document'} @@ -161,41 +157,6 @@ export function CreateChunkModal({

- {/* Content Input */} -
- -