Compare commits

..

38 Commits

Author SHA1 Message Date
Waleed
d63a5cb504 v0.5.71: ux, ci improvements, docs updates 2026-01-25 03:08:08 -08:00
Waleed
8bd5d41723 v0.5.70: router fix, anthropic agent response format adherence 2026-01-24 20:57:02 -08:00
Waleed
c12931bc50 v0.5.69: kb upgrades, blog, copilot improvements, auth consolidation (#2973)
* fix(subflows): tag dropdown + resolution logic (#2949)

* fix(subflows): tag dropdown + resolution logic

* fixes;

* revert parallel change

* chore(deps): bump posthog-js to 1.334.1 (#2948)

* fix(idempotency): add conflict target to atomicallyClaimDb query + remove redundant db namespace tracking (#2950)

* fix(idempotency): add conflict target to atomicallyClaimDb query

* delete needs to account for namespace

* simplify namespace filtering logic

* fix cleanup

* consistent target

* improvement(kb): add document filtering, select all, and React Query migration (#2951)

* improvement(kb): add document filtering, select all, and React Query migration

* test(kb): update tests for enabledFilter and removed userId params

* fix(kb): remove non-null assertion, add explicit guard

* improvement(logs): trace span, details (#2952)

* improvement(action-bar): ordering

* improvement(logs): details, trace span

* feat(blog): v0.5 release post (#2953)

* feat(blog): v0.5 post

* improvement(blog): simplify title and remove code block header

- Simplified blog title from Introducing Sim Studio v0.5 to Introducing Sim v0.5
- Removed language label header and copy button from code blocks for cleaner appearance

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

* ack PR comments

* small styling improvements

* created system to create post-specific components

* updated componnet

* cache invalidation

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* feat(admin): add credits endpoint to issue credits to users (#2954)

* feat(admin): add credits endpoint to issue credits to users

* fix(admin): use existing credit functions and handle enterprise seats

* fix(admin): reject NaN and Infinity in amount validation

* styling

* fix(admin): validate userId and email are strings

* improvement(copilot): fast mode, subagent tool responses and allow preferences (#2955)

* Improvements

* Fix actions mapping

* Remove console logs

* fix(billing): handle missing userStats and prevent crashes (#2956)

* fix(billing): handle missing userStats and prevent crashes

* fix(billing): correct import path for getFilledPillColor

* fix(billing): add Number.isFinite check to lastPeriodCost

* fix(logs): refresh logic to refresh logs details (#2958)

* fix(security): add authentication and input validation to API routes (#2959)

* fix(security): add authentication and input validation to API routes

* moved utils

* remove extraneous commetns

* removed unused dep

* improvement(helm): add internal ingress support and same-host path consolidation (#2960)

* improvement(helm): add internal ingress support and same-host path consolidation

* improvement(helm): clean up ingress template comments

Simplify verbose inline Helm comments and section dividers to match the
minimal style used in services.yaml.

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

* fix(helm): add missing copilot path consolidation for realtime host

When copilot.host equals realtime.host but differs from app.host,
copilot paths were not being routed. Added logic to consolidate
copilot paths into the realtime rule for this scenario.

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

* improvement(helm): follow ingress best practices

- Remove orphan comments that appeared when services were disabled
- Add documentation about path ordering requirements
- Paths rendered in order: realtime, copilot, app (specific before catch-all)
- Clean template output matching industry Helm chart standards

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* feat(blog): enterprise post (#2961)

* feat(blog): enterprise post

* added more images, styling

* more content

* updated v0-5 post

* remove unused transition

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>

* fix(envvars): resolution standardized (#2957)

* fix(envvars): resolution standardized

* remove comments

* address bugbot

* fix highlighting for env vars

* remove comments

* address greptile

* address bugbot

* fix(copilot): mask credentials fix (#2963)

* Fix copilot masking

* Clean up

* Lint

* improvement(webhooks): remove dead code (#2965)

* fix(webhooks): subscription recreation path

* improvement(webhooks): remove dead code

* fix tests

* address bugbot comments

* fix restoration edge case

* fix more edge cases

* address bugbot comments

* fix gmail polling

* add warnings for UI indication for credential sets

* fix(preview): subblock values (#2969)

* fix(child-workflow): nested spans handoff (#2966)

* fix(child-workflow): nested spans handoff

* remove overly defensive programming

* update type check

* type more code

* remove more dead code

* address bugbot comments

* fix(security): restrict API key access on internal-only routes (#2964)

* fix(security): restrict API key access on internal-only routes

* test(security): update function execute tests for checkInternalAuth

* updated agent handler

* move session check higher in checkSessionOrInternalAuth

* extracted duplicate code into helper for resolving user from jwt

* fix(copilot): update copilot chat title (#2968)

* fix(hitl): fix condition blocks after hitl (#2967)

* fix(notes): ghost edges (#2970)

* fix(notes): ghost edges

* fix deployed state fallback

* fallback

* remove UI level checks

* annotation missing from autoconnect source check

* improvement(docs): loop and parallel var reference syntax (#2975)

* fix(blog): slash actions description (#2976)

* improvement(docs): loop and parallel var reference syntax

* fix(blog): slash actions description

* fix(auth): copilot routes (#2977)

* Fix copilot auth

* Fix

* Fix

* Fix

* fix(copilot): fix edit summary for loops/parallels (#2978)

* fix(integrations): hide from tool bar (#2544)

* fix(landing): ui (#2979)

* fix(edge-validation): race condition on collaborative add (#2980)

* fix(variables): boolean type support and input improvements (#2981)

* fix(variables): boolean type support and input improvements

* fix formatting

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2026-01-24 14:29:53 -08:00
Waleed
e9c4251c1c v0.5.68: router block reasoning, executor improvements, variable resolution consolidation, helm updates (#2946)
* improvement(workflow-item): stabilize avatar layout and fix name truncation (#2939)

* improvement(workflow-item): stabilize avatar layout and fix name truncation

* fix(avatars): revert overflow bg to hardcoded color for contrast

* fix(executor): stop parallel execution when block errors (#2940)

* improvement(helm): add per-deployment extraVolumes support (#2942)

* fix(gmail): expose messageId field in read email block (#2943)

* fix(resolver): consolidate reference resolution  (#2941)

* fix(resolver): consolidate code to resolve references

* fix edge cases

* use already formatted error

* fix multi index

* fix backwards compat reachability

* handle backwards compatibility accurately

* use shared constant correctly

* feat(router): expose reasoning output in router v2 block (#2945)

* fix(copilot): always allow, credential masking (#2947)

* Fix always allow, credential validation

* Credential masking

* Autoload

* fix(executor): handle condition dead-end branches in loops (#2944)

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
2026-01-22 13:48:15 -08:00
Waleed
cc2be33d6b v0.5.67: loading, password reset, ui improvements, helm updates (#2928)
* fix(zustand): updated to useShallow from deprecated createWithEqualityFn (#2919)

* fix(logger): use direct env access for webpack inlining (#2920)

* fix(notifications): text overflow with line-clamp (#2921)

* chore(helm): add env vars for Vertex AI, orgs, and telemetry (#2922)

* fix(auth): improve reset password flow and consolidate brand detection (#2924)

* fix(auth): improve reset password flow and consolidate brand detection

* fix(auth): set errorHandled for EMAIL_NOT_VERIFIED to prevent duplicate error

* fix(auth): clear success message on login errors

* chore(auth): fix import order per lint

* fix(action-bar): duplicate subflows with children (#2923)

* fix(action-bar): duplicate subflows with children

* fix(action-bar): add validateTriggerPaste for subflow duplicate

* fix(resolver): agent response format, input formats, root level (#2925)

* fix(resolvers): agent response format, input formats, root level

* fix response block initial seeding

* fix tests

* fix(messages-input): fix cursor alignment and auto-resize with overlay (#2926)

* fix(messages-input): fix cursor alignment and auto-resize with overlay

* fixed remaining zustand warnings

* fix(stores): remove dead code causing log spam on startup (#2927)

* fix(stores): remove dead code causing log spam on startup

* fix(stores): replace custom tools zustand store with react query cache

* improvement(ui): use BrandedButton and BrandedLink components (#2930)

- Refactor auth forms to use BrandedButton component
- Add BrandedLink component for changelog page
- Reduce code duplication in login, signup, reset-password forms
- Update star count default value

* fix(custom-tools): remove unsafe title fallback in getCustomTool (#2929)

* fix(custom-tools): remove unsafe title fallback in getCustomTool

* fix(custom-tools): restore title fallback in getCustomTool lookup

Custom tools are referenced by title (custom_${title}), not database ID.
The title fallback is required for client-side tool resolution to work.

* fix(null-bodies): empty bodies handling (#2931)

* fix(null-statuses): empty bodies handling

* address bugbot comment

* fix(token-refresh): microsoft, notion, x, linear (#2933)

* fix(microsoft): proactive refresh needed

* fix(x): missing token refresh flag

* notion and linear missing flag too

* address bugbot comment

* fix(auth): handle EMAIL_NOT_VERIFIED in onError callback (#2932)

* fix(auth): handle EMAIL_NOT_VERIFIED in onError callback

* refactor(auth): extract redirectToVerify helper to reduce duplication

* fix(workflow-selector): use dedicated selector for workflow dropdown (#2934)

* feat(workflow-block): preview (#2935)

* improvement(copilot): tool configs to show nested props (#2936)

* fix(auth): add genericOAuth providers to trustedProviders (#2937)

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
2026-01-21 22:53:25 -08:00
Vikhyath Mondreti
45371e521e v0.5.66: external http requests fix, ring highlighting 2026-01-21 02:55:39 -08:00
Waleed
0ce0f98aa5 v0.5.65: gemini updates, textract integration, ui updates (#2909)
* fix(google): wrap primitive tool responses for Gemini API compatibility (#2900)

* fix(canonical): copilot path + update parent (#2901)

* fix(rss): add top-level title, link, pubDate fields to RSS trigger output (#2902)

* fix(rss): add top-level title, link, pubDate fields to RSS trigger output

* fix(imap): add top-level fields to IMAP trigger output

* improvement(browseruse): add profile id param (#2903)

* improvement(browseruse): add profile id param

* make request a stub since we have directExec

* improvement(executor): upgraded abort controller to handle aborts for loops and parallels (#2880)

* improvement(executor): upgraded abort controller to handle aborts for loops and parallels

* comments

* improvement(files): update execution for passing base64 strings (#2906)

* progress

* improvement(execution): update execution for passing base64 strings

* fix types

* cleanup comments

* path security vuln

* reject promise correctly

* fix redirect case

* remove proxy routes

* fix tests

* use ipaddr

* feat(tools): added textract, added v2 for mistral, updated tag dropdown (#2904)

* feat(tools): added textract

* cleanup

* ack pr comments

* reorder

* removed upload for textract async version

* fix additional fields dropdown in editor, update parser to leave validation to be done on the server

* added mistral v2, files v2, and finalized textract

* updated the rest of the old file patterns, updated mistral outputs for v2

* updated tag dropdown to parse non-operation fields as well

* updated extension finder

* cleanup

* added description for inputs to workflow

* use helper for internal route check

* fix tag dropdown merge conflict change

* remove duplicate code

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>

* fix(ui): change add inputs button to match output selector (#2907)

* fix(canvas): removed invite to workspace from canvas popover (#2908)

* fix(canvas): removed invite to workspace

* removed unused props

* fix(copilot): legacy tool display names (#2911)

* fix(a2a): canonical merge  (#2912)

* fix canonical merge

* fix empty array case

* fix(change-detection): copilot diffs have extra field (#2913)

* improvement(logs): improved logs ui bugs, added subflow disable UI (#2910)

* improvement(logs): improved logs ui bugs, added subflow disable UI

* added duplicate to action bar for subflows

* feat(broadcast): email v0.5 (#2905)

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
2026-01-20 23:54:55 -08:00
Waleed
dff1c9d083 v0.5.64: unsubscribe, search improvements, metrics, additional SSO configuration 2026-01-20 00:34:11 -08:00
Vikhyath Mondreti
b09f683072 v0.5.63: ui and performance improvements, more google tools 2026-01-18 15:22:42 -08:00
Vikhyath Mondreti
a8bb0db660 v0.5.62: webhook bug fixes, seeding default subblock values, block selection fixes 2026-01-16 20:27:06 -08:00
Waleed
af82820a28 v0.5.61: webhook improvements, workflow controls, react query for deployment status, chat fixes, reducto and pulse OCR, linear fixes 2026-01-16 18:06:23 -08:00
Waleed
4372841797 v0.5.60: invitation flow improvements, chat fixes, a2a improvements, additional copilot actions 2026-01-15 00:02:18 -08:00
Waleed
5e8c843241 v0.5.59: a2a support, documentation 2026-01-13 13:21:21 -08:00
Waleed
7bf3d73ee6 v0.5.58: export folders, new tools, permissions groups enhancements 2026-01-13 00:56:59 -08:00
Vikhyath Mondreti
7ffc11a738 v0.5.57: subagents, context menu improvements, bug fixes 2026-01-11 11:38:40 -08:00
Waleed
be578e2ed7 v0.5.56: batch operations, access control and permission groups, billing fixes 2026-01-10 00:31:34 -08:00
Waleed
f415e5edc4 v0.5.55: polling groups, bedrock provider, devcontainer fixes, workflow preview enhancements 2026-01-08 23:36:56 -08:00
Waleed
13a6e6c3fa v0.5.54: seo, model blacklist, helm chart updates, fireflies integration, autoconnect improvements, billing fixes 2026-01-07 16:09:45 -08:00
Waleed
f5ab7f21ae v0.5.53: hotkey improvements, added redis fallback, fixes for workflow tool 2026-01-06 23:34:52 -08:00
Waleed
bfb6fffe38 v0.5.52: new port-based router block, combobox expression and variable support 2026-01-06 16:14:10 -08:00
Waleed
4fbec0a43f v0.5.51: triggers, kb, condition block improvements, supabase and grain integration updates 2026-01-06 14:26:46 -08:00
Waleed
585f5e365b v0.5.50: import improvements, ui upgrades, kb styling and performance improvements 2026-01-05 00:35:55 -08:00
Waleed
3792bdd252 v0.5.49: hitl improvements, new email styles, imap trigger, logs context menu (#2672)
* feat(logs-context-menu): consolidated logs utils and types, added logs record context menu (#2659)

* feat(email): welcome email; improvement(emails): ui/ux (#2658)

* feat(email): welcome email; improvement(emails): ui/ux

* improvement(emails): links, accounts, preview

* refactor(emails): file structure and wrapper components

* added envvar for personal emails sent, added isHosted gate

* fixed failing tests, added env mock

* fix: removed comment

---------

Co-authored-by: waleed <walif6@gmail.com>

* fix(logging): hitl + trigger dev crash protection (#2664)

* hitl gaps

* deal with trigger worker crashes

* cleanup import strcuture

* feat(imap): added support for imap trigger (#2663)

* feat(tools): added support for imap trigger

* feat(imap): added parity, tested

* ack PR comments

* final cleanup

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

Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>

* fix(grain): updated grain trigger to auto-establish trigger (#2666)

Co-authored-by: aadamgough <adam@sim.ai>

* feat(admin): routes to manage deployments (#2667)

* feat(admin): routes to manage deployments

* fix naming fo deployed by

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date (#2668)

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date

* removed unused params, cleaned up redundant utils

* improvement(invite): aligned styling (#2669)

* improvement(invite): aligned with rest of app

* fix(invite): error handling

* fix: addressed comments

---------

Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>
Co-authored-by: Adam Gough <77861281+aadamgough@users.noreply.github.com>
Co-authored-by: aadamgough <adam@sim.ai>
2026-01-03 13:19:18 -08:00
Waleed
eb5d1f3e5b v0.5.48: copy-paste workflow blocks, docs updates, mcp tool fixes 2025-12-31 18:00:04 -08:00
Waleed
54ab82c8dd v0.5.47: deploy workflow as mcp, kb chunks tokenizer, UI improvements, jira service management tools 2025-12-30 23:18:58 -08:00
Waleed
f895bf469b v0.5.46: build improvements, greptile, light mode improvements 2025-12-29 02:17:52 -08:00
Waleed
dd3209af06 v0.5.45: light mode fixes, realtime usage indicator, docker build improvements 2025-12-27 19:57:42 -08:00
Waleed
b6ba3b50a7 v0.5.44: keyboard shortcuts, autolayout, light mode, byok, testing improvements 2025-12-26 21:25:19 -08:00
Waleed
b304233062 v0.5.43: export logs, circleback, grain, vertex, code hygiene, schedule improvements 2025-12-23 19:19:18 -08:00
Vikhyath Mondreti
57e4b49bd6 v0.5.42: fix memory migration 2025-12-23 01:24:54 -08:00
Vikhyath Mondreti
e12dd204ed v0.5.41: memory fixes, copilot improvements, knowledgebase improvements, LLM providers standardization 2025-12-23 00:15:18 -08:00
Vikhyath Mondreti
3d9d9cbc54 v0.5.40: supabase ops to allow non-public schemas, jira uuid 2025-12-21 22:28:05 -08:00
Waleed
0f4ec962ad v0.5.39: notion, workflow variables fixes 2025-12-20 20:44:00 -08:00
Waleed
4827866f9a v0.5.38: snap to grid, copilot ux improvements, billing line items 2025-12-20 17:24:38 -08:00
Waleed
3e697d9ed9 v0.5.37: redaction utils consolidation, logs updates, autoconnect improvements, additional kb tag types 2025-12-19 22:31:55 -08:00
Martin Yankov
4431a1a484 fix(helm): add custom egress rules to realtime network policy (#2481)
The realtime service network policy was missing the custom egress rules section
that allows configuration of additional egress rules via values.yaml. This caused
the realtime pods to be unable to connect to external databases (e.g., PostgreSQL
on port 5432) when using external database configurations.

The app network policy already had this section, but the realtime network policy
was missing it, creating an inconsistency and preventing the realtime service
from accessing external databases configured via networkPolicy.egress values.

This fix adds the same custom egress rules template section to the realtime
network policy, matching the app network policy behavior and allowing users to
configure database connectivity via values.yaml.
2025-12-19 18:59:08 -08:00
Waleed
4d1a9a3f22 v0.5.36: hitl improvements, opengraph, slack fixes, one-click unsubscribe, auth checks, new db indexes 2025-12-19 01:27:49 -08:00
Vikhyath Mondreti
eb07a080fb v0.5.35: helm updates, copilot improvements, 404 for docs, salesforce fixes, subflow resize clamping 2025-12-18 16:23:19 -08:00
181 changed files with 3439 additions and 7909 deletions

View File

@@ -44,7 +44,7 @@ services:
deploy: deploy:
resources: resources:
limits: limits:
memory: 1G memory: 4G
environment: environment:
- NODE_ENV=development - NODE_ENV=development
- DATABASE_URL=postgresql://postgres:postgres@db:5432/simstudio - DATABASE_URL=postgresql://postgres:postgres@db:5432/simstudio

View File

@@ -10,9 +10,6 @@ concurrency:
group: ci-${{ github.ref }} group: ci-${{ github.ref }}
cancel-in-progress: false cancel-in-progress: false
permissions:
contents: read
jobs: jobs:
test-build: test-build:
name: Test and Build name: Test and Build
@@ -281,30 +278,3 @@ jobs:
if: needs.check-docs-changes.outputs.docs_changed == 'true' if: needs.check-docs-changes.outputs.docs_changed == 'true'
uses: ./.github/workflows/docs-embeddings.yml uses: ./.github/workflows/docs-embeddings.yml
secrets: inherit secrets: inherit
# Create GitHub Release (only for version commits on main, after all builds complete)
create-release:
name: Create GitHub Release
runs-on: blacksmith-4vcpu-ubuntu-2404
needs: [create-ghcr-manifests, detect-version]
if: needs.detect-version.outputs.is_release == 'true'
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Create release
env:
GH_PAT: ${{ secrets.GITHUB_TOKEN }}
run: bun run scripts/create-single-release.ts ${{ needs.detect-version.outputs.version }}

View File

@@ -4,9 +4,6 @@ on:
workflow_call: workflow_call:
workflow_dispatch: # Allow manual triggering workflow_dispatch: # Allow manual triggering
permissions:
contents: read
jobs: jobs:
process-docs-embeddings: process-docs-embeddings:
name: Process Documentation Embeddings name: Process Documentation Embeddings

View File

@@ -4,9 +4,6 @@ on:
workflow_call: workflow_call:
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
jobs: jobs:
migrate: migrate:
name: Apply Database Migrations name: Apply Database Migrations

View File

@@ -6,9 +6,6 @@ on:
paths: paths:
- 'packages/cli/**' - 'packages/cli/**'
permissions:
contents: read
jobs: jobs:
publish-npm: publish-npm:
runs-on: blacksmith-4vcpu-ubuntu-2404 runs-on: blacksmith-4vcpu-ubuntu-2404

View File

@@ -6,9 +6,6 @@ on:
paths: paths:
- 'packages/python-sdk/**' - 'packages/python-sdk/**'
permissions:
contents: write
jobs: jobs:
publish-pypi: publish-pypi:
runs-on: blacksmith-4vcpu-ubuntu-2404 runs-on: blacksmith-4vcpu-ubuntu-2404

View File

@@ -6,9 +6,6 @@ on:
paths: paths:
- 'packages/ts-sdk/**' - 'packages/ts-sdk/**'
permissions:
contents: write
jobs: jobs:
publish-npm: publish-npm:
runs-on: blacksmith-4vcpu-ubuntu-2404 runs-on: blacksmith-4vcpu-ubuntu-2404

View File

@@ -4,9 +4,6 @@ on:
workflow_call: workflow_call:
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
jobs: jobs:
test-build: test-build:
name: Test and Build name: Test and Build

View File

@@ -185,6 +185,11 @@ export default async function Page(props: { params: Promise<{ slug?: string[]; l
tableOfContent={{ tableOfContent={{
style: 'clerk', style: 'clerk',
enabled: true, enabled: true,
header: (
<div key='toc-header' className='mb-2 font-medium text-sm'>
On this page
</div>
),
footer: <TOCFooter />, footer: <TOCFooter />,
single: false, single: false,
}} }}

View File

@@ -3,13 +3,13 @@ import { defineI18nUI } from 'fumadocs-ui/i18n'
import { DocsLayout } from 'fumadocs-ui/layouts/docs' import { DocsLayout } from 'fumadocs-ui/layouts/docs'
import { RootProvider } from 'fumadocs-ui/provider/next' import { RootProvider } from 'fumadocs-ui/provider/next'
import { Geist_Mono, Inter } from 'next/font/google' import { Geist_Mono, Inter } from 'next/font/google'
import Image from 'next/image'
import { import {
SidebarFolder, SidebarFolder,
SidebarItem, SidebarItem,
SidebarSeparator, SidebarSeparator,
} from '@/components/docs-layout/sidebar-components' } from '@/components/docs-layout/sidebar-components'
import { Navbar } from '@/components/navbar/navbar' import { Navbar } from '@/components/navbar/navbar'
import { SimLogoFull } from '@/components/ui/sim-logo'
import { i18n } from '@/lib/i18n' import { i18n } from '@/lib/i18n'
import { source } from '@/lib/source' import { source } from '@/lib/source'
import '../global.css' import '../global.css'
@@ -102,7 +102,16 @@ export default async function Layout({ children, params }: LayoutProps) {
<DocsLayout <DocsLayout
tree={source.pageTree[lang]} tree={source.pageTree[lang]}
nav={{ nav={{
title: <SimLogoFull className='h-7 w-auto' />, title: (
<Image
src='/static/logo.png'
alt='Sim'
width={72}
height={28}
className='h-7 w-auto'
priority
/>
),
}} }}
sidebar={{ sidebar={{
defaultOpenLevel: 0, defaultOpenLevel: 0,

View File

@@ -33,41 +33,15 @@ async function loadGoogleFont(font: string, weights: string, text: string): Prom
throw new Error('Failed to load font data') throw new Error('Failed to load font data')
} }
/**
* Sim logo with icon and "Sim" text for OG image.
*/
function SimLogoFull() {
return (
<svg height='28' viewBox='720 440 1020 320' fill='none'>
{/* Green icon - top left shape with cutout */}
<path
fillRule='evenodd'
clipRule='evenodd'
d='M875.791 577.171C875.791 581.922 873.911 586.483 870.576 589.842L870.098 590.323C866.764 593.692 862.234 595.575 857.517 595.575H750.806C740.978 595.575 733 603.6 733 613.498V728.902C733 738.799 740.978 746.826 750.806 746.826H865.382C875.209 746.826 883.177 738.799 883.177 728.902V620.853C883.177 616.448 884.912 612.222 888.008 609.104C891.093 605.997 895.29 604.249 899.664 604.249H1008.16C1017.99 604.249 1025.96 596.224 1025.96 586.327V470.923C1025.96 461.025 1017.99 453 1008.16 453H893.586C883.759 453 875.791 461.025 875.791 470.923V577.171ZM910.562 477.566H991.178C996.922 477.566 1001.57 482.254 1001.57 488.029V569.22C1001.57 574.995 996.922 579.683 991.178 579.683H910.562C904.828 579.683 900.173 574.995 900.173 569.22V488.029C900.173 482.254 904.828 477.566 910.562 477.566Z'
fill='#33C482'
/>
{/* Green icon - bottom right square */}
<path
d='M1008.3 624.59H923.113C912.786 624.59 904.414 633.022 904.414 643.423V728.171C904.414 738.572 912.786 747.004 923.113 747.004H1008.3C1018.63 747.004 1027 738.572 1027 728.171V643.423C1027 633.022 1018.63 624.59 1008.3 624.59Z'
fill='#33C482'
/>
{/* "Sim" text - white for dark background */}
<path
d='M1210.54 515.657C1226.65 515.657 1240.59 518.51 1252.31 524.257H1252.31C1264.3 529.995 1273.63 538.014 1280.26 548.319H1280.26C1287.19 558.635 1290.78 570.899 1291.08 585.068L1291.1 586.089H1249.11L1249.09 585.115C1248.8 574.003 1245.18 565.493 1238.32 559.451C1231.45 553.399 1221.79 550.308 1209.21 550.308C1196.3 550.308 1186.48 553.113 1179.61 558.588C1172.76 564.046 1169.33 571.499 1169.33 581.063C1169.33 588.092 1171.88 593.978 1177.01 598.783C1182.17 603.618 1189.99 607.399 1200.56 610.061H1200.56L1238.77 619.451C1257.24 623.65 1271.21 630.571 1280.57 640.293L1281.01 640.739C1290.13 650.171 1294.64 662.97 1294.64 679.016C1294.64 692.923 1290.88 705.205 1283.34 715.822L1283.33 715.834C1275.81 726.134 1265.44 734.14 1252.26 739.866L1252.25 739.871C1239.36 745.302 1224.12 748 1206.54 748C1180.9 748 1160.36 741.696 1145.02 728.984C1129.67 716.258 1122 699.269 1122 678.121V677.121H1163.99V678.121C1163.99 688.869 1167.87 697.367 1175.61 703.722L1176.34 704.284C1184.04 709.997 1194.37 712.902 1207.43 712.902C1222.13 712.902 1233.3 710.087 1241.07 704.588C1248.8 698.812 1252.64 691.21 1252.64 681.699C1252.64 674.769 1250.5 669.057 1246.25 664.49L1246.23 664.478L1246.22 664.464C1242.28 659.929 1234.83 656.119 1223.64 653.152L1185.43 644.208L1185.42 644.204C1166.05 639.407 1151.49 632.035 1141.83 622.012L1141.83 622.006L1141.82 622C1132.43 611.94 1127.78 598.707 1127.78 582.405C1127.78 568.81 1131.23 556.976 1138.17 546.949L1138.18 546.941L1138.19 546.933C1145.41 536.936 1155.18 529.225 1167.48 523.793L1167.48 523.79C1180.07 518.36 1194.43 515.657 1210.54 515.657ZM1323.39 521.979C1331.68 525.008 1337.55 526.482 1343.51 526.482C1349.48 526.482 1355.64 525.005 1364.49 521.973L1365.82 521.52V742.633H1322.05V521.489L1323.39 521.979ZM1642.01 515.657C1667.11 515.657 1686.94 523.031 1701.39 537.876C1715.83 552.716 1723 572.968 1723 598.507V742.633H1680.12V608.794C1680.12 591.666 1675.72 578.681 1667.07 569.681L1667.06 569.669L1667.04 569.656C1658.67 560.359 1647.26 555.675 1632.68 555.675C1622.47 555.675 1613.47 558.022 1605.64 562.69L1605.63 562.696C1598.11 567.064 1592.17 573.475 1587.8 581.968C1583.44 590.448 1581.25 600.424 1581.25 611.925V742.633H1537.92V608.347C1537.92 591.208 1533.67 578.376 1525.31 569.68L1525.31 569.674L1525.3 569.668C1516.93 560.664 1505.52 556.122 1490.93 556.122C1480.72 556.122 1471.72 558.469 1463.89 563.138L1463.88 563.144C1456.36 567.511 1450.41 573.922 1446.05 582.415L1446.05 582.422L1446.04 582.428C1441.69 590.602 1439.5 600.423 1439.5 611.925V742.633H1395.72V521.919H1435.05V554.803C1439.92 544.379 1447.91 535.465 1458.37 528.356C1470.71 519.875 1485.58 515.657 1502.93 515.657C1522.37 515.657 1538.61 520.931 1551.55 531.538C1560.38 538.771 1567.1 547.628 1571.72 558.091C1576.05 547.619 1582.83 538.757 1592.07 531.524C1605.61 520.93 1622.28 515.657 1642.01 515.657ZM1343.49 452C1351.45 452 1358.23 454.786 1363.75 460.346C1369.27 465.905 1372.04 472.721 1372.04 480.73C1372.04 488.452 1369.27 495.254 1363.77 501.096L1363.76 501.105L1363.75 501.115C1358.23 506.675 1351.45 509.461 1343.49 509.461C1335.81 509.461 1329.05 506.669 1323.25 501.134L1323.23 501.115L1323.21 501.096C1317.71 495.254 1314.94 488.452 1314.94 480.73C1314.94 472.721 1317.7 465.905 1323.23 460.346L1323.24 460.337L1323.25 460.327C1329.05 454.792 1335.81 452 1343.49 452Z'
fill='#fafafa'
/>
</svg>
)
}
/** /**
* Generates dynamic Open Graph images for documentation pages. * Generates dynamic Open Graph images for documentation pages.
* Style matches Cursor docs: dark background, title at top, logo bottom-left, domain bottom-right.
*/ */
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const title = searchParams.get('title') || 'Documentation' const title = searchParams.get('title') || 'Documentation'
const baseUrl = new URL(request.url).origin
const allText = `${title}docs.sim.ai` const allText = `${title}docs.sim.ai`
const fontData = await loadGoogleFont('Geist', '400;500;600', allText) const fontData = await loadGoogleFont('Geist', '400;500;600', allText)
@@ -78,39 +52,84 @@ export async function GET(request: NextRequest) {
width: '100%', width: '100%',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
justifyContent: 'space-between', background: '#0c0c0c',
padding: '56px 64px', position: 'relative',
background: '#121212', // Dark mode background matching docs (hsla 0, 0%, 7%)
fontFamily: 'Geist', fontFamily: 'Geist',
}} }}
> >
{/* Title at top */} {/* Base gradient layer - subtle purple tint across the entire image */}
<span <div
style={{ style={{
fontSize: getTitleFontSize(title), position: 'absolute',
fontWeight: 500, top: 0,
color: '#fafafa', // Light text matching docs left: 0,
lineHeight: 1.2, width: '100%',
letterSpacing: '-0.02em', height: '100%',
background:
'radial-gradient(ellipse 150% 100% at 50% 100%, rgba(88, 28, 135, 0.15) 0%, rgba(88, 28, 135, 0.08) 25%, rgba(88, 28, 135, 0.03) 50%, transparent 80%)',
display: 'flex',
}} }}
> />
{title}
</span>
{/* Footer: icon left, domain right */} {/* Secondary glow - adds depth without harsh edges */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
background:
'radial-gradient(ellipse 100% 80% at 80% 90%, rgba(112, 31, 252, 0.12) 0%, rgba(112, 31, 252, 0.04) 40%, transparent 70%)',
display: 'flex',
}}
/>
{/* Top darkening - creates natural vignette */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
background:
'linear-gradient(180deg, rgba(0, 0, 0, 0.3) 0%, transparent 40%, transparent 100%)',
display: 'flex',
}}
/>
{/* Content */}
<div <div
style={{ style={{
display: 'flex', display: 'flex',
flexDirection: 'column',
padding: '56px 72px',
height: '100%',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
}} }}
> >
<SimLogoFull /> {/* Logo */}
<img src={`${baseUrl}/static/logo.png`} alt='sim' height={32} />
{/* Title */}
<span
style={{
fontSize: getTitleFontSize(title),
fontWeight: 600,
color: '#ffffff',
lineHeight: 1.1,
letterSpacing: '-0.02em',
}}
>
{title}
</span>
{/* Footer */}
<span <span
style={{ style={{
fontSize: 20, fontSize: 20,
fontWeight: 400, fontWeight: 500,
color: '#71717a', color: '#71717a',
}} }}
> >

View File

@@ -9,20 +9,11 @@ body {
} }
@theme { @theme {
--color-fd-primary: #33c482; /* Green from Sim logo */ --color-fd-primary: #802fff; /* Purple from control-bar component */
--font-geist-sans: var(--font-geist-sans); --font-geist-sans: var(--font-geist-sans);
--font-geist-mono: var(--font-geist-mono); --font-geist-mono: var(--font-geist-mono);
} }
/* Ensure primary color is set in both light and dark modes */
:root {
--color-fd-primary: #33c482;
}
.dark {
--color-fd-primary: #33c482;
}
/* Font family utilities */ /* Font family utilities */
.font-sans { .font-sans {
font-family: var(--font-geist-sans), ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, font-family: var(--font-geist-sans), ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
@@ -43,7 +34,7 @@ body {
:root { :root {
--fd-border: transparent !important; --fd-border: transparent !important;
--fd-border-sidebar: transparent !important; --fd-border-sidebar: transparent !important;
--fd-nav-height: 65px; /* Custom navbar height (h-16 = 64px + 1px border) */ --fd-nav-height: 64px; /* Custom navbar height (h-16 = 4rem = 64px) */
/* Content container width used to center main content */ /* Content container width used to center main content */
--spacing-fd-container: 1400px; --spacing-fd-container: 1400px;
/* Edge gutter = leftover space on each side of centered container */ /* Edge gutter = leftover space on each side of centered container */
@@ -145,11 +136,11 @@ aside#nd-sidebar {
/* On mobile, let fumadocs handle the layout natively */ /* On mobile, let fumadocs handle the layout natively */
@media (min-width: 1024px) { @media (min-width: 1024px) {
:root { :root {
--fd-banner-height: 65px !important; /* 64px navbar + 1px border */ --fd-banner-height: 64px !important;
} }
#nd-docs-layout { #nd-docs-layout {
--fd-docs-height: calc(100dvh - 65px) !important; /* 64px navbar + 1px border */ --fd-docs-height: calc(100dvh - 64px) !important;
--fd-sidebar-width: 300px !important; --fd-sidebar-width: 300px !important;
margin-left: var(--sidebar-offset) !important; margin-left: var(--sidebar-offset) !important;
margin-right: var(--toc-offset) !important; margin-right: var(--toc-offset) !important;
@@ -236,19 +227,19 @@ html:not(.dark) #nd-sidebar button:not([aria-label*="ollapse"]):not([aria-label*
letter-spacing: 0.05em !important; letter-spacing: 0.05em !important;
} }
/* Override active state */ /* Override active state (NO PURPLE) */
#nd-sidebar a[data-active="true"], #nd-sidebar a[data-active="true"],
#nd-sidebar button[data-active="true"], #nd-sidebar button[data-active="true"],
#nd-sidebar a.bg-fd-primary\/10, #nd-sidebar a.bg-fd-primary\/10,
#nd-sidebar a.text-fd-primary, #nd-sidebar a.text-fd-primary,
#nd-sidebar a[class*="bg-fd-primary"], #nd-sidebar a[class*="bg-fd-primary"],
#nd-sidebar a[class*="text-fd-primary"], #nd-sidebar a[class*="text-fd-primary"],
/* Override custom sidebar green classes */ /* Override custom sidebar purple classes */
#nd-sidebar #nd-sidebar
a.bg-emerald-50\/80, a.bg-purple-50\/80,
#nd-sidebar a.text-emerald-600, #nd-sidebar a.text-purple-600,
#nd-sidebar a[class*="bg-emerald"], #nd-sidebar a[class*="bg-purple"],
#nd-sidebar a[class*="text-emerald"] { #nd-sidebar a[class*="text-purple"] {
background-image: none !important; background-image: none !important;
} }
@@ -259,10 +250,10 @@ html.dark #nd-sidebar a.bg-fd-primary\/10,
html.dark #nd-sidebar a.text-fd-primary, html.dark #nd-sidebar a.text-fd-primary,
html.dark #nd-sidebar a[class*="bg-fd-primary"], html.dark #nd-sidebar a[class*="bg-fd-primary"],
html.dark #nd-sidebar a[class*="text-fd-primary"], html.dark #nd-sidebar a[class*="text-fd-primary"],
html.dark #nd-sidebar a.bg-emerald-50\/80, html.dark #nd-sidebar a.bg-purple-50\/80,
html.dark #nd-sidebar a.text-emerald-600, html.dark #nd-sidebar a.text-purple-600,
html.dark #nd-sidebar a[class*="bg-emerald"], html.dark #nd-sidebar a[class*="bg-purple"],
html.dark #nd-sidebar a[class*="text-emerald"] { html.dark #nd-sidebar a[class*="text-purple"] {
background-color: rgba(255, 255, 255, 0.15) !important; background-color: rgba(255, 255, 255, 0.15) !important;
color: rgba(255, 255, 255, 1) !important; color: rgba(255, 255, 255, 1) !important;
} }
@@ -274,10 +265,10 @@ html:not(.dark) #nd-sidebar a.bg-fd-primary\/10,
html:not(.dark) #nd-sidebar a.text-fd-primary, html:not(.dark) #nd-sidebar a.text-fd-primary,
html:not(.dark) #nd-sidebar a[class*="bg-fd-primary"], html:not(.dark) #nd-sidebar a[class*="bg-fd-primary"],
html:not(.dark) #nd-sidebar a[class*="text-fd-primary"], html:not(.dark) #nd-sidebar a[class*="text-fd-primary"],
html:not(.dark) #nd-sidebar a.bg-emerald-50\/80, html:not(.dark) #nd-sidebar a.bg-purple-50\/80,
html:not(.dark) #nd-sidebar a.text-emerald-600, html:not(.dark) #nd-sidebar a.text-purple-600,
html:not(.dark) #nd-sidebar a[class*="bg-emerald"], html:not(.dark) #nd-sidebar a[class*="bg-purple"],
html:not(.dark) #nd-sidebar a[class*="text-emerald"] { html:not(.dark) #nd-sidebar a[class*="text-purple"] {
background-color: rgba(0, 0, 0, 0.07) !important; background-color: rgba(0, 0, 0, 0.07) !important;
color: rgba(0, 0, 0, 0.9) !important; color: rgba(0, 0, 0, 0.9) !important;
} }
@@ -295,8 +286,8 @@ html:not(.dark) #nd-sidebar button:hover:not([data-active="true"]) {
} }
/* Dark mode - ensure active/selected items don't change on hover */ /* Dark mode - ensure active/selected items don't change on hover */
html.dark #nd-sidebar a.bg-emerald-50\/80:hover, html.dark #nd-sidebar a.bg-purple-50\/80:hover,
html.dark #nd-sidebar a[class*="bg-emerald"]:hover, html.dark #nd-sidebar a[class*="bg-purple"]:hover,
html.dark #nd-sidebar a[data-active="true"]:hover, html.dark #nd-sidebar a[data-active="true"]:hover,
html.dark #nd-sidebar button[data-active="true"]:hover { html.dark #nd-sidebar button[data-active="true"]:hover {
background-color: rgba(255, 255, 255, 0.15) !important; background-color: rgba(255, 255, 255, 0.15) !important;
@@ -304,8 +295,8 @@ html.dark #nd-sidebar button[data-active="true"]:hover {
} }
/* Light mode - ensure active/selected items don't change on hover */ /* Light mode - ensure active/selected items don't change on hover */
html:not(.dark) #nd-sidebar a.bg-emerald-50\/80:hover, html:not(.dark) #nd-sidebar a.bg-purple-50\/80:hover,
html:not(.dark) #nd-sidebar a[class*="bg-emerald"]:hover, html:not(.dark) #nd-sidebar a[class*="bg-purple"]:hover,
html:not(.dark) #nd-sidebar a[data-active="true"]:hover, html:not(.dark) #nd-sidebar a[data-active="true"]:hover,
html:not(.dark) #nd-sidebar button[data-active="true"]:hover { html:not(.dark) #nd-sidebar button[data-active="true"]:hover {
background-color: rgba(0, 0, 0, 0.07) !important; background-color: rgba(0, 0, 0, 0.07) !important;
@@ -377,24 +368,16 @@ aside[data-sidebar] > *:not([data-sidebar-viewport]) {
button[aria-label="Toggle Sidebar"], button[aria-label="Toggle Sidebar"],
button[aria-label="Collapse Sidebar"], button[aria-label="Collapse Sidebar"],
/* Hide nav title/logo in sidebar on desktop - target all possible locations */ /* Hide nav title/logo in sidebar on desktop - target all possible locations */
/* Lower specificity selectors first (attribute selectors) */
[data-sidebar-header],
[data-sidebar] [data-title],
aside[data-sidebar] a[href="/"], aside[data-sidebar] a[href="/"],
aside[data-sidebar] a[href="/"] img, aside[data-sidebar] a[href="/"] img,
aside[data-sidebar] > a:first-child, aside[data-sidebar] > a:first-child,
aside[data-sidebar] > div > a:first-child, aside[data-sidebar] > div > a:first-child,
aside[data-sidebar] img[alt="Sim"], aside[data-sidebar] img[alt="Sim"],
aside[data-sidebar] svg[aria-label="Sim"], [data-sidebar-header],
/* Higher specificity selectors (ID selectors) */ [data-sidebar] [data-title],
#nd-sidebar
a[href="/"],
#nd-sidebar a[href="/"] img,
#nd-sidebar a[href="/"] svg,
#nd-sidebar > a:first-child, #nd-sidebar > a:first-child,
#nd-sidebar > div:first-child > a:first-child, #nd-sidebar > div:first-child > a:first-child,
#nd-sidebar img[alt="Sim"], #nd-sidebar img[alt="Sim"],
#nd-sidebar svg[aria-label="Sim"],
/* Hide theme toggle at bottom of sidebar on desktop */ /* Hide theme toggle at bottom of sidebar on desktop */
#nd-sidebar #nd-sidebar
> footer, > footer,
@@ -532,15 +515,6 @@ pre code .line {
color: var(--color-fd-primary); color: var(--color-fd-primary);
} }
/* ============================================
TOC (Table of Contents) Styling
============================================ */
/* Remove the thin border-left on nested TOC items (keeps main indicator only) */
#nd-toc a[style*="padding-inline-start"] {
border-left: none !important;
}
/* Add bottom spacing to prevent abrupt page endings */ /* Add bottom spacing to prevent abrupt page endings */
[data-content] { [data-content] {
padding-top: 1.5rem !important; padding-top: 1.5rem !important;

View File

@@ -44,7 +44,7 @@ export function SidebarItem({ item }: { item: Item }) {
'lg:text-gray-600 lg:dark:text-gray-400', 'lg:text-gray-600 lg:dark:text-gray-400',
!active && 'lg:hover:bg-gray-100/60 lg:dark:hover:bg-gray-800/40', !active && 'lg:hover:bg-gray-100/60 lg:dark:hover:bg-gray-800/40',
active && active &&
'lg:bg-emerald-50/80 lg:font-normal lg:text-emerald-600 lg:dark:bg-emerald-900/15 lg:dark:text-emerald-400' 'lg:bg-purple-50/80 lg:font-normal lg:text-purple-600 lg:dark:bg-purple-900/15 lg:dark:text-purple-400'
)} )}
> >
{item.name} {item.name}
@@ -79,7 +79,7 @@ export function SidebarFolder({ item, children }: { item: Folder; children: Reac
'lg:text-gray-600 lg:dark:text-gray-400', 'lg:text-gray-600 lg:dark:text-gray-400',
!active && 'lg:hover:bg-gray-100/60 lg:dark:hover:bg-gray-800/40', !active && 'lg:hover:bg-gray-100/60 lg:dark:hover:bg-gray-800/40',
active && active &&
'lg:bg-emerald-50/80 lg:font-normal lg:text-emerald-600 lg:dark:bg-emerald-900/15 lg:dark:text-emerald-400' 'lg:bg-purple-50/80 lg:font-normal lg:text-purple-600 lg:dark:bg-purple-900/15 lg:dark:text-purple-400'
)} )}
> >
{item.name} {item.name}
@@ -104,7 +104,7 @@ export function SidebarFolder({ item, children }: { item: Folder; children: Reac
'lg:text-gray-800 lg:dark:text-gray-200', 'lg:text-gray-800 lg:dark:text-gray-200',
!active && 'lg:hover:bg-gray-100/60 lg:dark:hover:bg-gray-800/40', !active && 'lg:hover:bg-gray-100/60 lg:dark:hover:bg-gray-800/40',
active && active &&
'lg:bg-emerald-50/80 lg:text-emerald-600 lg:dark:bg-emerald-900/15 lg:dark:text-emerald-400' 'lg:bg-purple-50/80 lg:text-purple-600 lg:dark:bg-purple-900/15 lg:dark:text-purple-400'
)} )}
> >
{item.name} {item.name}

View File

@@ -23,7 +23,7 @@ export function TOCFooter() {
rel='noopener noreferrer' rel='noopener noreferrer'
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
className='group mt-2 inline-flex h-8 w-fit items-center justify-center gap-1 whitespace-nowrap rounded-[10px] border border-[#2AAD6C] bg-gradient-to-b from-[#3ED990] to-[#2AAD6C] px-3 pr-[10px] pl-[12px] font-medium text-sm text-white shadow-[inset_0_2px_4px_0_#5EE8A8] outline-none transition-all hover:shadow-lg focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50' className='group mt-2 inline-flex h-8 w-fit items-center justify-center gap-1 whitespace-nowrap rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] px-3 pr-[10px] pl-[12px] font-medium text-sm text-white shadow-[inset_0_2px_4px_0_#9B77FF] outline-none transition-all hover:shadow-lg focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50'
aria-label='Get started with Sim - Sign up for free' aria-label='Get started with Sim - Sign up for free'
> >
<span>Get started</span> <span>Get started</span>

View File

@@ -1,9 +1,9 @@
'use client' 'use client'
import Image from 'next/image'
import Link from 'next/link' import Link from 'next/link'
import { LanguageDropdown } from '@/components/ui/language-dropdown' import { LanguageDropdown } from '@/components/ui/language-dropdown'
import { SearchTrigger } from '@/components/ui/search-trigger' import { SearchTrigger } from '@/components/ui/search-trigger'
import { SimLogoFull } from '@/components/ui/sim-logo'
import { ThemeToggle } from '@/components/ui/theme-toggle' import { ThemeToggle } from '@/components/ui/theme-toggle'
export function Navbar() { export function Navbar() {
@@ -27,7 +27,13 @@ export function Navbar() {
{/* Left cluster: logo */} {/* Left cluster: logo */}
<div className='flex items-center'> <div className='flex items-center'>
<Link href='/' className='flex min-w-[100px] items-center'> <Link href='/' className='flex min-w-[100px] items-center'>
<SimLogoFull className='h-7 w-auto' /> <Image
src='/static/logo.png'
alt='Sim'
width={72}
height={28}
className='h-7 w-auto'
/>
</Link> </Link>
</div> </div>

View File

@@ -1,87 +0,0 @@
'use client'
import { useState } from 'react'
import { cn, getAssetUrl } from '@/lib/utils'
import { Lightbox } from './lightbox'
interface ActionImageProps {
src: string
alt: string
enableLightbox?: boolean
}
interface ActionVideoProps {
src: string
alt: string
enableLightbox?: boolean
}
export function ActionImage({ src, alt, enableLightbox = true }: ActionImageProps) {
const [isLightboxOpen, setIsLightboxOpen] = useState(false)
const handleClick = () => {
if (enableLightbox) {
setIsLightboxOpen(true)
}
}
return (
<>
<img
src={src}
alt={alt}
onClick={handleClick}
className={cn(
'inline-block w-full max-w-[200px] rounded border border-neutral-200 dark:border-neutral-700',
enableLightbox && 'cursor-pointer transition-opacity hover:opacity-90'
)}
/>
{enableLightbox && (
<Lightbox
isOpen={isLightboxOpen}
onClose={() => setIsLightboxOpen(false)}
src={src}
alt={alt}
type='image'
/>
)}
</>
)
}
export function ActionVideo({ src, alt, enableLightbox = true }: ActionVideoProps) {
const [isLightboxOpen, setIsLightboxOpen] = useState(false)
const resolvedSrc = getAssetUrl(src)
const handleClick = () => {
if (enableLightbox) {
setIsLightboxOpen(true)
}
}
return (
<>
<video
src={resolvedSrc}
autoPlay
loop
muted
playsInline
onClick={handleClick}
className={cn(
'inline-block w-full max-w-[200px] rounded border border-neutral-200 dark:border-neutral-700',
enableLightbox && 'cursor-pointer transition-opacity hover:opacity-90'
)}
/>
{enableLightbox && (
<Lightbox
isOpen={isLightboxOpen}
onClose={() => setIsLightboxOpen(false)}
src={src}
alt={alt}
type='video'
/>
)}
</>
)
}

View File

@@ -1,9 +1,8 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Check, ChevronDown } from 'lucide-react' import { Check, ChevronRight } from 'lucide-react'
import { useParams, usePathname, useRouter } from 'next/navigation' import { useParams, usePathname, useRouter } from 'next/navigation'
import { cn } from '@/lib/utils'
const languages = { const languages = {
en: { name: 'English', flag: '🇺🇸' }, en: { name: 'English', flag: '🇺🇸' },
@@ -16,7 +15,6 @@ const languages = {
export function LanguageDropdown() { export function LanguageDropdown() {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const [hoveredIndex, setHoveredIndex] = useState<number>(-1)
const pathname = usePathname() const pathname = usePathname()
const params = useParams() const params = useParams()
const router = useRouter() const router = useRouter()
@@ -73,15 +71,6 @@ export function LanguageDropdown() {
return () => window.removeEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey)
}, [isOpen]) }, [isOpen])
// Reset hovered index when popover closes
useEffect(() => {
if (!isOpen) {
setHoveredIndex(-1)
}
}, [isOpen])
const languageEntries = Object.entries(languages)
return ( return (
<div className='relative'> <div className='relative'>
<button <button
@@ -93,14 +82,14 @@ export function LanguageDropdown() {
aria-haspopup='listbox' aria-haspopup='listbox'
aria-expanded={isOpen} aria-expanded={isOpen}
aria-controls='language-menu' aria-controls='language-menu'
className='flex cursor-pointer items-center gap-1.5 rounded-[6px] px-3 py-2 font-normal text-[0.9375rem] text-foreground/60 leading-[1.4] transition-colors hover:bg-foreground/8 hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring' className='flex cursor-pointer items-center gap-1.5 rounded-xl px-3 py-2 font-normal text-[0.9375rem] text-foreground/60 leading-[1.4] transition-colors hover:bg-foreground/8 hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring'
style={{ style={{
fontFamily: fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
}} }}
> >
<span>{languages[currentLang as keyof typeof languages]?.name}</span> <span>{languages[currentLang as keyof typeof languages]?.name}</span>
<ChevronDown className={cn('h-3.5 w-3.5 transition-transform', isOpen && 'rotate-180')} /> <ChevronRight className='h-3.5 w-3.5' />
</button> </button>
{isOpen && ( {isOpen && (
@@ -109,37 +98,29 @@ export function LanguageDropdown() {
<div <div
id='language-menu' id='language-menu'
role='listbox' role='listbox'
className='absolute top-full right-0 z-[1001] mt-2 max-h-[400px] min-w-[160px] overflow-auto rounded-[6px] bg-white px-[6px] py-[6px] shadow-lg dark:bg-neutral-900' className='absolute top-full right-0 z-[1001] mt-1 max-h-[75vh] w-56 overflow-auto rounded-xl border border-border/50 bg-white shadow-2xl md:w-44 md:bg-background/95 md:backdrop-blur-md dark:bg-neutral-950 md:dark:bg-background/95'
> >
{languageEntries.map(([code, lang], index) => { {Object.entries(languages).map(([code, lang]) => (
const isSelected = currentLang === code <button
const isHovered = hoveredIndex === index key={code}
onClick={(e) => {
return ( e.preventDefault()
<button e.stopPropagation()
key={code} handleLanguageChange(code)
onClick={(e) => { }}
e.preventDefault() role='option'
e.stopPropagation() aria-selected={currentLang === code}
handleLanguageChange(code) className={`flex w-full cursor-pointer items-center gap-3 px-3 py-3 text-base transition-colors first:rounded-t-xl last:rounded-b-xl hover:bg-muted/80 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring md:gap-2 md:px-2.5 md:py-2 md:text-sm ${
}} currentLang === code ? 'bg-muted/60 font-medium text-primary' : 'text-foreground'
onMouseEnter={() => setHoveredIndex(index)} }`}
onMouseLeave={() => setHoveredIndex(-1)} >
role='option' <span className='text-base md:text-sm'>{lang.flag}</span>
aria-selected={isSelected} <span className='leading-none'>{lang.name}</span>
className={cn( {currentLang === code && (
'flex h-[26px] w-full min-w-0 cursor-pointer items-center gap-[8px] rounded-[6px] px-[6px] text-[13px] transition-colors', <Check className='ml-auto h-4 w-4 text-primary md:h-3.5 md:w-3.5' />
'text-neutral-700 dark:text-neutral-200', )}
isHovered && 'bg-neutral-100 dark:bg-neutral-800', </button>
'focus:outline-none' ))}
)}
>
<span className='text-[13px]'>{lang.flag}</span>
<span className='flex-1 text-left leading-none'>{lang.name}</span>
{isSelected && <Check className='ml-auto h-3.5 w-3.5' />}
</button>
)
})}
</div> </div>
</> </>
)} )}

View File

@@ -1,108 +0,0 @@
'use client'
import { cn } from '@/lib/utils'
interface SimLogoProps {
className?: string
}
/**
* Sim logo with icon and text.
* The icon stays green (#33C482), text adapts to light/dark mode.
*/
export function SimLogo({ className }: SimLogoProps) {
return (
<svg
viewBox='720 440 320 320'
fill='none'
xmlns='http://www.w3.org/2000/svg'
className={cn('h-7 w-auto', className)}
aria-label='Sim'
>
{/* Green icon - top left shape with cutout */}
<path
fillRule='evenodd'
clipRule='evenodd'
d='M875.791 577.171C875.791 581.922 873.911 586.483 870.576 589.842L870.098 590.323C866.764 593.692 862.234 595.575 857.517 595.575H750.806C740.978 595.575 733 603.6 733 613.498V728.902C733 738.799 740.978 746.826 750.806 746.826H865.382C875.209 746.826 883.177 738.799 883.177 728.902V620.853C883.177 616.448 884.912 612.222 888.008 609.104C891.093 605.997 895.29 604.249 899.664 604.249H1008.16C1017.99 604.249 1025.96 596.224 1025.96 586.327V470.923C1025.96 461.025 1017.99 453 1008.16 453H893.586C883.759 453 875.791 461.025 875.791 470.923V577.171ZM910.562 477.566H991.178C996.922 477.566 1001.57 482.254 1001.57 488.029V569.22C1001.57 574.995 996.922 579.683 991.178 579.683H910.562C904.828 579.683 900.173 574.995 900.173 569.22V488.029C900.173 482.254 904.828 477.566 910.562 477.566Z'
fill='#33C482'
/>
{/* Green icon - bottom right square */}
<path
d='M1008.3 624.59H923.113C912.786 624.59 904.414 633.022 904.414 643.423V728.171C904.414 738.572 912.786 747.004 923.113 747.004H1008.3C1018.63 747.004 1027 738.572 1027 728.171V643.423C1027 633.022 1018.63 624.59 1008.3 624.59Z'
fill='#33C482'
/>
{/* Gradient overlay on bottom right square */}
<path
d='M1008.3 624.199H923.113C912.786 624.199 904.414 632.631 904.414 643.033V727.78C904.414 738.181 912.786 746.612 923.113 746.612H1008.3C1018.63 746.612 1027 738.181 1027 727.78V643.033C1027 632.631 1018.63 624.199 1008.3 624.199Z'
fill='url(#sim-logo-gradient)'
fillOpacity='0.2'
/>
<defs>
<linearGradient
id='sim-logo-gradient'
x1='904.414'
y1='624.199'
x2='978.836'
y2='698.447'
gradientUnits='userSpaceOnUse'
>
<stop />
<stop offset='1' stopOpacity='0' />
</linearGradient>
</defs>
</svg>
)
}
/**
* Full Sim logo with icon and "Sim" text.
* The icon stays green (#33C482), text adapts to light/dark mode.
*/
export function SimLogoFull({ className }: SimLogoProps) {
return (
<svg
viewBox='720 440 1020 320'
fill='none'
xmlns='http://www.w3.org/2000/svg'
className={cn('h-7 w-auto', className)}
aria-label='Sim'
>
{/* Green icon - top left shape with cutout */}
<path
fillRule='evenodd'
clipRule='evenodd'
d='M875.791 577.171C875.791 581.922 873.911 586.483 870.576 589.842L870.098 590.323C866.764 593.692 862.234 595.575 857.517 595.575H750.806C740.978 595.575 733 603.6 733 613.498V728.902C733 738.799 740.978 746.826 750.806 746.826H865.382C875.209 746.826 883.177 738.799 883.177 728.902V620.853C883.177 616.448 884.912 612.222 888.008 609.104C891.093 605.997 895.29 604.249 899.664 604.249H1008.16C1017.99 604.249 1025.96 596.224 1025.96 586.327V470.923C1025.96 461.025 1017.99 453 1008.16 453H893.586C883.759 453 875.791 461.025 875.791 470.923V577.171ZM910.562 477.566H991.178C996.922 477.566 1001.57 482.254 1001.57 488.029V569.22C1001.57 574.995 996.922 579.683 991.178 579.683H910.562C904.828 579.683 900.173 574.995 900.173 569.22V488.029C900.173 482.254 904.828 477.566 910.562 477.566Z'
fill='#33C482'
/>
{/* Green icon - bottom right square */}
<path
d='M1008.3 624.59H923.113C912.786 624.59 904.414 633.022 904.414 643.423V728.171C904.414 738.572 912.786 747.004 923.113 747.004H1008.3C1018.63 747.004 1027 738.572 1027 728.171V643.423C1027 633.022 1018.63 624.59 1008.3 624.59Z'
fill='#33C482'
/>
{/* Gradient overlay on bottom right square */}
<path
d='M1008.3 624.199H923.113C912.786 624.199 904.414 632.631 904.414 643.033V727.78C904.414 738.181 912.786 746.612 923.113 746.612H1008.3C1018.63 746.612 1027 738.181 1027 727.78V643.033C1027 632.631 1018.63 624.199 1008.3 624.199Z'
fill='url(#sim-logo-full-gradient)'
fillOpacity='0.2'
/>
{/* "Sim" text - adapts to light/dark mode via currentColor */}
<path
d='M1210.54 515.657C1226.65 515.657 1240.59 518.51 1252.31 524.257H1252.31C1264.3 529.995 1273.63 538.014 1280.26 548.319H1280.26C1287.19 558.635 1290.78 570.899 1291.08 585.068L1291.1 586.089H1249.11L1249.09 585.115C1248.8 574.003 1245.18 565.493 1238.32 559.451C1231.45 553.399 1221.79 550.308 1209.21 550.308C1196.3 550.308 1186.48 553.113 1179.61 558.588C1172.76 564.046 1169.33 571.499 1169.33 581.063C1169.33 588.092 1171.88 593.978 1177.01 598.783C1182.17 603.618 1189.99 607.399 1200.56 610.061H1200.56L1238.77 619.451C1257.24 623.65 1271.21 630.571 1280.57 640.293L1281.01 640.739C1290.13 650.171 1294.64 662.97 1294.64 679.016C1294.64 692.923 1290.88 705.205 1283.34 715.822L1283.33 715.834C1275.81 726.134 1265.44 734.14 1252.26 739.866L1252.25 739.871C1239.36 745.302 1224.12 748 1206.54 748C1180.9 748 1160.36 741.696 1145.02 728.984C1129.67 716.258 1122 699.269 1122 678.121V677.121H1163.99V678.121C1163.99 688.869 1167.87 697.367 1175.61 703.722L1176.34 704.284C1184.04 709.997 1194.37 712.902 1207.43 712.902C1222.13 712.902 1233.3 710.087 1241.07 704.588C1248.8 698.812 1252.64 691.21 1252.64 681.699C1252.64 674.769 1250.5 669.057 1246.25 664.49L1246.23 664.478L1246.22 664.464C1242.28 659.929 1234.83 656.119 1223.64 653.152L1185.43 644.208L1185.42 644.204C1166.05 639.407 1151.49 632.035 1141.83 622.012L1141.83 622.006L1141.82 622C1132.43 611.94 1127.78 598.707 1127.78 582.405C1127.78 568.81 1131.23 556.976 1138.17 546.949L1138.18 546.941L1138.19 546.933C1145.41 536.936 1155.18 529.225 1167.48 523.793L1167.48 523.79C1180.07 518.36 1194.43 515.657 1210.54 515.657ZM1323.39 521.979C1331.68 525.008 1337.55 526.482 1343.51 526.482C1349.48 526.482 1355.64 525.005 1364.49 521.973L1365.82 521.52V742.633H1322.05V521.489L1323.39 521.979ZM1642.01 515.657C1667.11 515.657 1686.94 523.031 1701.39 537.876C1715.83 552.716 1723 572.968 1723 598.507V742.633H1680.12V608.794C1680.12 591.666 1675.72 578.681 1667.07 569.681L1667.06 569.669L1667.04 569.656C1658.67 560.359 1647.26 555.675 1632.68 555.675C1622.47 555.675 1613.47 558.022 1605.64 562.69L1605.63 562.696C1598.11 567.064 1592.17 573.475 1587.8 581.968C1583.44 590.448 1581.25 600.424 1581.25 611.925V742.633H1537.92V608.347C1537.92 591.208 1533.67 578.376 1525.31 569.68L1525.31 569.674L1525.3 569.668C1516.93 560.664 1505.52 556.122 1490.93 556.122C1480.72 556.122 1471.72 558.469 1463.89 563.138L1463.88 563.144C1456.36 567.511 1450.41 573.922 1446.05 582.415L1446.05 582.422L1446.04 582.428C1441.69 590.602 1439.5 600.423 1439.5 611.925V742.633H1395.72V521.919H1435.05V554.803C1439.92 544.379 1447.91 535.465 1458.37 528.356C1470.71 519.875 1485.58 515.657 1502.93 515.657C1522.37 515.657 1538.61 520.931 1551.55 531.538C1560.38 538.771 1567.1 547.628 1571.72 558.091C1576.05 547.619 1582.83 538.757 1592.07 531.524C1605.61 520.93 1622.28 515.657 1642.01 515.657ZM1343.49 452C1351.45 452 1358.23 454.786 1363.75 460.346C1369.27 465.905 1372.04 472.721 1372.04 480.73C1372.04 488.452 1369.27 495.254 1363.77 501.096L1363.76 501.105L1363.75 501.115C1358.23 506.675 1351.45 509.461 1343.49 509.461C1335.81 509.461 1329.05 506.669 1323.25 501.134L1323.23 501.115L1323.21 501.096C1317.71 495.254 1314.94 488.452 1314.94 480.73C1314.94 472.721 1317.7 465.905 1323.23 460.346L1323.24 460.337L1323.25 460.327C1329.05 454.792 1335.81 452 1343.49 452Z'
className='fill-neutral-900 dark:fill-white'
/>
<defs>
<linearGradient
id='sim-logo-full-gradient'
x1='904.414'
y1='624.199'
x2='978.836'
y2='698.447'
gradientUnits='userSpaceOnUse'
>
<stop />
<stop offset='1' stopOpacity='0' />
</linearGradient>
</defs>
</svg>
)
}

View File

@@ -17,7 +17,7 @@ MCP-Server gruppieren Ihre Workflow-Tools zusammen. Erstellen und verwalten Sie
<Video src="mcp/mcp-server.mp4" width={700} height={450} /> <Video src="mcp/mcp-server.mp4" width={700} height={450} />
</div> </div>
1. Navigieren Sie zu **Einstellungen → MCP-Server** 1. Navigieren Sie zu **Einstellungen → Bereitgestellte MCPs**
2. Klicken Sie auf **Server erstellen** 2. Klicken Sie auf **Server erstellen**
3. Geben Sie einen Namen und eine optionale Beschreibung ein 3. Geben Sie einen Namen und eine optionale Beschreibung ein
4. Kopieren Sie die Server-URL zur Verwendung in Ihren MCP-Clients 4. Kopieren Sie die Server-URL zur Verwendung in Ihren MCP-Clients
@@ -79,7 +79,7 @@ Füge deinen API-Key-Header (`X-API-Key`) für authentifizierten Zugriff hinzu,
## Server-Verwaltung ## Server-Verwaltung
In der Server-Detailansicht unter **Einstellungen → MCP-Server** können Sie: In der Server-Detailansicht unter **Einstellungen → Bereitgestellte MCPs** können Sie:
- **Tools anzeigen**: Alle Workflows sehen, die einem Server hinzugefügt wurden - **Tools anzeigen**: Alle Workflows sehen, die einem Server hinzugefügt wurden
- **URL kopieren**: Die Server-URL für MCP-Clients abrufen - **URL kopieren**: Die Server-URL für MCP-Clients abrufen

View File

@@ -27,7 +27,7 @@ MCP-Server stellen Sammlungen von Tools bereit, die Ihre Agenten nutzen können.
</div> </div>
1. Navigieren Sie zu Ihren Workspace-Einstellungen 1. Navigieren Sie zu Ihren Workspace-Einstellungen
2. Gehen Sie zum Abschnitt **MCP-Server** 2. Gehen Sie zum Abschnitt **Bereitgestellte MCPs**
3. Klicken Sie auf **MCP-Server hinzufügen** 3. Klicken Sie auf **MCP-Server hinzufügen**
4. Geben Sie die Server-Konfigurationsdetails ein 4. Geben Sie die Server-Konfigurationsdetails ein
5. Speichern Sie die Konfiguration 5. Speichern Sie die Konfiguration

View File

@@ -10,20 +10,12 @@ Stellen Sie Sim auf Ihrer eigenen Infrastruktur mit Docker oder Kubernetes berei
## Anforderungen ## Anforderungen
| Ressource | Klein | Standard | Produktion | | Ressource | Minimum | Empfohlen |
|----------|-------|----------|------------| |----------|---------|-------------|
| CPU | 2 Kerne | 4 Kerne | 8+ Kerne | | CPU | 2 Kerne | 4+ Kerne |
| RAM | 12 GB | 16 GB | 32+ GB | | RAM | 12 GB | 16+ GB |
| Speicher | 20 GB SSD | 50 GB SSD | 100+ GB SSD | | Speicher | 20 GB SSD | 50+ GB SSD |
| Docker | 20.10+ | 20.10+ | Neueste Version | | Docker | 20.10+ | Neueste Version |
**Klein**: Entwicklung, Tests, Einzelnutzer (1-5 Nutzer)
**Standard**: Teams (5-50 Nutzer), moderate Arbeitslasten
**Produktion**: Große Teams (50+ Nutzer), Hochverfügbarkeit, intensive Workflow-Ausführung
<Callout type="info">
Die Ressourcenanforderungen werden durch Workflow-Ausführung (isolated-vm Sandboxing), Dateiverarbeitung (In-Memory-Dokumentenparsing) und Vektoroperationen (pgvector) bestimmt. Arbeitsspeicher ist typischerweise der limitierende Faktor, nicht CPU. Produktionsdaten zeigen, dass die Hauptanwendung durchschnittlich 4-8 GB und bei hoher Last bis zu 12 GB benötigt.
</Callout>
## Schnellstart ## Schnellstart

View File

@@ -56,10 +56,6 @@ Controls response randomness and creativity:
- **Medium (0.3-0.7)**: Balanced creativity and focus. Good for general use. - **Medium (0.3-0.7)**: Balanced creativity and focus. Good for general use.
- **High (0.7-2.0)**: Creative and varied. Ideal for brainstorming and content generation. - **High (0.7-2.0)**: Creative and varied. Ideal for brainstorming and content generation.
### Max Output Tokens
Controls the maximum length of the model's response. For Anthropic models, Sim uses reliable defaults: streaming executions use the model's full capacity (e.g. 64,000 tokens for Claude 4.5), while non-streaming executions default to 8,192 to avoid timeout issues. For long-form content generation via API, explicitly set a higher value.
### API Key ### API Key
Your API key for the selected LLM provider. This is securely stored and used for authentication. Your API key for the selected LLM provider. This is securely stored and used for authentication.

View File

@@ -16,7 +16,7 @@ MCP servers group your workflow tools together. Create and manage them in worksp
<Video src="mcp/mcp-server.mp4" width={700} height={450} /> <Video src="mcp/mcp-server.mp4" width={700} height={450} />
</div> </div>
1. Navigate to **Settings → MCP Servers** 1. Navigate to **Settings → Deployed MCPs**
2. Click **Create Server** 2. Click **Create Server**
3. Enter a name and optional description 3. Enter a name and optional description
4. Copy the server URL for use in your MCP clients 4. Copy the server URL for use in your MCP clients
@@ -78,7 +78,7 @@ Include your API key header (`X-API-Key`) for authenticated access when using mc
## Server Management ## Server Management
From the server detail view in **Settings → MCP Servers**, you can: From the server detail view in **Settings → Deployed MCPs**, you can:
- **View tools**: See all workflows added to a server - **View tools**: See all workflows added to a server
- **Copy URL**: Get the server URL for MCP clients - **Copy URL**: Get the server URL for MCP clients

View File

@@ -27,7 +27,7 @@ MCP servers provide collections of tools that your agents can use. Configure the
</div> </div>
1. Navigate to your workspace settings 1. Navigate to your workspace settings
2. Go to the **MCP Servers** section 2. Go to the **Deployed MCPs** section
3. Click **Add MCP Server** 3. Click **Add MCP Server**
4. Enter the server configuration details 4. Enter the server configuration details
5. Save the configuration 5. Save the configuration

View File

@@ -4,7 +4,6 @@ description: Essential actions for navigating and using the Sim workflow editor
--- ---
import { Callout } from 'fumadocs-ui/components/callout' import { Callout } from 'fumadocs-ui/components/callout'
import { ActionImage, ActionVideo } from '@/components/ui/action-media'
A quick lookup for everyday actions in the Sim workflow editor. For keyboard shortcuts, see [Keyboard Shortcuts](/keyboard-shortcuts). A quick lookup for everyday actions in the Sim workflow editor. For keyboard shortcuts, see [Keyboard Shortcuts](/keyboard-shortcuts).
@@ -14,362 +13,124 @@ A quick lookup for everyday actions in the Sim workflow editor. For keyboard sho
## Workspaces ## Workspaces
<table> | Action | How |
<thead> |--------|-----|
<tr><th>Action</th><th>How</th><th>Preview</th></tr> | Create a workspace | Click workspace dropdown in sidebar → **New Workspace** |
</thead> | Rename a workspace | Workspace settings → Edit name |
<tbody> | Switch workspaces | Click workspace dropdown in sidebar → Select workspace |
<tr> | Invite team members | Workspace settings → **Team** → **Invite** |
<td>Create a workspace</td>
<td>Click workspace dropdown → **New Workspace**</td>
<td><ActionVideo src="quick-reference/create-workspace.mp4" alt="Create workspace" /></td>
</tr>
<tr>
<td>Switch workspaces</td>
<td>Click workspace dropdown → Select workspace</td>
<td><ActionVideo src="quick-reference/switch-workspace.mp4" alt="Switch workspaces" /></td>
</tr>
<tr>
<td>Invite team members</td>
<td>Sidebar → **Invite**</td>
<td><ActionVideo src="quick-reference/invite.mp4" alt="Invite team members" /></td>
</tr>
<tr>
<td>Rename a workspace</td>
<td>Right-click workspace → **Rename**</td>
<td rowSpan={4}><ActionImage src="/static/quick-reference/workspace-context-menu.png" alt="Workspace context menu" /></td>
</tr>
<tr>
<td>Duplicate a workspace</td>
<td>Right-click workspace → **Duplicate**</td>
</tr>
<tr>
<td>Export a workspace</td>
<td>Right-click workspace → **Export**</td>
</tr>
<tr>
<td>Delete a workspace</td>
<td>Right-click workspace → **Delete**</td>
</tr>
</tbody>
</table>
## Workflows ## Workflows
<table> | Action | How |
<thead> |--------|-----|
<tr><th>Action</th><th>How</th><th>Preview</th></tr> | Create a workflow | Click **New Workflow** button or `Mod+Shift+A` |
</thead> | Rename a workflow | Double-click workflow name in sidebar, or right-click → **Rename** |
<tbody> | Duplicate a workflow | Right-click workflow → **Duplicate** |
<tr> | Reorder workflows | Drag workflow up/down in the sidebar list |
<td>Create a workflow</td> | Import a workflow | Sidebar menu → **Import** → Select file |
<td>Click **+** button in sidebar</td> | Create a folder | Right-click in sidebar → **New Folder** |
<td><ActionImage src="/static/quick-reference/create-workflow.png" alt="Create workflow" /></td> | Rename a folder | Right-click folder → **Rename** |
</tr> | Delete a folder | Right-click folder → **Delete** |
<tr> | Collapse/expand folder | Click folder arrow, or double-click folder |
<td>Reorder / move workflows</td> | Move workflow to folder | Drag workflow onto folder in sidebar |
<td>Drag workflow up/down or onto a folder</td> | Delete a workflow | Right-click workflow → **Delete** |
<td><ActionVideo src="quick-reference/reordering.mp4" alt="Reorder workflows" /></td> | Export a workflow | Right-click workflow → **Export** |
</tr> | Assign workflow color | Right-click workflow → **Change Color** |
<tr> | Multi-select workflows | `Mod+Click` or `Shift+Click` workflows in sidebar |
<td>Import a workflow</td> | Open in new tab | Right-click workflow → **Open in New Tab** |
<td>Click import button in sidebar → Select file</td>
<td><ActionImage src="/static/quick-reference/import-workflow.png" alt="Import workflow" /></td>
</tr>
<tr>
<td>Multi-select workflows</td>
<td>`Mod+Click` or `Shift+Click` workflows in sidebar</td>
<td><ActionVideo src="quick-reference/multiselect.mp4" alt="Multi-select workflows" /></td>
</tr>
<tr>
<td>Open in new tab</td>
<td>Right-click workflow → **Open in New Tab**</td>
<td rowSpan={6}><ActionImage src="/static/quick-reference/workflow-context-menu.png" alt="Workflow context menu" /></td>
</tr>
<tr>
<td>Rename a workflow</td>
<td>Right-click workflow → **Rename**</td>
</tr>
<tr>
<td>Assign workflow color</td>
<td>Right-click workflow → **Change Color**</td>
</tr>
<tr>
<td>Duplicate a workflow</td>
<td>Right-click workflow → **Duplicate**</td>
</tr>
<tr>
<td>Export a workflow</td>
<td>Right-click workflow → **Export**</td>
</tr>
<tr>
<td>Delete a workflow</td>
<td>Right-click workflow → **Delete**</td>
</tr>
<tr>
<td>Rename a folder</td>
<td>Right-click folder → **Rename**</td>
<td rowSpan={6}><ActionImage src="/static/quick-reference/folder-context-menu.png" alt="Folder context menu" /></td>
</tr>
<tr>
<td>Create workflow in folder</td>
<td>Right-click folder → **Create workflow**</td>
</tr>
<tr>
<td>Create folder in folder</td>
<td>Right-click folder → **Create folder**</td>
</tr>
<tr>
<td>Duplicate a folder</td>
<td>Right-click folder → **Duplicate**</td>
</tr>
<tr>
<td>Export a folder</td>
<td>Right-click folder → **Export**</td>
</tr>
<tr>
<td>Delete a folder</td>
<td>Right-click folder → **Delete**</td>
</tr>
</tbody>
</table>
## Blocks ## Blocks
<table> | Action | How |
<thead> |--------|-----|
<tr><th>Action</th><th>How</th><th>Preview</th></tr> | Add a block | Drag from Toolbar panel, or right-click canvas → **Add Block** |
</thead> | Select a block | Click on the block |
<tbody> | Multi-select blocks | `Mod+Click` additional blocks, or right-drag to draw selection box |
<tr> | Move blocks | Drag selected block(s) to new position |
<td>Add a block</td> | Copy blocks | `Mod+C` with blocks selected |
<td>Drag from Toolbar panel, or right-click canvas → **Add Block**</td> | Paste blocks | `Mod+V` to paste copied blocks |
<td><ActionVideo src="quick-reference/add-block.mp4" alt="Add a block" /></td> | Duplicate blocks | Right-click → **Duplicate** |
</tr> | Delete blocks | `Delete` or `Backspace` key, or right-click → **Delete** |
<tr> | Rename a block | Click block name in header, or edit in the Editor panel |
<td>Multi-select blocks</td> | Enable/Disable a block | Right-click → **Enable/Disable** |
<td>`Mod+Click` additional blocks, or shift-drag to draw selection box</td> | Toggle handle orientation | Right-click → **Toggle Handles** |
<td><ActionVideo src="quick-reference/multiselect-blocks.mp4" alt="Multi-select blocks" /></td> | Toggle trigger mode | Right-click trigger block → **Toggle Trigger Mode** |
</tr> | Configure a block | Select block → use Editor panel on right |
<tr>
<td>Copy blocks</td>
<td>`Mod+C` with blocks selected</td>
<td rowSpan={2}><ActionVideo src="quick-reference/copy-paste.mp4" alt="Copy and paste blocks" /></td>
</tr>
<tr>
<td>Paste blocks</td>
<td>`Mod+V` to paste copied blocks</td>
</tr>
<tr>
<td>Duplicate blocks</td>
<td>Right-click → **Duplicate**</td>
<td><ActionVideo src="quick-reference/duplicate-block.mp4" alt="Duplicate blocks" /></td>
</tr>
<tr>
<td>Delete blocks</td>
<td>`Delete` or `Backspace` key, or right-click → **Delete**</td>
<td><ActionImage src="/static/quick-reference/delete-block.png" alt="Delete block" /></td>
</tr>
<tr>
<td>Rename a block</td>
<td>Click block name in header, or edit in the Editor panel</td>
<td><ActionVideo src="quick-reference/rename-block.mp4" alt="Rename a block" /></td>
</tr>
<tr>
<td>Enable/Disable a block</td>
<td>Right-click → **Enable/Disable**</td>
<td><ActionImage src="/static/quick-reference/disable-block.png" alt="Disable block" /></td>
</tr>
<tr>
<td>Toggle handle orientation</td>
<td>Right-click → **Toggle Handles**</td>
<td><ActionVideo src="quick-reference/toggle-handles.mp4" alt="Toggle handle orientation" /></td>
</tr>
<tr>
<td>Configure a block</td>
<td>Select block → use Editor panel on right</td>
<td><ActionVideo src="quick-reference/configure-block.mp4" alt="Configure a block" /></td>
</tr>
</tbody>
</table>
## Connections ## Connections
<table> | Action | How |
<thead> |--------|-----|
<tr><th>Action</th><th>How</th><th>Preview</th></tr> | Create a connection | Drag from output handle to input handle |
</thead> | Delete a connection | Click edge to select → `Delete` key |
<tbody> | Use output in another block | Drag connection tag into input field |
<tr>
<td>Create a connection</td> ## Canvas Navigation
<td>Drag from output handle to input handle</td>
<td><ActionVideo src="quick-reference/connect-blocks.mp4" alt="Connect blocks" /></td> | Action | How |
</tr> |--------|-----|
<tr> | Pan/move canvas | Left-drag on empty space, or scroll/trackpad |
<td>Delete a connection</td> | Zoom in/out | Scroll wheel or pinch gesture |
<td>Click edge to select `Delete` key</td> | Auto-layout | `Shift+L` |
<td><ActionVideo src="quick-reference/delete-connection.mp4" alt="Delete connection" /></td> | Draw selection box | Right-drag on empty canvas area |
</tr>
<tr>
<td>Use output in another block</td>
<td>Drag connection tag into input field</td>
<td><ActionVideo src="quick-reference/connection-tag.mp4" alt="Use connection tag" /></td>
</tr>
</tbody>
</table>
## Panels & Views ## Panels & Views
<table> | Action | How |
<thead> |--------|-----|
<tr><th>Action</th><th>How</th><th>Preview</th></tr> | Open Copilot tab | Press `C` or click Copilot tab |
</thead> | Open Toolbar tab | Press `T` or click Toolbar tab |
<tbody> | Open Editor tab | Press `E` or click Editor tab |
<tr> | Search toolbar | `Mod+F` |
<td>Search toolbar</td> | Toggle advanced mode | Click toggle button on input fields |
<td>`Mod+F`</td> | Resize panels | Drag panel edge |
<td><ActionVideo src="quick-reference/search-toolbar.mp4" alt="Search toolbar" /></td> | Collapse/expand sidebar | Click collapse button on sidebar |
</tr>
<tr>
<td>Search everything</td>
<td>`Mod+K`</td>
<td><ActionImage src="/static/quick-reference/search-everything.png" alt="Search everything" /></td>
</tr>
<tr>
<td>Toggle manual mode</td>
<td>Click toggle button to switch between manual and selector</td>
<td><ActionImage src="/static/quick-reference/toggle-manual-mode.png" alt="Toggle manual mode" /></td>
</tr>
<tr>
<td>Collapse/expand sidebar</td>
<td>Click collapse button on sidebar</td>
<td><ActionVideo src="quick-reference/collapse-sidebar.mp4" alt="Collapse sidebar" /></td>
</tr>
</tbody>
</table>
## Running & Testing ## Running & Testing
<table> | Action | How |
<thead> |--------|-----|
<tr><th>Action</th><th>How</th><th>Preview</th></tr> | Run workflow | Click Play button or `Mod+Enter` |
</thead> | Stop workflow | Click Stop button or `Mod+Enter` while running |
<tbody> | Test with chat | Use Chat panel on the right side |
<tr> | Select output to view | Click dropdown in Chat panel → Select block output |
<td>Run workflow</td> | Clear chat history | Click clear button in Chat panel |
<td>Click Run Workflow button or `Mod+Enter`</td> | View execution logs | Open terminal panel at bottom, or `Mod+L` |
<td><ActionImage src="/static/quick-reference/run-workflow.png" alt="Run workflow" /></td> | Filter logs by block | Click block filter in terminal |
</tr> | Filter logs by status | Click status filter in terminal |
<tr> | Search logs | Use search field in terminal |
<td>Stop workflow</td> | Copy log entry | Right-click log entry → **Copy** |
<td>Click Stop button or `Mod+Enter` while running</td> | Clear terminal | `Mod+D` |
<td><ActionImage src="/static/quick-reference/stop-workflow.png" alt="Stop workflow" /></td>
</tr>
<tr>
<td>Test with chat</td>
<td>Use Chat panel on the right side</td>
<td><ActionImage src="/static/quick-reference/test-chat.png" alt="Test with chat" /></td>
</tr>
<tr>
<td>Select output to view</td>
<td>Click dropdown in Chat panel → Select block output</td>
<td><ActionImage src="/static/quick-reference/output-select.png" alt="Select output to view" /></td>
</tr>
<tr>
<td>Clear chat history</td>
<td>Click clear button in Chat panel</td>
<td><ActionImage src="/static/quick-reference/clear-chat.png" alt="Clear chat history" /></td>
</tr>
<tr>
<td>View execution logs</td>
<td>Open terminal panel at bottom, or `Mod+L`</td>
<td><ActionImage src="/static/quick-reference/terminal.png" alt="Execution logs terminal" /></td>
</tr>
<tr>
<td>Filter logs by block or status</td>
<td>Click block filter in terminal or right-click log entry → **Filter by Block** or **Filter by Status**</td>
<td><ActionImage src="/static/quick-reference/filter-block.png" alt="Filter logs by block" /></td>
</tr>
<tr>
<td>Search logs</td>
<td>Use search field in terminal or right-click log entry → **Search**</td>
<td><ActionImage src="/static/quick-reference/terminal-search.png" alt="Search logs" /></td>
</tr>
<tr>
<td>Copy log entry</td>
<td>Clipboard Icon or Right-click log entry → **Copy**</td>
<td><ActionImage src="/static/quick-reference/copy-log.png" alt="Copy log entry" /></td>
</tr>
<tr>
<td>Clear terminal</td>
<td>Trash icon or `Mod+D`</td>
<td><ActionImage src="/static/quick-reference/clear-terminal.png" alt="Clear terminal" /></td>
</tr>
</tbody>
</table>
## Deployment ## Deployment
<table> | Action | How |
<thead> |--------|-----|
<tr><th>Action</th><th>How</th><th>Preview</th></tr> | Deploy a workflow | Click **Deploy** button in Deploy tab |
</thead> | Update deployment | Click **Update** when changes are detected |
<tbody> | View deployment status | Check status indicator (Live/Update/Deploy) in Deploy tab |
<tr> | Revert deployment | Access previous versions in Deploy tab |
<td>Deploy a workflow</td> | Copy webhook URL | Deploy tab → Copy webhook URL |
<td>Click **Deploy** button in panel</td> | Copy API endpoint | Deploy tab → Copy API endpoint URL |
<td><ActionImage src="/static/quick-reference/deploy.png" alt="Deploy workflow" /></td> | Set up a schedule | Add Schedule trigger block → Configure interval |
</tr>
<tr>
<td>Update deployment</td>
<td>Click **Update** when changes are detected</td>
<td><ActionImage src="/static/quick-reference/update-deployment.png" alt="Update deployment" /></td>
</tr>
<tr>
<td>View deployment status</td>
<td>Check status indicator (Live/Update/Deploy) in Deploy tab</td>
<td><ActionImage src="/static/quick-reference/view-deployment.png" alt="View deployment status" /></td>
</tr>
<tr>
<td>Revert deployment</td>
<td>Access previous versions in Deploy tab → **Promote to live**</td>
<td><ActionImage src="/static/quick-reference/promote-deployment.png" alt="Promote deployment to live" /></td>
</tr>
<tr>
<td>Copy API endpoint</td>
<td>Deploy tab → API → Copy API cURL</td>
<td><ActionImage src="/static/quick-reference/copy-api.png" alt="Copy API endpoint" /></td>
</tr>
</tbody>
</table>
## Variables ## Variables
<table> | Action | How |
<thead> |--------|-----|
<tr><th>Action</th><th>How</th><th>Preview</th></tr> | Add workflow variable | Variables tab → **Add Variable** |
</thead> | Edit workflow variable | Variables tab → Click variable to edit |
<tbody> | Delete workflow variable | Variables tab → Click delete icon on variable |
<tr> | Add environment variable | Settings → **Environment Variables** → **Add** |
<td>Add / Edit / Delete workflow variable</td> | Reference a variable | Use `{{variableName}}` syntax in block inputs |
<td>Panel -> Variables -> **Add Variable**, click to edit, or delete icon</td>
<td><ActionImage src="/static/quick-reference/variables.png" alt="Variables panel" /></td> ## Credentials
</tr>
<tr> | Action | How |
<td>Add environment variable</td> |--------|-----|
<td>Settings → **Environment Variables** → **Add**</td> | Add API key | Block credential field → **Add Credential** → Enter API key |
<td><ActionImage src="/static/quick-reference/add-env-variable.png" alt="Add environment variable" /></td> | Connect OAuth account | Block credential field → **Connect** → Authorize with provider |
</tr> | Manage credentials | Settings → **Credentials** |
<tr> | Remove credential | Settings → **Credentials** → Delete credential |
<td>Reference a workflow variable</td>
<td>Use `<blockName.itemName>` syntax in block inputs</td>
<td><ActionImage src="/static/quick-reference/variable-reference.png" alt="Reference workflow variable" /></td>
</tr>
<tr>
<td>Reference an environment variable</td>
<td>Use `{{ENV_VAR}}` syntax in block inputs</td>
<td><ActionImage src="/static/quick-reference/env-variable-reference.png" alt="Reference environment variable" /></td>
</tr>
</tbody>
</table>

View File

@@ -16,20 +16,12 @@ Deploy Sim on your own infrastructure with Docker or Kubernetes.
## Requirements ## Requirements
| Resource | Small | Standard | Production | | Resource | Minimum | Recommended |
|----------|-------|----------|------------| |----------|---------|-------------|
| CPU | 2 cores | 4 cores | 8+ cores | | CPU | 2 cores | 4+ cores |
| RAM | 12 GB | 16 GB | 32+ GB | | RAM | 12 GB | 16+ GB |
| Storage | 20 GB SSD | 50 GB SSD | 100+ GB SSD | | Storage | 20 GB SSD | 50+ GB SSD |
| Docker | 20.10+ | 20.10+ | Latest | | Docker | 20.10+ | Latest |
**Small**: Development, testing, single user (1-5 users)
**Standard**: Teams (5-50 users), moderate workloads
**Production**: Large teams (50+ users), high availability, heavy workflow execution
<Callout type="info">
Resource requirements are driven by workflow execution (isolated-vm sandboxing), file processing (in-memory document parsing), and vector operations (pgvector). Memory is typically the constraining factor rather than CPU. Production telemetry shows the main app uses 4-8 GB average with peaks up to 12 GB under heavy load.
</Callout>
## Quick Start ## Quick Start

View File

@@ -17,7 +17,7 @@ Los servidores MCP agrupan tus herramientas de flujo de trabajo. Créalos y gest
<Video src="mcp/mcp-server.mp4" width={700} height={450} /> <Video src="mcp/mcp-server.mp4" width={700} height={450} />
</div> </div>
1. Navega a **Configuración → Servidores MCP** 1. Navega a **Configuración → MCP implementados**
2. Haz clic en **Crear servidor** 2. Haz clic en **Crear servidor**
3. Introduce un nombre y una descripción opcional 3. Introduce un nombre y una descripción opcional
4. Copia la URL del servidor para usarla en tus clientes MCP 4. Copia la URL del servidor para usarla en tus clientes MCP
@@ -79,7 +79,7 @@ Incluye tu encabezado de clave API (`X-API-Key`) para acceso autenticado al usar
## Gestión del servidor ## Gestión del servidor
Desde la vista de detalles del servidor en **Configuración → Servidores MCP**, puedes: Desde la vista de detalles del servidor en **Configuración → MCP implementados**, puedes:
- **Ver herramientas**: consulta todos los flujos de trabajo añadidos a un servidor - **Ver herramientas**: consulta todos los flujos de trabajo añadidos a un servidor
- **Copiar URL**: obtén la URL del servidor para clientes MCP - **Copiar URL**: obtén la URL del servidor para clientes MCP

View File

@@ -27,7 +27,7 @@ Los servidores MCP proporcionan colecciones de herramientas que tus agentes pued
</div> </div>
1. Navega a la configuración de tu espacio de trabajo 1. Navega a la configuración de tu espacio de trabajo
2. Ve a la sección **Servidores MCP** 2. Ve a la sección **MCP implementados**
3. Haz clic en **Añadir servidor MCP** 3. Haz clic en **Añadir servidor MCP**
4. Introduce los detalles de configuración del servidor 4. Introduce los detalles de configuración del servidor
5. Guarda la configuración 5. Guarda la configuración

View File

@@ -10,20 +10,12 @@ Despliega Sim en tu propia infraestructura con Docker o Kubernetes.
## Requisitos ## Requisitos
| Recurso | Pequeño | Estándar | Producción | | Recurso | Mínimo | Recomendado |
|----------|---------|----------|------------| |----------|---------|-------------|
| CPU | 2 núcleos | 4 núcleos | 8+ núcleos | | CPU | 2 núcleos | 4+ núcleos |
| RAM | 12 GB | 16 GB | 32+ GB | | RAM | 12 GB | 16+ GB |
| Almacenamiento | 20 GB SSD | 50 GB SSD | 100+ GB SSD | | Almacenamiento | 20 GB SSD | 50+ GB SSD |
| Docker | 20.10+ | 20.10+ | Última versión | | Docker | 20.10+ | Última versión |
**Pequeño**: Desarrollo, pruebas, usuario único (1-5 usuarios)
**Estándar**: Equipos (5-50 usuarios), cargas de trabajo moderadas
**Producción**: Equipos grandes (50+ usuarios), alta disponibilidad, ejecución intensiva de workflows
<Callout type="info">
Los requisitos de recursos están determinados por la ejecución de workflows (sandboxing isolated-vm), procesamiento de archivos (análisis de documentos en memoria) y operaciones vectoriales (pgvector). La memoria suele ser el factor limitante, no la CPU. La telemetría de producción muestra que la aplicación principal usa 4-8 GB en promedio con picos de hasta 12 GB bajo carga pesada.
</Callout>
## Inicio rápido ## Inicio rápido

View File

@@ -17,7 +17,7 @@ Les serveurs MCP regroupent vos outils de workflow. Créez-les et gérez-les dan
<Video src="mcp/mcp-server.mp4" width={700} height={450} /> <Video src="mcp/mcp-server.mp4" width={700} height={450} />
</div> </div>
1. Accédez à **Paramètres → Serveurs MCP** 1. Accédez à **Paramètres → MCP déployés**
2. Cliquez sur **Créer un serveur** 2. Cliquez sur **Créer un serveur**
3. Saisissez un nom et une description facultative 3. Saisissez un nom et une description facultative
4. Copiez l'URL du serveur pour l'utiliser dans vos clients MCP 4. Copiez l'URL du serveur pour l'utiliser dans vos clients MCP
@@ -79,7 +79,7 @@ Incluez votre en-tête de clé API (`X-API-Key`) pour un accès authentifié lor
## Gestion du serveur ## Gestion du serveur
Depuis la vue détaillée du serveur dans **Paramètres → Serveurs MCP**, vous pouvez : Depuis la vue détaillée du serveur dans **Paramètres → MCP déployés**, vous pouvez :
- **Voir les outils** : voir tous les workflows ajoutés à un serveur - **Voir les outils** : voir tous les workflows ajoutés à un serveur
- **Copier l'URL** : obtenir l'URL du serveur pour les clients MCP - **Copier l'URL** : obtenir l'URL du serveur pour les clients MCP

View File

@@ -28,7 +28,7 @@ Les serveurs MCP fournissent des collections d'outils que vos agents peuvent uti
</div> </div>
1. Accédez aux paramètres de votre espace de travail 1. Accédez aux paramètres de votre espace de travail
2. Allez dans la section **Serveurs MCP** 2. Allez dans la section **MCP déployés**
3. Cliquez sur **Ajouter un serveur MCP** 3. Cliquez sur **Ajouter un serveur MCP**
4. Saisissez les détails de configuration du serveur 4. Saisissez les détails de configuration du serveur
5. Enregistrez la configuration 5. Enregistrez la configuration

View File

@@ -10,20 +10,12 @@ Déployez Sim sur votre propre infrastructure avec Docker ou Kubernetes.
## Prérequis ## Prérequis
| Ressource | Petit | Standard | Production | | Ressource | Minimum | Recommandé |
|----------|-------|----------|------------| |----------|---------|-------------|
| CPU | 2 cœurs | 4 cœurs | 8+ cœurs | | CPU | 2 cœurs | 4+ cœurs |
| RAM | 12 Go | 16 Go | 32+ Go | | RAM | 12 Go | 16+ Go |
| Stockage | 20 Go SSD | 50 Go SSD | 100+ Go SSD | | Stockage | 20 Go SSD | 50+ Go SSD |
| Docker | 20.10+ | 20.10+ | Dernière version | | Docker | 20.10+ | Dernière version |
**Petit** : Développement, tests, utilisateur unique (1-5 utilisateurs)
**Standard** : Équipes (5-50 utilisateurs), charges de travail modérées
**Production** : Grandes équipes (50+ utilisateurs), haute disponibilité, exécution intensive de workflows
<Callout type="info">
Les besoins en ressources sont déterminés par l'exécution des workflows (sandboxing isolated-vm), le traitement des fichiers (analyse de documents en mémoire) et les opérations vectorielles (pgvector). La mémoire est généralement le facteur limitant, pas le CPU. La télémétrie de production montre que l'application principale utilise 4-8 Go en moyenne avec des pics jusqu'à 12 Go sous forte charge.
</Callout>
## Démarrage rapide ## Démarrage rapide

View File

@@ -16,7 +16,7 @@ MCPサーバーは、ワークフローツールをまとめてグループ化
<Video src="mcp/mcp-server.mp4" width={700} height={450} /> <Video src="mcp/mcp-server.mp4" width={700} height={450} />
</div> </div>
1. **設定 → MCP サーバー**に移動します 1. **設定 → デプロイ済みMCP**に移動します
2. **サーバーを作成**をクリックします 2. **サーバーを作成**をクリックします
3. 名前とオプションの説明を入力します 3. 名前とオプションの説明を入力します
4. MCPクライアントで使用するためにサーバーURLをコピーします 4. MCPクライアントで使用するためにサーバーURLをコピーします
@@ -78,7 +78,7 @@ mcp-remoteまたは他のHTTPベースのMCPトランスポートを使用する
## サーバー管理 ## サーバー管理
**設定 → MCP サーバー**のサーバー詳細ビューから、次のことができます: **設定 → デプロイ済みMCP**のサーバー詳細ビューから、次のことができます:
- **ツールを表示**: サーバーに追加されたすべてのワークフローを確認 - **ツールを表示**: サーバーに追加されたすべてのワークフローを確認
- **URLをコピー**: MCPクライアント用のサーバーURLを取得 - **URLをコピー**: MCPクライアント用のサーバーURLを取得

View File

@@ -27,7 +27,7 @@ MCPサーバーはエージェントが使用できるツールのコレクシ
</div> </div>
1. ワークスペース設定に移動します 1. ワークスペース設定に移動します
2. **MCP サーバー**セクションに移動します 2. **デプロイ済みMCP**セクションに移動します
3. **MCPサーバーを追加**をクリックします 3. **MCPサーバーを追加**をクリックします
4. サーバー設定の詳細を入力します 4. サーバー設定の詳細を入力します
5. 設定を保存します 5. 設定を保存します

View File

@@ -10,20 +10,12 @@ DockerまたはKubernetesを使用して、自社のインフラストラクチ
## 要件 ## 要件
| リソース | スモール | スタンダード | プロダクション | | リソース | 最小 | 推奨 |
|----------|---------|-------------|----------------| |----------|---------|-------------|
| CPU | 2コア | 4コア | 8+コア | | CPU | 2コア | 4+コア |
| RAM | 12 GB | 16 GB | 32+ GB | | RAM | 12 GB | 16+ GB |
| ストレージ | 20 GB SSD | 50 GB SSD | 100+ GB SSD | | ストレージ | 20 GB SSD | 50+ GB SSD |
| Docker | 20.10+ | 20.10+ | 最新版 | | Docker | 20.10+ | 最新版 |
**スモール**: 開発、テスト、シングルユーザー1-5ユーザー
**スタンダード**: チーム5-50ユーザー、中程度のワークロード
**プロダクション**: 大規模チーム50+ユーザー)、高可用性、高負荷ワークフロー実行
<Callout type="info">
リソース要件は、ワークフロー実行isolated-vmサンドボックス、ファイル処理メモリ内ドキュメント解析、ベクトル演算pgvectorによって決まります。CPUよりもメモリが制約要因となることが多いです。本番環境のテレメトリによると、メインアプリは平均4-8 GB、高負荷時は最大12 GBを使用します。
</Callout>
## クイックスタート ## クイックスタート

View File

@@ -16,7 +16,7 @@ MCP 服务器用于将您的工作流工具进行分组。您可以在工作区
<Video src="mcp/mcp-server.mp4" width={700} height={450} /> <Video src="mcp/mcp-server.mp4" width={700} height={450} />
</div> </div>
1. 进入 **设置 → MCP 服务器** 1. 进入 **设置 → 已部署的 MCPs**
2. 点击 **创建服务器** 2. 点击 **创建服务器**
3. 输入名称和可选描述 3. 输入名称和可选描述
4. 复制服务器 URL 以在你的 MCP 客户端中使用 4. 复制服务器 URL 以在你的 MCP 客户端中使用
@@ -78,7 +78,7 @@ MCP 服务器用于将您的工作流工具进行分组。您可以在工作区
## 服务器管理 ## 服务器管理
在 **设置 → MCP 服务器** 的服务器详情页,你可以: 在 **设置 → 已部署的 MCPs** 的服务器详情页,你可以:
- **查看工具**:查看添加到服务器的所有工作流 - **查看工具**:查看添加到服务器的所有工作流
- **复制 URL**:获取 MCP 客户端的服务器 URL - **复制 URL**:获取 MCP 客户端的服务器 URL

View File

@@ -27,7 +27,7 @@ MCP 服务器提供工具集合,供您的代理使用。您可以在工作区
</div> </div>
1. 进入您的工作区设置 1. 进入您的工作区设置
2. 前往 **MCP Servers** 部分 2. 前往 **Deployed MCPs** 部分
3. 点击 **Add MCP Server** 3. 点击 **Add MCP Server**
4. 输入服务器配置信息 4. 输入服务器配置信息
5. 保存配置 5. 保存配置

View File

@@ -10,20 +10,12 @@ import { Callout } from 'fumadocs-ui/components/callout'
## 要求 ## 要求
| 资源 | 小型 | 标准 | 生产环境 | | 资源 | 最低要求 | 推荐配置 |
|----------|------|------|----------| |----------|---------|-------------|
| CPU | 2 核 | 4 核 | 8+ 核 | | CPU | 2 核 | 4 核及以上 |
| 内存 | 12 GB | 16 GB | 32+ GB | | 内存 | 12 GB | 16 GB 及以上 |
| 存储 | 20 GB SSD | 50 GB SSD | 100+ GB SSD | | 存储 | 20 GB SSD | 50 GB 及以上 SSD |
| Docker | 20.10+ | 20.10+ | 最新版本 | | Docker | 20.10+ | 最新版本 |
**小型**: 开发、测试、单用户1-5 用户)
**标准**: 团队5-50 用户)、中等工作负载
**生产环境**: 大型团队50+ 用户)、高可用性、密集工作流执行
<Callout type="info">
资源需求由工作流执行isolated-vm 沙箱、文件处理内存中文档解析和向量运算pgvector决定。内存通常是限制因素而不是 CPU。生产遥测数据显示主应用平均使用 4-8 GB高负载时峰值可达 12 GB。
</Callout>
## 快速开始 ## 快速开始

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

View File

@@ -8,7 +8,6 @@ import { executeInIsolatedVM } from '@/lib/execution/isolated-vm'
import { CodeLanguage, DEFAULT_CODE_LANGUAGE, isValidCodeLanguage } from '@/lib/execution/languages' import { CodeLanguage, DEFAULT_CODE_LANGUAGE, isValidCodeLanguage } from '@/lib/execution/languages'
import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants' import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants'
import { type OutputSchema, resolveBlockReference } from '@/executor/utils/block-reference' import { type OutputSchema, resolveBlockReference } from '@/executor/utils/block-reference'
import { formatLiteralForCode } from '@/executor/utils/code-formatting'
import { import {
createEnvVarPattern, createEnvVarPattern,
createWorkflowVariablePattern, createWorkflowVariablePattern,
@@ -388,12 +387,7 @@ function resolveWorkflowVariables(
if (type === 'number') { if (type === 'number') {
variableValue = Number(variableValue) variableValue = Number(variableValue)
} else if (type === 'boolean') { } else if (type === 'boolean') {
if (typeof variableValue === 'boolean') { variableValue = variableValue === 'true' || variableValue === true
// Already a boolean, keep as-is
} else {
const normalized = String(variableValue).toLowerCase().trim()
variableValue = normalized === 'true'
}
} else if (type === 'json' && typeof variableValue === 'string') { } else if (type === 'json' && typeof variableValue === 'string') {
try { try {
variableValue = JSON.parse(variableValue) variableValue = JSON.parse(variableValue)
@@ -693,7 +687,11 @@ export async function POST(req: NextRequest) {
prologue += `const environmentVariables = JSON.parse(${JSON.stringify(JSON.stringify(envVars))});\n` prologue += `const environmentVariables = JSON.parse(${JSON.stringify(JSON.stringify(envVars))});\n`
prologueLineCount++ prologueLineCount++
for (const [k, v] of Object.entries(contextVariables)) { for (const [k, v] of Object.entries(contextVariables)) {
prologue += `const ${k} = ${formatLiteralForCode(v, 'javascript')};\n` if (v === undefined) {
prologue += `const ${k} = undefined;\n`
} else {
prologue += `const ${k} = JSON.parse(${JSON.stringify(JSON.stringify(v))});\n`
}
prologueLineCount++ prologueLineCount++
} }
@@ -764,7 +762,11 @@ export async function POST(req: NextRequest) {
prologue += `environmentVariables = json.loads(${JSON.stringify(JSON.stringify(envVars))})\n` prologue += `environmentVariables = json.loads(${JSON.stringify(JSON.stringify(envVars))})\n`
prologueLineCount++ prologueLineCount++
for (const [k, v] of Object.entries(contextVariables)) { for (const [k, v] of Object.entries(contextVariables)) {
prologue += `${k} = ${formatLiteralForCode(v, 'python')}\n` if (v === undefined) {
prologue += `${k} = None\n`
} else {
prologue += `${k} = json.loads(${JSON.stringify(JSON.stringify(v))})\n`
}
prologueLineCount++ prologueLineCount++
} }
const wrapped = [ const wrapped = [

View File

@@ -16,10 +16,6 @@ mockKnowledgeSchemas()
mockDrizzleOrm() mockDrizzleOrm()
mockConsoleLogger() mockConsoleLogger()
vi.mock('@/lib/workspaces/permissions/utils', () => ({
getUserEntityPermissions: vi.fn().mockResolvedValue({ role: 'owner' }),
}))
describe('Knowledge Base API Route', () => { describe('Knowledge Base API Route', () => {
const mockAuth$ = mockAuth() const mockAuth$ = mockAuth()
@@ -90,7 +86,6 @@ describe('Knowledge Base API Route', () => {
const validKnowledgeBaseData = { const validKnowledgeBaseData = {
name: 'Test Knowledge Base', name: 'Test Knowledge Base',
description: 'Test description', description: 'Test description',
workspaceId: 'test-workspace-id',
chunkingConfig: { chunkingConfig: {
maxSize: 1024, maxSize: 1024,
minSize: 100, minSize: 100,
@@ -138,25 +133,11 @@ describe('Knowledge Base API Route', () => {
expect(data.details).toBeDefined() expect(data.details).toBeDefined()
}) })
it('should require workspaceId', async () => {
mockAuth$.mockAuthenticatedUser()
const req = createMockRequest('POST', { name: 'Test KB' })
const { POST } = await import('@/app/api/knowledge/route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toBe('Invalid request data')
expect(data.details).toBeDefined()
})
it('should validate chunking config constraints', async () => { it('should validate chunking config constraints', async () => {
mockAuth$.mockAuthenticatedUser() mockAuth$.mockAuthenticatedUser()
const invalidData = { const invalidData = {
name: 'Test KB', name: 'Test KB',
workspaceId: 'test-workspace-id',
chunkingConfig: { chunkingConfig: {
maxSize: 100, // 100 tokens = 400 characters maxSize: 100, // 100 tokens = 400 characters
minSize: 500, // Invalid: minSize (500 chars) > maxSize (400 chars) minSize: 500, // Invalid: minSize (500 chars) > maxSize (400 chars)
@@ -176,7 +157,7 @@ describe('Knowledge Base API Route', () => {
it('should use default values for optional fields', async () => { it('should use default values for optional fields', async () => {
mockAuth$.mockAuthenticatedUser() mockAuth$.mockAuthenticatedUser()
const minimalData = { name: 'Test KB', workspaceId: 'test-workspace-id' } const minimalData = { name: 'Test KB' }
const req = createMockRequest('POST', minimalData) const req = createMockRequest('POST', minimalData)
const { POST } = await import('@/app/api/knowledge/route') const { POST } = await import('@/app/api/knowledge/route')
const response = await POST(req) const response = await POST(req)

View File

@@ -19,7 +19,7 @@ const logger = createLogger('KnowledgeBaseAPI')
const CreateKnowledgeBaseSchema = z.object({ const CreateKnowledgeBaseSchema = z.object({
name: z.string().min(1, 'Name is required'), name: z.string().min(1, 'Name is required'),
description: z.string().optional(), description: z.string().optional(),
workspaceId: z.string().min(1, 'Workspace ID is required'), workspaceId: z.string().optional(),
embeddingModel: z.literal('text-embedding-3-small').default('text-embedding-3-small'), embeddingModel: z.literal('text-embedding-3-small').default('text-embedding-3-small'),
embeddingDimension: z.literal(1536).default(1536), embeddingDimension: z.literal(1536).default(1536),
chunkingConfig: z chunkingConfig: z

View File

@@ -408,7 +408,6 @@ describe('Knowledge Search Utils', () => {
input: ['test query'], input: ['test query'],
model: 'text-embedding-3-small', model: 'text-embedding-3-small',
encoding_format: 'float', encoding_format: 'float',
dimensions: 1536,
}), }),
}) })
) )

View File

@@ -0,0 +1,204 @@
import { db } from '@sim/db'
import { member, permissions, user, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
const logger = createLogger('OrganizationWorkspacesAPI')
/**
* GET /api/organizations/[id]/workspaces
* Get workspaces related to the organization with optional filtering
* Query parameters:
* - ?available=true - Only workspaces where user can invite others (admin permissions)
* - ?member=userId - Workspaces where specific member has access
*/
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: organizationId } = await params
const url = new URL(request.url)
const availableOnly = url.searchParams.get('available') === 'true'
const memberId = url.searchParams.get('member')
// Verify user is a member of this organization
const memberEntry = await db
.select()
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
.limit(1)
if (memberEntry.length === 0) {
return NextResponse.json(
{
error: 'Forbidden - Not a member of this organization',
},
{ status: 403 }
)
}
const userRole = memberEntry[0].role
const hasAdminAccess = ['owner', 'admin'].includes(userRole)
if (availableOnly) {
// Get workspaces where user has admin permissions (can invite others)
const availableWorkspaces = await db
.select({
id: workspace.id,
name: workspace.name,
ownerId: workspace.ownerId,
createdAt: workspace.createdAt,
isOwner: eq(workspace.ownerId, session.user.id),
permissionType: permissions.permissionType,
})
.from(workspace)
.leftJoin(
permissions,
and(
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspace.id),
eq(permissions.userId, session.user.id)
)
)
.where(
or(
// User owns the workspace
eq(workspace.ownerId, session.user.id),
// User has admin permission on the workspace
and(
eq(permissions.userId, session.user.id),
eq(permissions.entityType, 'workspace'),
eq(permissions.permissionType, 'admin')
)
)
)
// Filter and format the results
const workspacesWithInvitePermission = availableWorkspaces
.filter((workspace) => {
// Include if user owns the workspace OR has admin permission
return workspace.isOwner || workspace.permissionType === 'admin'
})
.map((workspace) => ({
id: workspace.id,
name: workspace.name,
isOwner: workspace.isOwner,
canInvite: true, // All returned workspaces have invite permission
createdAt: workspace.createdAt,
}))
logger.info('Retrieved available workspaces for organization member', {
organizationId,
userId: session.user.id,
workspaceCount: workspacesWithInvitePermission.length,
})
return NextResponse.json({
success: true,
data: {
workspaces: workspacesWithInvitePermission,
totalCount: workspacesWithInvitePermission.length,
filter: 'available',
},
})
}
if (memberId && hasAdminAccess) {
// Get workspaces where specific member has access (admin only)
const memberWorkspaces = await db
.select({
id: workspace.id,
name: workspace.name,
ownerId: workspace.ownerId,
isOwner: eq(workspace.ownerId, memberId),
permissionType: permissions.permissionType,
createdAt: permissions.createdAt,
})
.from(workspace)
.leftJoin(
permissions,
and(
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspace.id),
eq(permissions.userId, memberId)
)
)
.where(
or(
// Member owns the workspace
eq(workspace.ownerId, memberId),
// Member has permissions on the workspace
and(eq(permissions.userId, memberId), eq(permissions.entityType, 'workspace'))
)
)
const formattedWorkspaces = memberWorkspaces.map((workspace) => ({
id: workspace.id,
name: workspace.name,
isOwner: workspace.isOwner,
permission: workspace.permissionType,
joinedAt: workspace.createdAt,
createdAt: workspace.createdAt,
}))
return NextResponse.json({
success: true,
data: {
workspaces: formattedWorkspaces,
totalCount: formattedWorkspaces.length,
filter: 'member',
memberId,
},
})
}
// Default: Get all workspaces (basic info only for regular members)
if (!hasAdminAccess) {
return NextResponse.json({
success: true,
data: {
workspaces: [],
totalCount: 0,
message: 'Workspace access information is only available to organization admins',
},
})
}
// For admins: Get summary of all workspaces
const allWorkspaces = await db
.select({
id: workspace.id,
name: workspace.name,
ownerId: workspace.ownerId,
createdAt: workspace.createdAt,
ownerName: user.name,
})
.from(workspace)
.leftJoin(user, eq(workspace.ownerId, user.id))
return NextResponse.json({
success: true,
data: {
workspaces: allWorkspaces,
totalCount: allWorkspaces.length,
filter: 'all',
},
userRole,
hasAdminAccess,
})
} catch (error) {
logger.error('Failed to get organization workspaces', { error })
return NextResponse.json(
{
error: 'Internal server error',
},
{ status: 500 }
)
}
}

View File

@@ -1,257 +0,0 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
export const dynamic = 'force-dynamic'
const logger = createLogger('SupabaseStorageUploadAPI')
const SupabaseStorageUploadSchema = z.object({
projectId: z.string().min(1, 'Project ID is required'),
apiKey: z.string().min(1, 'API key is required'),
bucket: z.string().min(1, 'Bucket name is required'),
fileName: z.string().min(1, 'File name is required'),
path: z.string().optional().nullable(),
fileData: z.any(),
contentType: z.string().optional().nullable(),
upsert: z.boolean().optional().default(false),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(
`[${requestId}] Unauthorized Supabase storage upload attempt: ${authResult.error}`
)
return NextResponse.json(
{
success: false,
error: authResult.error || 'Authentication required',
},
{ status: 401 }
)
}
logger.info(
`[${requestId}] Authenticated Supabase storage upload request via ${authResult.authType}`,
{
userId: authResult.userId,
}
)
const body = await request.json()
const validatedData = SupabaseStorageUploadSchema.parse(body)
const fileData = validatedData.fileData
const isStringInput = typeof fileData === 'string'
logger.info(`[${requestId}] Uploading to Supabase Storage`, {
bucket: validatedData.bucket,
fileName: validatedData.fileName,
path: validatedData.path,
fileDataType: isStringInput ? 'string' : 'object',
})
if (!fileData) {
return NextResponse.json(
{
success: false,
error: 'fileData is required',
},
{ status: 400 }
)
}
let uploadBody: Buffer
let uploadContentType: string | undefined
if (isStringInput) {
let content = fileData as string
const dataUrlMatch = content.match(/^data:([^;]+);base64,(.+)$/s)
if (dataUrlMatch) {
const [, mimeType, base64Data] = dataUrlMatch
content = base64Data
if (!validatedData.contentType) {
uploadContentType = mimeType
}
logger.info(`[${requestId}] Extracted base64 from data URL (MIME: ${mimeType})`)
}
const cleanedContent = content.replace(/[\s\r\n]/g, '')
const isLikelyBase64 = /^[A-Za-z0-9+/]*={0,2}$/.test(cleanedContent)
if (isLikelyBase64 && cleanedContent.length >= 4) {
try {
uploadBody = Buffer.from(cleanedContent, 'base64')
const expectedMinSize = Math.floor(cleanedContent.length * 0.7)
const expectedMaxSize = Math.ceil(cleanedContent.length * 0.8)
if (
uploadBody.length >= expectedMinSize &&
uploadBody.length <= expectedMaxSize &&
uploadBody.length > 0
) {
logger.info(
`[${requestId}] Decoded base64 content: ${cleanedContent.length} chars -> ${uploadBody.length} bytes`
)
} else {
const reEncoded = uploadBody.toString('base64')
if (reEncoded !== cleanedContent) {
logger.info(
`[${requestId}] Content looked like base64 but re-encoding didn't match, using as plain text`
)
uploadBody = Buffer.from(content, 'utf-8')
} else {
logger.info(
`[${requestId}] Decoded base64 content (verified): ${uploadBody.length} bytes`
)
}
}
} catch (decodeError) {
logger.info(
`[${requestId}] Failed to decode as base64, using as plain text: ${decodeError}`
)
uploadBody = Buffer.from(content, 'utf-8')
}
} else {
uploadBody = Buffer.from(content, 'utf-8')
logger.info(`[${requestId}] Using content as plain text (${uploadBody.length} bytes)`)
}
uploadContentType =
uploadContentType || validatedData.contentType || 'application/octet-stream'
} else {
const rawFile = fileData
logger.info(`[${requestId}] Processing file object: ${rawFile.name || 'unknown'}`)
let userFile
try {
userFile = processSingleFileToUserFile(rawFile, requestId, logger)
} catch (error) {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to process file',
},
{ status: 400 }
)
}
const buffer = await downloadFileFromStorage(userFile, requestId, logger)
uploadBody = buffer
uploadContentType = validatedData.contentType || userFile.type || 'application/octet-stream'
}
let fullPath = validatedData.fileName
if (validatedData.path) {
const folderPath = validatedData.path.endsWith('/')
? validatedData.path
: `${validatedData.path}/`
fullPath = `${folderPath}${validatedData.fileName}`
}
const supabaseUrl = `https://${validatedData.projectId}.supabase.co/storage/v1/object/${validatedData.bucket}/${fullPath}`
const headers: Record<string, string> = {
apikey: validatedData.apiKey,
Authorization: `Bearer ${validatedData.apiKey}`,
'Content-Type': uploadContentType,
}
if (validatedData.upsert) {
headers['x-upsert'] = 'true'
}
logger.info(`[${requestId}] Sending to Supabase: ${supabaseUrl}`, {
contentType: uploadContentType,
bodySize: uploadBody.length,
upsert: validatedData.upsert,
})
const response = await fetch(supabaseUrl, {
method: 'POST',
headers,
body: new Uint8Array(uploadBody),
})
if (!response.ok) {
const errorText = await response.text()
let errorData
try {
errorData = JSON.parse(errorText)
} catch {
errorData = { message: errorText }
}
logger.error(`[${requestId}] Supabase Storage upload failed:`, {
status: response.status,
statusText: response.statusText,
error: errorData,
})
return NextResponse.json(
{
success: false,
error: errorData.message || errorData.error || `Upload failed: ${response.statusText}`,
details: errorData,
},
{ status: response.status }
)
}
const result = await response.json()
logger.info(`[${requestId}] File uploaded successfully to Supabase Storage`, {
bucket: validatedData.bucket,
path: fullPath,
})
const publicUrl = `https://${validatedData.projectId}.supabase.co/storage/v1/object/public/${validatedData.bucket}/${fullPath}`
return NextResponse.json({
success: true,
output: {
message: 'Successfully uploaded file to storage',
results: {
...result,
path: fullPath,
bucket: validatedData.bucket,
publicUrl,
},
},
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{
success: false,
error: 'Invalid request data',
details: error.errors,
},
{ status: 400 }
)
}
logger.error(`[${requestId}] Error uploading to Supabase Storage:`, error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Internal server error',
},
{ status: 500 }
)
}
}

View File

@@ -36,7 +36,7 @@ import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { getBaseUrl } from '@/lib/core/utils/urls' import { getBaseUrl } from '@/lib/core/utils/urls'
import type { CredentialRequirement } from '@/lib/workflows/credentials/credential-extractor' import type { CredentialRequirement } from '@/lib/workflows/credentials/credential-extractor'
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview' import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview'
import { getBlock } from '@/blocks/registry' import { getBlock } from '@/blocks/registry'
import { useStarTemplate, useTemplate } from '@/hooks/queries/templates' import { useStarTemplate, useTemplate } from '@/hooks/queries/templates'
@@ -330,7 +330,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
try { try {
return ( return (
<PreviewWorkflow <WorkflowPreview
workflowState={template.state} workflowState={template.state}
height='100%' height='100%'
width='100%' width='100%'

View File

@@ -4,7 +4,7 @@ import { Star, User } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation' import { useParams, useRouter } from 'next/navigation'
import { VerifiedBadge } from '@/components/ui/verified-badge' import { VerifiedBadge } from '@/components/ui/verified-badge'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview' import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview'
import { getBlock } from '@/blocks/registry' import { getBlock } from '@/blocks/registry'
import { useStarTemplate } from '@/hooks/queries/templates' import { useStarTemplate } from '@/hooks/queries/templates'
import type { WorkflowState } from '@/stores/workflows/workflow/types' import type { WorkflowState } from '@/stores/workflows/workflow/types'
@@ -200,14 +200,13 @@ function TemplateCardInner({
className='pointer-events-none h-[180px] w-full cursor-pointer overflow-hidden rounded-[6px]' className='pointer-events-none h-[180px] w-full cursor-pointer overflow-hidden rounded-[6px]'
> >
{normalizedState && isInView ? ( {normalizedState && isInView ? (
<PreviewWorkflow <WorkflowPreview
workflowState={normalizedState} workflowState={normalizedState}
height={180} height={180}
width='100%' width='100%'
isPannable={false} isPannable={false}
defaultZoom={0.8} defaultZoom={0.8}
fitPadding={0.2} fitPadding={0.2}
lightweight
/> />
) : ( ) : (
<div className='h-full w-full bg-[var(--surface-4)]' /> <div className='h-full w-full bg-[var(--surface-4)]' />

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useCallback, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { AlertCircle, Loader2 } from 'lucide-react' import { AlertCircle, Loader2 } from 'lucide-react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { import {
@@ -13,8 +13,13 @@ import {
PopoverContent, PopoverContent,
PopoverItem, PopoverItem,
} from '@/components/emcn' } from '@/components/emcn'
import { redactApiKeys } from '@/lib/core/security/redaction'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { Preview } from '@/app/workspace/[workspaceId]/w/components/preview' import {
getLeftmostBlockId,
PreviewEditor,
WorkflowPreview,
} from '@/app/workspace/[workspaceId]/w/components/preview'
import { useExecutionSnapshot } from '@/hooks/queries/logs' import { useExecutionSnapshot } from '@/hooks/queries/logs'
import type { WorkflowState } from '@/stores/workflows/workflow/types' import type { WorkflowState } from '@/stores/workflows/workflow/types'
@@ -27,6 +32,13 @@ interface TraceSpan {
children?: TraceSpan[] children?: TraceSpan[]
} }
interface BlockExecutionData {
input: unknown
output: unknown
status: string
durationMs: number
}
interface MigratedWorkflowState extends WorkflowState { interface MigratedWorkflowState extends WorkflowState {
_migrated: true _migrated: true
_note?: string _note?: string
@@ -58,35 +70,98 @@ export function ExecutionSnapshot({
onClose = () => {}, onClose = () => {},
}: ExecutionSnapshotProps) { }: ExecutionSnapshotProps) {
const { data, isLoading, error } = useExecutionSnapshot(executionId) const { data, isLoading, error } = useExecutionSnapshot(executionId)
const lastExecutionIdRef = useRef<string | null>(null) const [pinnedBlockId, setPinnedBlockId] = useState<string | null>(null)
const autoSelectedForExecutionRef = useRef<string | null>(null)
const [isMenuOpen, setIsMenuOpen] = useState(false) const [isMenuOpen, setIsMenuOpen] = useState(false)
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 }) const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 })
const [contextMenuBlockId, setContextMenuBlockId] = useState<string | null>(null)
const menuRef = useRef<HTMLDivElement>(null) const menuRef = useRef<HTMLDivElement>(null)
const closeMenu = useCallback(() => { const closeMenu = useCallback(() => {
setIsMenuOpen(false) setIsMenuOpen(false)
setContextMenuBlockId(null)
}, []) }, [])
const handleCanvasContextMenu = useCallback((e: React.MouseEvent) => { const handleCanvasContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
setContextMenuBlockId(null)
setMenuPosition({ x: e.clientX, y: e.clientY }) setMenuPosition({ x: e.clientX, y: e.clientY })
setIsMenuOpen(true) setIsMenuOpen(true)
}, []) }, [])
const handleNodeContextMenu = useCallback(
(blockId: string, mousePosition: { x: number; y: number }) => {
setContextMenuBlockId(blockId)
setMenuPosition(mousePosition)
setIsMenuOpen(true)
},
[]
)
const handleCopyExecutionId = useCallback(() => { const handleCopyExecutionId = useCallback(() => {
navigator.clipboard.writeText(executionId) navigator.clipboard.writeText(executionId)
closeMenu() closeMenu()
}, [executionId, closeMenu]) }, [executionId, closeMenu])
const handleOpenDetails = useCallback(() => {
if (contextMenuBlockId) {
setPinnedBlockId(contextMenuBlockId)
}
closeMenu()
}, [contextMenuBlockId, closeMenu])
const blockExecutions = useMemo(() => {
if (!traceSpans || !Array.isArray(traceSpans)) return {}
const blockExecutionMap: Record<string, BlockExecutionData> = {}
const collectBlockSpans = (spans: TraceSpan[]): TraceSpan[] => {
const blockSpans: TraceSpan[] = []
for (const span of spans) {
if (span.blockId) {
blockSpans.push(span)
}
if (span.children && Array.isArray(span.children)) {
blockSpans.push(...collectBlockSpans(span.children))
}
}
return blockSpans
}
const allBlockSpans = collectBlockSpans(traceSpans)
for (const span of allBlockSpans) {
if (span.blockId && !blockExecutionMap[span.blockId]) {
blockExecutionMap[span.blockId] = {
input: redactApiKeys(span.input || {}),
output: redactApiKeys(span.output || {}),
status: span.status || 'unknown',
durationMs: span.duration || 0,
}
}
}
return blockExecutionMap
}, [traceSpans])
const workflowState = data?.workflowState as WorkflowState | undefined const workflowState = data?.workflowState as WorkflowState | undefined
// Track execution ID changes for key reset // Auto-select the leftmost block once when data loads for a new executionId
const executionKey = executionId !== lastExecutionIdRef.current ? executionId : undefined useEffect(() => {
if (executionId !== lastExecutionIdRef.current) { if (
lastExecutionIdRef.current = executionId workflowState &&
} !isMigratedWorkflowState(workflowState) &&
autoSelectedForExecutionRef.current !== executionId
) {
autoSelectedForExecutionRef.current = executionId
const leftmostId = getLeftmostBlockId(workflowState)
setPinnedBlockId(leftmostId)
}
}, [executionId, workflowState])
const renderContent = () => { const renderContent = () => {
if (isLoading) { if (isLoading) {
@@ -151,17 +226,44 @@ export function ExecutionSnapshot({
} }
return ( return (
<Preview <div
key={executionKey} style={{ height, width }}
workflowState={workflowState} className={cn(
traceSpans={traceSpans} 'flex overflow-hidden',
className={className} !isModal && 'rounded-[4px] border border-[var(--border)]',
height={height} className
width={width} )}
onCanvasContextMenu={handleCanvasContextMenu} >
showBorder={!isModal} <div className='h-full flex-1' onContextMenu={handleCanvasContextMenu}>
autoSelectLeftmost <WorkflowPreview
/> workflowState={workflowState}
isPannable={true}
defaultPosition={{ x: 0, y: 0 }}
defaultZoom={0.8}
onNodeClick={(blockId) => {
setPinnedBlockId(blockId)
}}
onNodeContextMenu={handleNodeContextMenu}
onPaneClick={() => setPinnedBlockId(null)}
cursorStyle='pointer'
executedBlocks={blockExecutions}
selectedBlockId={pinnedBlockId}
/>
</div>
{pinnedBlockId && workflowState.blocks[pinnedBlockId] && (
<PreviewEditor
block={workflowState.blocks[pinnedBlockId]}
executionData={blockExecutions[pinnedBlockId]}
allBlockExecutions={blockExecutions}
workflowBlocks={workflowState.blocks}
workflowVariables={workflowState.variables}
loops={workflowState.loops}
parallels={workflowState.parallels}
isExecutionMode
onClose={() => setPinnedBlockId(null)}
/>
)}
</div>
) )
} }
@@ -185,6 +287,9 @@ export function ExecutionSnapshot({
}} }}
/> />
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}> <PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
{contextMenuBlockId && (
<PopoverItem onClick={handleOpenDetails}>Open Details</PopoverItem>
)}
<PopoverItem onClick={handleCopyExecutionId}>Copy Execution ID</PopoverItem> <PopoverItem onClick={handleCopyExecutionId}>Copy Execution ID</PopoverItem>
</PopoverContent> </PopoverContent>
</Popover>, </Popover>,
@@ -199,6 +304,7 @@ export function ExecutionSnapshot({
open={isOpen} open={isOpen}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) { if (!open) {
setPinnedBlockId(null)
onClose() onClose()
} }
}} }}

View File

@@ -573,19 +573,7 @@ const TraceSpanNode = memo(function TraceSpanNode({
return children.sort((a, b) => parseTime(a.startTime) - parseTime(b.startTime)) return children.sort((a, b) => parseTime(a.startTime) - parseTime(b.startTime))
}, [span, spanId, spanStartTime]) }, [span, spanId, spanStartTime])
// Hide empty model timing segments for agents without tool calls const hasChildren = allChildren.length > 0
const filteredChildren = useMemo(() => {
const isAgent = span.type?.toLowerCase() === 'agent'
const hasToolCalls =
(span.toolCalls?.length ?? 0) > 0 || allChildren.some((c) => c.type?.toLowerCase() === 'tool')
if (isAgent && !hasToolCalls) {
return allChildren.filter((c) => c.type?.toLowerCase() !== 'model')
}
return allChildren
}, [allChildren, span.type, span.toolCalls])
const hasChildren = filteredChildren.length > 0
const isExpanded = isRootWorkflow || expandedNodes.has(spanId) const isExpanded = isRootWorkflow || expandedNodes.has(spanId)
const isToggleable = !isRootWorkflow const isToggleable = !isRootWorkflow
@@ -697,7 +685,7 @@ const TraceSpanNode = memo(function TraceSpanNode({
{/* Nested Children */} {/* Nested Children */}
{hasChildren && ( {hasChildren && (
<div className='flex min-w-0 flex-col gap-[2px] border-[var(--border)] border-l pl-[10px]'> <div className='flex min-w-0 flex-col gap-[2px] border-[var(--border)] border-l pl-[10px]'>
{filteredChildren.map((child, index) => ( {allChildren.map((child, index) => (
<div key={child.id || `${spanId}-child-${index}`} className='pl-[6px]'> <div key={child.id || `${spanId}-child-${index}`} className='pl-[6px]'>
<TraceSpanNode <TraceSpanNode
span={child} span={child}

View File

@@ -3,7 +3,7 @@ import { Star, User } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation' import { useParams, useRouter } from 'next/navigation'
import { VerifiedBadge } from '@/components/ui/verified-badge' import { VerifiedBadge } from '@/components/ui/verified-badge'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview' import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview'
import { getBlock } from '@/blocks/registry' import { getBlock } from '@/blocks/registry'
import { useStarTemplate } from '@/hooks/queries/templates' import { useStarTemplate } from '@/hooks/queries/templates'
import type { WorkflowState } from '@/stores/workflows/workflow/types' import type { WorkflowState } from '@/stores/workflows/workflow/types'
@@ -206,7 +206,7 @@ function TemplateCardInner({
className='pointer-events-none h-[180px] w-full overflow-hidden rounded-[6px]' className='pointer-events-none h-[180px] w-full overflow-hidden rounded-[6px]'
> >
{normalizedState && isInView ? ( {normalizedState && isInView ? (
<PreviewWorkflow <WorkflowPreview
workflowState={normalizedState} workflowState={normalizedState}
height={180} height={180}
width='100%' width='100%'
@@ -214,7 +214,6 @@ function TemplateCardInner({
defaultZoom={0.8} defaultZoom={0.8}
fitPadding={0.2} fitPadding={0.2}
cursorStyle='pointer' cursorStyle='pointer'
lightweight
/> />
) : ( ) : (
<div className='h-full w-full bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' /> <div className='h-full w-full bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />

View File

@@ -25,277 +25,18 @@ function extractFieldValue(rawValue: unknown): string | undefined {
return undefined return undefined
} }
type EmbedInfo = {
url: string
type: 'iframe' | 'video' | 'audio'
aspectRatio?: string
}
const EMBED_SCALE = 0.78
const EMBED_INVERSE_SCALE = `${(1 / EMBED_SCALE) * 100}%`
function getTwitchParent(): string {
return typeof window !== 'undefined' ? window.location.hostname : 'localhost'
}
/** /**
* Get embed info for supported media platforms * Extract YouTube video ID from various YouTube URL formats
*/ */
function getEmbedInfo(url: string): EmbedInfo | null { function getYouTubeVideoId(url: string): string | null {
const youtubeMatch = url.match( const patterns = [
/(?:youtube\.com\/watch\?(?:.*&)?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/ /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/,
) /youtube\.com\/watch\?.*v=([a-zA-Z0-9_-]{11})/,
if (youtubeMatch) { ]
return { url: `https://www.youtube.com/embed/${youtubeMatch[1]}`, type: 'iframe' } for (const pattern of patterns) {
const match = url.match(pattern)
if (match) return match[1]
} }
const vimeoMatch = url.match(/vimeo\.com\/(\d+)/)
if (vimeoMatch) {
return { url: `https://player.vimeo.com/video/${vimeoMatch[1]}`, type: 'iframe' }
}
const dailymotionMatch = url.match(/dailymotion\.com\/video\/([a-zA-Z0-9]+)/)
if (dailymotionMatch) {
return { url: `https://www.dailymotion.com/embed/video/${dailymotionMatch[1]}`, type: 'iframe' }
}
const twitchVideoMatch = url.match(/twitch\.tv\/videos\/(\d+)/)
if (twitchVideoMatch) {
return {
url: `https://player.twitch.tv/?video=${twitchVideoMatch[1]}&parent=${getTwitchParent()}`,
type: 'iframe',
}
}
const twitchChannelMatch = url.match(/twitch\.tv\/([a-zA-Z0-9_]+)(?:\/|$)/)
if (twitchChannelMatch && !url.includes('/videos/') && !url.includes('/clip/')) {
return {
url: `https://player.twitch.tv/?channel=${twitchChannelMatch[1]}&parent=${getTwitchParent()}`,
type: 'iframe',
}
}
const streamableMatch = url.match(/streamable\.com\/([a-zA-Z0-9]+)/)
if (streamableMatch) {
return { url: `https://streamable.com/e/${streamableMatch[1]}`, type: 'iframe' }
}
const wistiaMatch = url.match(/(?:wistia\.com|wistia\.net)\/(?:medias|embed)\/([a-zA-Z0-9]+)/)
if (wistiaMatch) {
return { url: `https://fast.wistia.net/embed/iframe/${wistiaMatch[1]}`, type: 'iframe' }
}
const tiktokMatch = url.match(/tiktok\.com\/@[^/]+\/video\/(\d+)/)
if (tiktokMatch) {
return {
url: `https://www.tiktok.com/embed/v2/${tiktokMatch[1]}`,
type: 'iframe',
aspectRatio: '9/16',
}
}
const soundcloudMatch = url.match(/soundcloud\.com\/([a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+)/)
if (soundcloudMatch) {
return {
url: `https://w.soundcloud.com/player/?url=${encodeURIComponent(url)}&color=%23ff5500&auto_play=false&hide_related=true&show_comments=false&show_user=true&show_reposts=false&show_teaser=false`,
type: 'iframe',
aspectRatio: '3/2',
}
}
const spotifyTrackMatch = url.match(/open\.spotify\.com\/track\/([a-zA-Z0-9]+)/)
if (spotifyTrackMatch) {
return {
url: `https://open.spotify.com/embed/track/${spotifyTrackMatch[1]}`,
type: 'iframe',
aspectRatio: '3.7/1',
}
}
const spotifyAlbumMatch = url.match(/open\.spotify\.com\/album\/([a-zA-Z0-9]+)/)
if (spotifyAlbumMatch) {
return {
url: `https://open.spotify.com/embed/album/${spotifyAlbumMatch[1]}`,
type: 'iframe',
aspectRatio: '2/3',
}
}
const spotifyPlaylistMatch = url.match(/open\.spotify\.com\/playlist\/([a-zA-Z0-9]+)/)
if (spotifyPlaylistMatch) {
return {
url: `https://open.spotify.com/embed/playlist/${spotifyPlaylistMatch[1]}`,
type: 'iframe',
aspectRatio: '2/3',
}
}
const spotifyEpisodeMatch = url.match(/open\.spotify\.com\/episode\/([a-zA-Z0-9]+)/)
if (spotifyEpisodeMatch) {
return {
url: `https://open.spotify.com/embed/episode/${spotifyEpisodeMatch[1]}`,
type: 'iframe',
aspectRatio: '2.5/1',
}
}
const spotifyShowMatch = url.match(/open\.spotify\.com\/show\/([a-zA-Z0-9]+)/)
if (spotifyShowMatch) {
return {
url: `https://open.spotify.com/embed/show/${spotifyShowMatch[1]}`,
type: 'iframe',
aspectRatio: '3.7/1',
}
}
const appleMusicSongMatch = url.match(/music\.apple\.com\/([a-z]{2})\/song\/[^/]+\/(\d+)/)
if (appleMusicSongMatch) {
const [, country, songId] = appleMusicSongMatch
return {
url: `https://embed.music.apple.com/${country}/song/${songId}`,
type: 'iframe',
aspectRatio: '3/2',
}
}
const appleMusicAlbumMatch = url.match(/music\.apple\.com\/([a-z]{2})\/album\/(?:[^/]+\/)?(\d+)/)
if (appleMusicAlbumMatch) {
const [, country, albumId] = appleMusicAlbumMatch
return {
url: `https://embed.music.apple.com/${country}/album/${albumId}`,
type: 'iframe',
aspectRatio: '2/3',
}
}
const appleMusicPlaylistMatch = url.match(
/music\.apple\.com\/([a-z]{2})\/playlist\/[^/]+\/(pl\.[a-zA-Z0-9]+)/
)
if (appleMusicPlaylistMatch) {
const [, country, playlistId] = appleMusicPlaylistMatch
return {
url: `https://embed.music.apple.com/${country}/playlist/${playlistId}`,
type: 'iframe',
aspectRatio: '2/3',
}
}
const loomMatch = url.match(/loom\.com\/share\/([a-zA-Z0-9]+)/)
if (loomMatch) {
return { url: `https://www.loom.com/embed/${loomMatch[1]}`, type: 'iframe' }
}
const facebookVideoMatch =
url.match(/facebook\.com\/.*\/videos\/(\d+)/) || url.match(/fb\.watch\/([a-zA-Z0-9_-]+)/)
if (facebookVideoMatch) {
return {
url: `https://www.facebook.com/plugins/video.php?href=${encodeURIComponent(url)}&show_text=false`,
type: 'iframe',
}
}
const instagramReelMatch = url.match(/instagram\.com\/reel\/([a-zA-Z0-9_-]+)/)
if (instagramReelMatch) {
return {
url: `https://www.instagram.com/reel/${instagramReelMatch[1]}/embed`,
type: 'iframe',
aspectRatio: '9/16',
}
}
const instagramPostMatch = url.match(/instagram\.com\/p\/([a-zA-Z0-9_-]+)/)
if (instagramPostMatch) {
return {
url: `https://www.instagram.com/p/${instagramPostMatch[1]}/embed`,
type: 'iframe',
aspectRatio: '4/5',
}
}
const twitterMatch = url.match(/(?:twitter\.com|x\.com)\/[^/]+\/status\/(\d+)/)
if (twitterMatch) {
return {
url: `https://platform.twitter.com/embed/Tweet.html?id=${twitterMatch[1]}`,
type: 'iframe',
aspectRatio: '3/4',
}
}
const rumbleMatch =
url.match(/rumble\.com\/embed\/([a-zA-Z0-9]+)/) || url.match(/rumble\.com\/([a-zA-Z0-9]+)-/)
if (rumbleMatch) {
return { url: `https://rumble.com/embed/${rumbleMatch[1]}/`, type: 'iframe' }
}
const bilibiliMatch = url.match(/bilibili\.com\/video\/(BV[a-zA-Z0-9]+)/)
if (bilibiliMatch) {
return {
url: `https://player.bilibili.com/player.html?bvid=${bilibiliMatch[1]}&high_quality=1`,
type: 'iframe',
}
}
const vidyardMatch = url.match(/(?:vidyard\.com|share\.vidyard\.com)\/watch\/([a-zA-Z0-9]+)/)
if (vidyardMatch) {
return { url: `https://play.vidyard.com/${vidyardMatch[1]}`, type: 'iframe' }
}
const cfStreamMatch =
url.match(/cloudflarestream\.com\/([a-zA-Z0-9]+)/) ||
url.match(/videodelivery\.net\/([a-zA-Z0-9]+)/)
if (cfStreamMatch) {
return { url: `https://iframe.cloudflarestream.com/${cfStreamMatch[1]}`, type: 'iframe' }
}
const twitchClipMatch =
url.match(/clips\.twitch\.tv\/([a-zA-Z0-9_-]+)/) ||
url.match(/twitch\.tv\/[^/]+\/clip\/([a-zA-Z0-9_-]+)/)
if (twitchClipMatch) {
return {
url: `https://clips.twitch.tv/embed?clip=${twitchClipMatch[1]}&parent=${getTwitchParent()}`,
type: 'iframe',
}
}
const mixcloudMatch = url.match(/mixcloud\.com\/([^/]+\/[^/]+)/)
if (mixcloudMatch) {
return {
url: `https://www.mixcloud.com/widget/iframe/?feed=%2F${encodeURIComponent(mixcloudMatch[1])}%2F&hide_cover=1`,
type: 'iframe',
aspectRatio: '2/1',
}
}
const googleDriveMatch = url.match(/drive\.google\.com\/file\/d\/([a-zA-Z0-9_-]+)/)
if (googleDriveMatch) {
return { url: `https://drive.google.com/file/d/${googleDriveMatch[1]}/preview`, type: 'iframe' }
}
if (url.includes('dropbox.com') && /\.(mp4|mov|webm)/.test(url)) {
const directUrl = url
.replace('www.dropbox.com', 'dl.dropboxusercontent.com')
.replace('?dl=0', '')
return { url: directUrl, type: 'video' }
}
const tenorMatch = url.match(/tenor\.com\/view\/[^/]+-(\d+)/)
if (tenorMatch) {
return { url: `https://tenor.com/embed/${tenorMatch[1]}`, type: 'iframe', aspectRatio: '1/1' }
}
const giphyMatch = url.match(/giphy\.com\/(?:gifs|embed)\/(?:.*-)?([a-zA-Z0-9]+)/)
if (giphyMatch) {
return { url: `https://giphy.com/embed/${giphyMatch[1]}`, type: 'iframe', aspectRatio: '1/1' }
}
if (/\.(mp4|webm|ogg|mov)(\?|$)/i.test(url)) {
return { url, type: 'video' }
}
if (/\.(mp3|wav|m4a|aac)(\?|$)/i.test(url)) {
return { url, type: 'audio' }
}
return null return null
} }
@@ -367,57 +108,29 @@ const NoteMarkdown = memo(function NoteMarkdown({ content }: { content: string }
) )
}, },
a: ({ href, children }: any) => { a: ({ href, children }: any) => {
const embedInfo = href ? getEmbedInfo(href) : null const videoId = href ? getYouTubeVideoId(href) : null
if (embedInfo) { if (videoId) {
return ( return (
<span className='my-2 block w-full'> <span className='inline'>
<a <a
href={href} href={href}
target='_blank' target='_blank'
rel='noopener noreferrer' rel='noopener noreferrer'
className='mb-1 block break-all text-[var(--brand-secondary)] underline-offset-2 hover:underline' className='text-[var(--brand-secondary)] underline-offset-2 hover:underline'
> >
{children} {children}
</a> </a>
<span className='block w-full overflow-hidden rounded-md'> <span className='mt-1.5 block overflow-hidden rounded-md'>
{embedInfo.type === 'iframe' && ( <iframe
<span src={`https://www.youtube.com/embed/${videoId}`}
className='block overflow-hidden' title='YouTube video'
style={{ allow='accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture; web-share'
width: '100%', allowFullScreen
aspectRatio: embedInfo.aspectRatio || '16/9', loading='lazy'
}} referrerPolicy='strict-origin-when-cross-origin'
> sandbox='allow-scripts allow-same-origin allow-presentation allow-popups'
<iframe className='aspect-video w-full'
src={embedInfo.url} />
title='Media'
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share'
allowFullScreen
loading='lazy'
className='origin-top-left'
style={{
width: EMBED_INVERSE_SCALE,
height: EMBED_INVERSE_SCALE,
transform: `scale(${EMBED_SCALE})`,
}}
/>
</span>
)}
{embedInfo.type === 'video' && (
<video
src={embedInfo.url}
controls
preload='metadata'
className='aspect-video w-full'
>
<track kind='captions' src='' default />
</video>
)}
{embedInfo.type === 'audio' && (
<audio src={embedInfo.url} controls preload='metadata' className='w-full'>
<track kind='captions' src='' default />
</audio>
)}
</span> </span>
</span> </span>
) )
@@ -427,7 +140,7 @@ const NoteMarkdown = memo(function NoteMarkdown({ content }: { content: string }
href={href} href={href}
target='_blank' target='_blank'
rel='noopener noreferrer' rel='noopener noreferrer'
className='break-all text-[var(--brand-secondary)] underline-offset-2 hover:underline' className='text-[var(--brand-secondary)] underline-offset-2 hover:underline'
> >
{children} {children}
</a> </a>
@@ -446,26 +159,6 @@ const NoteMarkdown = memo(function NoteMarkdown({ content }: { content: string }
{children} {children}
</blockquote> </blockquote>
), ),
table: ({ children }: any) => (
<div className='my-2 max-w-full overflow-x-auto'>
<table className='w-full border-collapse text-xs'>{children}</table>
</div>
),
thead: ({ children }: any) => (
<thead className='border-[var(--border)] border-b'>{children}</thead>
),
tbody: ({ children }: any) => <tbody>{children}</tbody>,
tr: ({ children }: any) => (
<tr className='border-[var(--border)] border-b last:border-b-0'>{children}</tr>
),
th: ({ children }: any) => (
<th className='px-2 py-1 text-left font-semibold text-[var(--text-primary)]'>
{children}
</th>
),
td: ({ children }: any) => (
<td className='px-2 py-1 text-[var(--text-secondary)]'>{children}</td>
),
}} }}
> >
{content} {content}
@@ -478,7 +171,7 @@ export const NoteBlock = memo(function NoteBlock({
data, data,
selected, selected,
}: NodeProps<NoteBlockNodeData>) { }: NodeProps<NoteBlockNodeData>) {
const { type, name } = data const { type, config, name } = data
const { activeWorkflowId, isEnabled, handleClick, hasRing, ringStyles } = useBlockVisual({ const { activeWorkflowId, isEnabled, handleClick, hasRing, ringStyles } = useBlockVisual({
blockId: id, blockId: id,
@@ -555,8 +248,8 @@ export const NoteBlock = memo(function NoteBlock({
</div> </div>
</div> </div>
<div className='relative overflow-hidden p-[8px]'> <div className='relative p-[8px]'>
<div className='relative max-w-full break-all'> <div className='relative break-words'>
{isEmpty ? ( {isEmpty ? (
<p className='text-[#868686] text-sm'>Add note...</p> <p className='text-[#868686] text-sm'>Add note...</p>
) : ( ) : (

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { import {
Button, Button,
@@ -17,7 +17,11 @@ import {
} from '@/components/emcn' } from '@/components/emcn'
import { Skeleton } from '@/components/ui' import { Skeleton } from '@/components/ui'
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils' import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
import { Preview, PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview' import {
getLeftmostBlockId,
PreviewEditor,
WorkflowPreview,
} from '@/app/workspace/[workspaceId]/w/components/preview'
import { useDeploymentVersionState, useRevertToVersion } from '@/hooks/queries/workflows' import { useDeploymentVersionState, useRevertToVersion } from '@/hooks/queries/workflows'
import type { WorkflowState } from '@/stores/workflows/workflow/types' import type { WorkflowState } from '@/stores/workflows/workflow/types'
import { Versions } from './components' import { Versions } from './components'
@@ -55,6 +59,8 @@ export function GeneralDeploy({
const [showLoadDialog, setShowLoadDialog] = useState(false) const [showLoadDialog, setShowLoadDialog] = useState(false)
const [showPromoteDialog, setShowPromoteDialog] = useState(false) const [showPromoteDialog, setShowPromoteDialog] = useState(false)
const [showExpandedPreview, setShowExpandedPreview] = useState(false) const [showExpandedPreview, setShowExpandedPreview] = useState(false)
const [expandedSelectedBlockId, setExpandedSelectedBlockId] = useState<string | null>(null)
const hasAutoSelectedRef = useRef(false)
const [versionToLoad, setVersionToLoad] = useState<number | null>(null) const [versionToLoad, setVersionToLoad] = useState<number | null>(null)
const [versionToPromote, setVersionToPromote] = useState<number | null>(null) const [versionToPromote, setVersionToPromote] = useState<number | null>(null)
@@ -129,6 +135,19 @@ export function GeneralDeploy({
const hasDeployedData = deployedState && Object.keys(deployedState.blocks || {}).length > 0 const hasDeployedData = deployedState && Object.keys(deployedState.blocks || {}).length > 0
const showLoadingSkeleton = isLoadingDeployedState && !hasDeployedData const showLoadingSkeleton = isLoadingDeployedState && !hasDeployedData
// Auto-select the leftmost block once when expanded preview opens
useEffect(() => {
if (showExpandedPreview && workflowToShow && !hasAutoSelectedRef.current) {
hasAutoSelectedRef.current = true
const leftmostId = getLeftmostBlockId(workflowToShow)
setExpandedSelectedBlockId(leftmostId)
}
// Reset when modal closes
if (!showExpandedPreview) {
hasAutoSelectedRef.current = false
}
}, [showExpandedPreview, workflowToShow])
if (showLoadingSkeleton) { if (showLoadingSkeleton) {
return ( return (
<div className='space-y-[12px]'> <div className='space-y-[12px]'>
@@ -186,7 +205,7 @@ export function GeneralDeploy({
{workflowToShow ? ( {workflowToShow ? (
<> <>
<div className='[&_*]:!cursor-default h-full w-full cursor-default'> <div className='[&_*]:!cursor-default h-full w-full cursor-default'>
<PreviewWorkflow <WorkflowPreview
workflowState={workflowToShow} workflowState={workflowToShow}
height='100%' height='100%'
width='100%' width='100%'
@@ -287,15 +306,46 @@ export function GeneralDeploy({
</Modal> </Modal>
{workflowToShow && ( {workflowToShow && (
<Modal open={showExpandedPreview} onOpenChange={setShowExpandedPreview}> <Modal
open={showExpandedPreview}
onOpenChange={(open) => {
if (!open) {
setExpandedSelectedBlockId(null)
}
setShowExpandedPreview(open)
}}
>
<ModalContent size='full' className='flex h-[90vh] flex-col'> <ModalContent size='full' className='flex h-[90vh] flex-col'>
<ModalHeader> <ModalHeader>
{previewMode === 'selected' && selectedVersionInfo {previewMode === 'selected' && selectedVersionInfo
? selectedVersionInfo.name || `v${selectedVersion}` ? selectedVersionInfo.name || `v${selectedVersion}`
: 'Live Workflow'} : 'Live Workflow'}
</ModalHeader> </ModalHeader>
<ModalBody className='!p-0 min-h-0 flex-1 overflow-hidden'> <ModalBody className='!p-0 min-h-0 flex-1'>
<Preview workflowState={workflowToShow} autoSelectLeftmost /> <div className='flex h-full w-full overflow-hidden'>
<div className='h-full flex-1'>
<WorkflowPreview
workflowState={workflowToShow}
isPannable={true}
defaultPosition={{ x: 0, y: 0 }}
defaultZoom={0.6}
onNodeClick={(blockId) => {
setExpandedSelectedBlockId(blockId)
}}
onPaneClick={() => setExpandedSelectedBlockId(null)}
selectedBlockId={expandedSelectedBlockId}
/>
</div>
{expandedSelectedBlockId && workflowToShow.blocks?.[expandedSelectedBlockId] && (
<PreviewEditor
block={workflowToShow.blocks[expandedSelectedBlockId]}
workflowVariables={workflowToShow.variables}
loops={workflowToShow.loops}
parallels={workflowToShow.parallels}
onClose={() => setExpandedSelectedBlockId(null)}
/>
)}
</div>
</ModalBody> </ModalBody>
</ModalContent> </ModalContent>
</Modal> </Modal>

View File

@@ -435,7 +435,7 @@ export function McpDeploy({
return ( return (
<div className='flex h-full flex-col items-center justify-center gap-3'> <div className='flex h-full flex-col items-center justify-center gap-3'>
<p className='text-[13px] text-[var(--text-muted)]'> <p className='text-[13px] text-[var(--text-muted)]'>
Create an MCP Server in Settings MCP Servers first. Create an MCP Server in Settings Deployed MCPs first.
</p> </p>
<Button <Button
variant='tertiary' variant='tertiary'

View File

@@ -19,7 +19,7 @@ import { Skeleton } from '@/components/ui'
import { useSession } from '@/lib/auth/auth-client' import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { captureAndUploadOGImage, OG_IMAGE_HEIGHT, OG_IMAGE_WIDTH } from '@/lib/og' import { captureAndUploadOGImage, OG_IMAGE_HEIGHT, OG_IMAGE_WIDTH } from '@/lib/og'
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview' import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview'
import { useCreatorProfiles } from '@/hooks/queries/creator-profile' import { useCreatorProfiles } from '@/hooks/queries/creator-profile'
import { import {
useCreateTemplate, useCreateTemplate,
@@ -439,14 +439,13 @@ const OGCaptureContainer = forwardRef<HTMLDivElement>((_, ref) => {
}} }}
aria-hidden='true' aria-hidden='true'
> >
<PreviewWorkflow <WorkflowPreview
workflowState={workflowState} workflowState={workflowState}
height='100%' height='100%'
width='100%' width='100%'
isPannable={false} isPannable={false}
defaultZoom={0.8} defaultZoom={0.8}
fitPadding={0.2} fitPadding={0.2}
lightweight
/> />
</div> </div>
) )
@@ -478,7 +477,7 @@ function TemplatePreviewContent({ existingTemplate }: TemplatePreviewContentProp
} }
return ( return (
<PreviewWorkflow <WorkflowPreview
key={`template-preview-${existingTemplate.id}`} key={`template-preview-${existingTemplate.id}`}
workflowState={workflowState} workflowState={workflowState}
height='100%' height='100%'

View File

@@ -3,9 +3,8 @@
import { useCallback, useRef, useState } from 'react' import { useCallback, useRef, useState } from 'react'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import clsx from 'clsx' import clsx from 'clsx'
import { RepeatIcon, SplitIcon } from 'lucide-react' import { ChevronDown, RepeatIcon, SplitIcon } from 'lucide-react'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { ChevronDown } from '@/components/emcn'
import { import {
FieldItem, FieldItem,
type SchemaField, type SchemaField,
@@ -116,8 +115,9 @@ function ConnectionItem({
{hasFields && ( {hasFields && (
<ChevronDown <ChevronDown
className={clsx( className={clsx(
'h-[8px] w-[8px] flex-shrink-0 text-[var(--text-tertiary)] transition-transform duration-100 group-hover:text-[var(--text-primary)]', 'h-3.5 w-3.5 flex-shrink-0 transition-transform duration-100',
!isExpanded && '-rotate-90' 'text-[var(--text-secondary)] group-hover:text-[var(--text-primary)]',
isExpanded && 'rotate-180'
)} )}
/> />
)} )}

View File

@@ -39,8 +39,6 @@ import { normalizeName } from '@/executor/constants'
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation' import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
import { useTagSelection } from '@/hooks/kb/use-tag-selection' import { useTagSelection } from '@/hooks/kb/use-tag-selection'
import { createShouldHighlightEnvVar, useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars' import { createShouldHighlightEnvVar, useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars'
import { useCodeUndoRedo } from '@/hooks/use-code-undo-redo'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('Code') const logger = createLogger('Code')
@@ -214,6 +212,7 @@ export const Code = memo(function Code({
const handleStreamStartRef = useRef<() => void>(() => {}) const handleStreamStartRef = useRef<() => void>(() => {})
const handleGeneratedContentRef = useRef<(generatedCode: string) => void>(() => {}) const handleGeneratedContentRef = useRef<(generatedCode: string) => void>(() => {})
const handleStreamChunkRef = useRef<(chunk: string) => void>(() => {}) const handleStreamChunkRef = useRef<(chunk: string) => void>(() => {})
const hasEditedSinceFocusRef = useRef(false)
const codeRef = useRef(code) const codeRef = useRef(code)
codeRef.current = code codeRef.current = code
@@ -221,12 +220,8 @@ export const Code = memo(function Code({
const emitTagSelection = useTagSelection(blockId, subBlockId) const emitTagSelection = useTagSelection(blockId, subBlockId)
const [languageValue] = useSubBlockValue<string>(blockId, 'language') const [languageValue] = useSubBlockValue<string>(blockId, 'language')
const availableEnvVars = useAvailableEnvVarKeys(workspaceId) const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
const blockType = useWorkflowStore(
useCallback((state) => state.blocks?.[blockId]?.type, [blockId])
)
const effectiveLanguage = (languageValue as 'javascript' | 'python' | 'json') || language const effectiveLanguage = (languageValue as 'javascript' | 'python' | 'json') || language
const isFunctionCode = blockType === 'function' && subBlockId === 'code'
const trimmedCode = code.trim() const trimmedCode = code.trim()
const containsReferencePlaceholders = const containsReferencePlaceholders =
@@ -301,15 +296,6 @@ export const Code = memo(function Code({
const updatePromptValue = wandHook?.updatePromptValue || (() => {}) const updatePromptValue = wandHook?.updatePromptValue || (() => {})
const cancelGeneration = wandHook?.cancelGeneration || (() => {}) const cancelGeneration = wandHook?.cancelGeneration || (() => {})
const { recordChange, recordReplace, flushPending, startSession, undo, redo } = useCodeUndoRedo({
blockId,
subBlockId,
value: code,
enabled: isFunctionCode,
isReadOnly: readOnly || disabled || isPreview,
isStreaming: isAiStreaming,
})
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId, false, { const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId, false, {
isStreaming: isAiStreaming, isStreaming: isAiStreaming,
onStreamingEnd: () => { onStreamingEnd: () => {
@@ -361,10 +347,9 @@ export const Code = memo(function Code({
setCode(generatedCode) setCode(generatedCode)
if (!isPreview && !disabled) { if (!isPreview && !disabled) {
setStoreValue(generatedCode) setStoreValue(generatedCode)
recordReplace(generatedCode)
} }
} }
}, [disabled, isPreview, recordReplace, setStoreValue]) }, [isPreview, disabled, setStoreValue])
useEffect(() => { useEffect(() => {
if (!editorRef.current) return if (!editorRef.current) return
@@ -507,7 +492,7 @@ export const Code = memo(function Code({
setCode(newValue) setCode(newValue)
setStoreValue(newValue) setStoreValue(newValue)
recordChange(newValue) hasEditedSinceFocusRef.current = true
const newCursorPosition = dropPosition + 1 const newCursorPosition = dropPosition + 1
setCursorPosition(newCursorPosition) setCursorPosition(newCursorPosition)
@@ -536,7 +521,7 @@ export const Code = memo(function Code({
if (!isPreview && !readOnly) { if (!isPreview && !readOnly) {
setCode(newValue) setCode(newValue)
emitTagSelection(newValue) emitTagSelection(newValue)
recordChange(newValue) hasEditedSinceFocusRef.current = true
} }
setShowTags(false) setShowTags(false)
setActiveSourceBlockId(null) setActiveSourceBlockId(null)
@@ -554,7 +539,7 @@ export const Code = memo(function Code({
if (!isPreview && !readOnly) { if (!isPreview && !readOnly) {
setCode(newValue) setCode(newValue)
emitTagSelection(newValue) emitTagSelection(newValue)
recordChange(newValue) hasEditedSinceFocusRef.current = true
} }
setShowEnvVars(false) setShowEnvVars(false)
@@ -640,9 +625,9 @@ export const Code = memo(function Code({
const handleValueChange = useCallback( const handleValueChange = useCallback(
(newCode: string) => { (newCode: string) => {
if (!isAiStreaming && !isPreview && !disabled && !readOnly) { if (!isAiStreaming && !isPreview && !disabled && !readOnly) {
hasEditedSinceFocusRef.current = true
setCode(newCode) setCode(newCode)
setStoreValue(newCode) setStoreValue(newCode)
recordChange(newCode)
const textarea = editorRef.current?.querySelector('textarea') const textarea = editorRef.current?.querySelector('textarea')
if (textarea) { if (textarea) {
@@ -661,7 +646,7 @@ export const Code = memo(function Code({
} }
} }
}, },
[isAiStreaming, isPreview, disabled, readOnly, recordChange, setStoreValue] [isAiStreaming, isPreview, disabled, readOnly, setStoreValue]
) )
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
@@ -672,39 +657,21 @@ export const Code = memo(function Code({
} }
if (isAiStreaming) { if (isAiStreaming) {
e.preventDefault() e.preventDefault()
return
} }
if (!isFunctionCode) return if (e.key === 'z' && (e.metaKey || e.ctrlKey) && !hasEditedSinceFocusRef.current) {
const isUndo = (e.key === 'z' || e.key === 'Z') && (e.metaKey || e.ctrlKey) && !e.shiftKey
const isRedo =
((e.key === 'z' || e.key === 'Z') && (e.metaKey || e.ctrlKey) && e.shiftKey) ||
(e.key === 'y' && (e.metaKey || e.ctrlKey))
if (isUndo) {
e.preventDefault() e.preventDefault()
e.stopPropagation()
undo()
return
}
if (isRedo) {
e.preventDefault()
e.stopPropagation()
redo()
} }
}, },
[isAiStreaming, isFunctionCode, redo, undo] [isAiStreaming]
) )
const handleEditorFocus = useCallback(() => { const handleEditorFocus = useCallback(() => {
startSession(codeRef.current) hasEditedSinceFocusRef.current = false
if (!isPreview && !disabled && !readOnly && codeRef.current.trim() === '') { if (!isPreview && !disabled && !readOnly && codeRef.current.trim() === '') {
setShowTags(true) setShowTags(true)
setCursorPosition(0) setCursorPosition(0)
} }
}, [disabled, isPreview, readOnly, startSession]) }, [isPreview, disabled, readOnly])
const handleEditorBlur = useCallback(() => {
flushPending()
}, [flushPending])
/** /**
* Renders the line numbers, aligned with wrapped visual lines and highlighting the active line. * Renders the line numbers, aligned with wrapped visual lines and highlighting the active line.
@@ -824,7 +791,6 @@ export const Code = memo(function Code({
onValueChange={handleValueChange} onValueChange={handleValueChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onFocus={handleEditorFocus} onFocus={handleEditorFocus}
onBlur={handleEditorBlur}
highlight={highlightCode} highlight={highlightCode}
{...getCodeEditorProps({ isStreaming: isAiStreaming, isPreview, disabled })} {...getCodeEditorProps({ isStreaming: isAiStreaming, isPreview, disabled })}
/> />

View File

@@ -338,11 +338,6 @@ const arePropsEqual = (prevProps: SubBlockProps, nextProps: SubBlockProps): bool
const configEqual = const configEqual =
prevProps.config.id === nextProps.config.id && prevProps.config.type === nextProps.config.type prevProps.config.id === nextProps.config.id && prevProps.config.type === nextProps.config.type
const canonicalToggleEqual =
!!prevProps.canonicalToggle === !!nextProps.canonicalToggle &&
prevProps.canonicalToggle?.mode === nextProps.canonicalToggle?.mode &&
prevProps.canonicalToggle?.disabled === nextProps.canonicalToggle?.disabled
return ( return (
prevProps.blockId === nextProps.blockId && prevProps.blockId === nextProps.blockId &&
configEqual && configEqual &&
@@ -351,7 +346,8 @@ const arePropsEqual = (prevProps: SubBlockProps, nextProps: SubBlockProps): bool
prevProps.disabled === nextProps.disabled && prevProps.disabled === nextProps.disabled &&
prevProps.fieldDiffStatus === nextProps.fieldDiffStatus && prevProps.fieldDiffStatus === nextProps.fieldDiffStatus &&
prevProps.allowExpandInPreview === nextProps.allowExpandInPreview && prevProps.allowExpandInPreview === nextProps.allowExpandInPreview &&
canonicalToggleEqual prevProps.canonicalToggle?.mode === nextProps.canonicalToggle?.mode &&
prevProps.canonicalToggle?.disabled === nextProps.canonicalToggle?.disabled
) )
} }

View File

@@ -38,7 +38,7 @@ import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/component
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config' import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
import { getSubBlockStableKey } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils' import { getSubBlockStableKey } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks' import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview' import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview'
import { getBlock } from '@/blocks/registry' import { getBlock } from '@/blocks/registry'
import type { SubBlockType } from '@/blocks/types' import type { SubBlockType } from '@/blocks/types'
import { useWorkflowState } from '@/hooks/queries/workflows' import { useWorkflowState } from '@/hooks/queries/workflows'
@@ -458,7 +458,7 @@ export function Editor() {
) : childWorkflowState ? ( ) : childWorkflowState ? (
<> <>
<div className='[&_*:active]:!cursor-grabbing [&_*]:!cursor-grab [&_.react-flow__handle]:!hidden h-full w-full'> <div className='[&_*:active]:!cursor-grabbing [&_*]:!cursor-grab [&_.react-flow__handle]:!hidden h-full w-full'>
<PreviewWorkflow <WorkflowPreview
workflowState={childWorkflowState} workflowState={childWorkflowState}
height={160} height={160}
width='100%' width='100%'
@@ -466,7 +466,6 @@ export function Editor() {
defaultZoom={0.6} defaultZoom={0.6}
fitPadding={0.15} fitPadding={0.15}
cursorStyle='grab' cursorStyle='grab'
lightweight
/> />
</div> </div>
<Tooltip.Root> <Tooltip.Root>

View File

@@ -1,121 +0,0 @@
'use client'
import { memo } from 'react'
import clsx from 'clsx'
import { Filter } from 'lucide-react'
import {
Button,
Popover,
PopoverContent,
PopoverDivider,
PopoverItem,
PopoverScrollArea,
PopoverSection,
PopoverTrigger,
} from '@/components/emcn'
import type {
BlockInfo,
TerminalFilters,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types'
import { getBlockIcon } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils'
/**
* Props for the FilterPopover component
*/
export interface FilterPopoverProps {
open: boolean
onOpenChange: (open: boolean) => void
filters: TerminalFilters
toggleStatus: (status: 'error' | 'info') => void
toggleBlock: (blockId: string) => void
uniqueBlocks: BlockInfo[]
hasActiveFilters: boolean
}
/**
* Filter popover component used in terminal header and output panel
*/
export const FilterPopover = memo(function FilterPopover({
open,
onOpenChange,
filters,
toggleStatus,
toggleBlock,
uniqueBlocks,
hasActiveFilters,
}: FilterPopoverProps) {
return (
<Popover open={open} onOpenChange={onOpenChange} size='sm'>
<PopoverTrigger asChild>
<Button
variant='ghost'
className='!p-1.5 -m-1.5'
onClick={(e) => e.stopPropagation()}
aria-label='Filters'
>
<Filter
className={clsx('h-3 w-3', hasActiveFilters && 'text-[var(--brand-secondary)]')}
/>
</Button>
</PopoverTrigger>
<PopoverContent
side='top'
align='end'
sideOffset={4}
onClick={(e) => e.stopPropagation()}
minWidth={160}
maxWidth={220}
maxHeight={300}
>
<PopoverSection>Status</PopoverSection>
<PopoverItem
active={filters.statuses.has('error')}
showCheck={filters.statuses.has('error')}
onClick={() => toggleStatus('error')}
>
<div
className='h-[6px] w-[6px] rounded-[2px]'
style={{ backgroundColor: 'var(--text-error)' }}
/>
<span className='flex-1'>Error</span>
</PopoverItem>
<PopoverItem
active={filters.statuses.has('info')}
showCheck={filters.statuses.has('info')}
onClick={() => toggleStatus('info')}
>
<div
className='h-[6px] w-[6px] rounded-[2px]'
style={{ backgroundColor: 'var(--terminal-status-info-color)' }}
/>
<span className='flex-1'>Info</span>
</PopoverItem>
{uniqueBlocks.length > 0 && (
<>
<PopoverDivider className='my-[4px]' />
<PopoverSection className='!mt-0'>Blocks</PopoverSection>
<PopoverScrollArea className='max-h-[100px]'>
{uniqueBlocks.map((block) => {
const BlockIcon = getBlockIcon(block.blockType)
const isSelected = filters.blockIds.has(block.blockId)
return (
<PopoverItem
key={block.blockId}
active={isSelected}
showCheck={isSelected}
onClick={() => toggleBlock(block.blockId)}
>
{BlockIcon && <BlockIcon className='h-3 w-3' />}
<span className='flex-1'>{block.blockName}</span>
</PopoverItem>
)
})}
</PopoverScrollArea>
</>
)}
</PopoverContent>
</Popover>
)
})

View File

@@ -1 +0,0 @@
export { FilterPopover, type FilterPopoverProps } from './filter-popover'

View File

@@ -1,5 +1,2 @@
export { FilterPopover, type FilterPopoverProps } from './filter-popover' export { LogRowContextMenu } from './log-row-context-menu'
export { LogRowContextMenu, type LogRowContextMenuProps } from './log-row-context-menu' export { OutputContextMenu } from './output-context-menu'
export { OutputPanel, type OutputPanelProps } from './output-panel'
export { RunningBadge, StatusDisplay, type StatusDisplayProps } from './status-display'
export { ToggleButton, type ToggleButtonProps } from './toggle-button'

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { memo, type RefObject } from 'react' import type { RefObject } from 'react'
import { import {
Popover, Popover,
PopoverAnchor, PopoverAnchor,
@@ -8,13 +8,20 @@ import {
PopoverDivider, PopoverDivider,
PopoverItem, PopoverItem,
} from '@/components/emcn' } from '@/components/emcn'
import type {
ContextMenuPosition,
TerminalFilters,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types'
import type { ConsoleEntry } from '@/stores/terminal' import type { ConsoleEntry } from '@/stores/terminal'
export interface LogRowContextMenuProps { interface ContextMenuPosition {
x: number
y: number
}
interface TerminalFilters {
blockIds: Set<string>
statuses: Set<'error' | 'info'>
runIds: Set<string>
}
interface LogRowContextMenuProps {
isOpen: boolean isOpen: boolean
position: ContextMenuPosition position: ContextMenuPosition
menuRef: RefObject<HTMLDivElement | null> menuRef: RefObject<HTMLDivElement | null>
@@ -23,16 +30,19 @@ export interface LogRowContextMenuProps {
filters: TerminalFilters filters: TerminalFilters
onFilterByBlock: (blockId: string) => void onFilterByBlock: (blockId: string) => void
onFilterByStatus: (status: 'error' | 'info') => void onFilterByStatus: (status: 'error' | 'info') => void
onFilterByRunId: (runId: string) => void
onCopyRunId: (runId: string) => void onCopyRunId: (runId: string) => void
onClearFilters: () => void
onClearConsole: () => void onClearConsole: () => void
onFixInCopilot: (entry: ConsoleEntry) => void onFixInCopilot: (entry: ConsoleEntry) => void
hasActiveFilters: boolean
} }
/** /**
* Context menu for terminal log rows (left side). * Context menu for terminal log rows (left side).
* Displays filtering options based on the selected row's properties. * Displays filtering options based on the selected row's properties.
*/ */
export const LogRowContextMenu = memo(function LogRowContextMenu({ export function LogRowContextMenu({
isOpen, isOpen,
position, position,
menuRef, menuRef,
@@ -41,15 +51,19 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({
filters, filters,
onFilterByBlock, onFilterByBlock,
onFilterByStatus, onFilterByStatus,
onFilterByRunId,
onCopyRunId, onCopyRunId,
onClearFilters,
onClearConsole, onClearConsole,
onFixInCopilot, onFixInCopilot,
hasActiveFilters,
}: LogRowContextMenuProps) { }: LogRowContextMenuProps) {
const hasRunId = entry?.executionId != null const hasRunId = entry?.executionId != null
const isBlockFiltered = entry ? filters.blockIds.has(entry.blockId) : false const isBlockFiltered = entry ? filters.blockIds.has(entry.blockId) : false
const entryStatus = entry?.success ? 'info' : 'error' const entryStatus = entry?.success ? 'info' : 'error'
const isStatusFiltered = entry ? filters.statuses.has(entryStatus) : false const isStatusFiltered = entry ? filters.statuses.has(entryStatus) : false
const isRunIdFiltered = entry?.executionId ? filters.runIds.has(entry.executionId) : false
return ( return (
<Popover <Popover
@@ -120,11 +134,34 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({
> >
Filter by Status Filter by Status
</PopoverItem> </PopoverItem>
{hasRunId && (
<PopoverItem
showCheck={isRunIdFiltered}
onClick={() => {
onFilterByRunId(entry.executionId!)
onClose()
}}
>
Filter by Run ID
</PopoverItem>
)}
</> </>
)} )}
{/* Clear filters */}
{hasActiveFilters && (
<PopoverItem
onClick={() => {
onClearFilters()
onClose()
}}
>
Clear All Filters
</PopoverItem>
)}
{/* Destructive action */} {/* Destructive action */}
{entry && <PopoverDivider />} {(entry || hasActiveFilters) && <PopoverDivider />}
<PopoverItem <PopoverItem
onClick={() => { onClick={() => {
onClearConsole() onClearConsole()
@@ -136,4 +173,4 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({
</PopoverContent> </PopoverContent>
</Popover> </Popover>
) )
}) }

View File

@@ -1 +0,0 @@
export { LogRowContextMenu, type LogRowContextMenuProps } from './log-row-context-menu'

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { memo, type RefObject } from 'react' import type { RefObject } from 'react'
import { import {
Popover, Popover,
PopoverAnchor, PopoverAnchor,
@@ -8,9 +8,13 @@ import {
PopoverDivider, PopoverDivider,
PopoverItem, PopoverItem,
} from '@/components/emcn' } from '@/components/emcn'
import type { ContextMenuPosition } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types'
export interface OutputContextMenuProps { interface ContextMenuPosition {
x: number
y: number
}
interface OutputContextMenuProps {
isOpen: boolean isOpen: boolean
position: ContextMenuPosition position: ContextMenuPosition
menuRef: RefObject<HTMLDivElement | null> menuRef: RefObject<HTMLDivElement | null>
@@ -18,8 +22,6 @@ export interface OutputContextMenuProps {
onCopySelection: () => void onCopySelection: () => void
onCopyAll: () => void onCopyAll: () => void
onSearch: () => void onSearch: () => void
structuredView: boolean
onToggleStructuredView: () => void
wrapText: boolean wrapText: boolean
onToggleWrap: () => void onToggleWrap: () => void
openOnRun: boolean openOnRun: boolean
@@ -32,7 +34,7 @@ export interface OutputContextMenuProps {
* Context menu for terminal output panel (right side). * Context menu for terminal output panel (right side).
* Displays copy, search, and display options for the code viewer. * Displays copy, search, and display options for the code viewer.
*/ */
export const OutputContextMenu = memo(function OutputContextMenu({ export function OutputContextMenu({
isOpen, isOpen,
position, position,
menuRef, menuRef,
@@ -40,8 +42,6 @@ export const OutputContextMenu = memo(function OutputContextMenu({
onCopySelection, onCopySelection,
onCopyAll, onCopyAll,
onSearch, onSearch,
structuredView,
onToggleStructuredView,
wrapText, wrapText,
onToggleWrap, onToggleWrap,
openOnRun, openOnRun,
@@ -96,9 +96,6 @@ export const OutputContextMenu = memo(function OutputContextMenu({
{/* Display settings - toggles don't close menu */} {/* Display settings - toggles don't close menu */}
<PopoverDivider /> <PopoverDivider />
<PopoverItem showCheck={structuredView} onClick={onToggleStructuredView}>
Structured View
</PopoverItem>
<PopoverItem showCheck={wrapText} onClick={onToggleWrap}> <PopoverItem showCheck={wrapText} onClick={onToggleWrap}>
Wrap Text Wrap Text
</PopoverItem> </PopoverItem>
@@ -119,4 +116,4 @@ export const OutputContextMenu = memo(function OutputContextMenu({
</PopoverContent> </PopoverContent>
</Popover> </Popover>
) )
}) }

View File

@@ -1,609 +0,0 @@
'use client'
import type React from 'react'
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { Badge, ChevronDown } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
type ValueType = 'null' | 'undefined' | 'array' | 'string' | 'number' | 'boolean' | 'object'
type BadgeVariant = 'green' | 'blue' | 'orange' | 'purple' | 'gray' | 'red'
interface NodeEntry {
key: string
value: unknown
path: string
}
/**
* Search context for the structured output tree.
* Separates stable values (query, pathToMatchIndices) from frequently changing currentMatchIndex
* to avoid unnecessary re-renders of the entire tree.
*/
interface SearchContextValue {
query: string
pathToMatchIndices: Map<string, number[]>
currentMatchIndexRef: React.RefObject<number>
}
const SearchContext = createContext<SearchContextValue | null>(null)
const BADGE_VARIANTS: Record<ValueType, BadgeVariant> = {
string: 'green',
number: 'blue',
boolean: 'orange',
array: 'purple',
null: 'gray',
undefined: 'gray',
object: 'gray',
} as const
const STYLES = {
row: 'group flex min-h-[22px] cursor-pointer items-center gap-[6px] rounded-[8px] px-[6px] -mx-[6px] hover:bg-[var(--surface-6)] dark:hover:bg-[var(--surface-5)]',
chevron:
'h-[8px] w-[8px] flex-shrink-0 text-[var(--text-tertiary)] transition-transform duration-100 group-hover:text-[var(--text-primary)]',
keyName:
'font-medium text-[13px] text-[var(--text-primary)] group-hover:text-[var(--text-primary)]',
badge: 'rounded-[4px] px-[4px] py-[0px] text-[11px]',
summary: 'text-[12px] text-[var(--text-tertiary)]',
indent:
'mt-[2px] ml-[3px] flex min-w-0 flex-col gap-[2px] border-[var(--border)] border-l pl-[9px]',
value: 'py-[2px] text-[13px] text-[var(--text-primary)]',
emptyValue: 'py-[2px] text-[13px] text-[var(--text-tertiary)]',
matchHighlight: 'bg-yellow-200/60 dark:bg-yellow-500/40',
currentMatchHighlight: 'bg-orange-400',
} as const
const EMPTY_MATCH_INDICES: number[] = []
/**
* Returns the type label for a value
*/
function getTypeLabel(value: unknown): ValueType {
if (value === null) return 'null'
if (value === undefined) return 'undefined'
if (Array.isArray(value)) return 'array'
return typeof value as ValueType
}
/**
* Formats a primitive value for display
*/
function formatPrimitive(value: unknown): string {
if (value === null) return 'null'
if (value === undefined) return 'undefined'
return String(value)
}
/**
* Checks if a value is a primitive (not object/array)
*/
function isPrimitive(value: unknown): value is null | undefined | string | number | boolean {
return value === null || value === undefined || typeof value !== 'object'
}
/**
* Checks if a value is an empty object or array
*/
function isEmpty(value: unknown): boolean {
if (Array.isArray(value)) return value.length === 0
if (typeof value === 'object' && value !== null) return Object.keys(value).length === 0
return false
}
/**
* Extracts error message from various error data formats
*/
function extractErrorMessage(data: unknown): string {
if (typeof data === 'string') return data
if (data instanceof Error) return data.message
if (typeof data === 'object' && data !== null && 'message' in data) {
return String((data as { message: unknown }).message)
}
return JSON.stringify(data, null, 2)
}
/**
* Builds node entries from an object or array value
*/
function buildEntries(value: unknown, basePath: string): NodeEntry[] {
if (Array.isArray(value)) {
return value.map((item, i) => ({ key: String(i), value: item, path: `${basePath}[${i}]` }))
}
return Object.entries(value as Record<string, unknown>).map(([k, v]) => ({
key: k,
value: v,
path: `${basePath}.${k}`,
}))
}
/**
* Gets the count summary for collapsed arrays/objects
*/
function getCollapsedSummary(value: unknown): string | null {
if (Array.isArray(value)) {
const len = value.length
return `${len} item${len !== 1 ? 's' : ''}`
}
if (typeof value === 'object' && value !== null) {
const count = Object.keys(value).length
return `${count} key${count !== 1 ? 's' : ''}`
}
return null
}
/**
* Computes initial expanded paths for first-level items
*/
function computeInitialPaths(data: unknown, isError: boolean): Set<string> {
if (isError) return new Set(['root.error'])
if (!data || typeof data !== 'object') return new Set()
const entries = Array.isArray(data)
? data.map((_, i) => `root[${i}]`)
: Object.keys(data).map((k) => `root.${k}`)
return new Set(entries)
}
/**
* Gets all ancestor paths needed to reach a given path
*/
function getAncestorPaths(path: string): string[] {
const ancestors: string[] = []
let current = path
while (current.includes('.') || current.includes('[')) {
const splitPoint = Math.max(current.lastIndexOf('.'), current.lastIndexOf('['))
if (splitPoint <= 0) break
current = current.slice(0, splitPoint)
if (current !== 'root') ancestors.push(current)
}
return ancestors
}
/**
* Finds all case-insensitive matches of a query within text
*/
function findTextMatches(text: string, query: string): Array<[number, number]> {
if (!query) return []
const matches: Array<[number, number]> = []
const lowerText = text.toLowerCase()
const lowerQuery = query.toLowerCase()
let pos = 0
while (pos < lowerText.length) {
const idx = lowerText.indexOf(lowerQuery, pos)
if (idx === -1) break
matches.push([idx, idx + query.length])
pos = idx + 1
}
return matches
}
/**
* Adds match entries for a primitive value at the given path
*/
function addPrimitiveMatches(value: unknown, path: string, query: string, matches: string[]): void {
const text = formatPrimitive(value)
const count = findTextMatches(text, query).length
for (let i = 0; i < count; i++) {
matches.push(path)
}
}
/**
* Recursively collects all match paths across the entire data tree
*/
function collectAllMatchPaths(data: unknown, query: string, basePath: string): string[] {
if (!query) return []
const matches: string[] = []
if (isPrimitive(data)) {
addPrimitiveMatches(data, `${basePath}.value`, query, matches)
return matches
}
for (const entry of buildEntries(data, basePath)) {
if (isPrimitive(entry.value)) {
addPrimitiveMatches(entry.value, entry.path, query, matches)
} else {
matches.push(...collectAllMatchPaths(entry.value, query, entry.path))
}
}
return matches
}
/**
* Builds a map from path to array of global match indices
*/
function buildPathToIndicesMap(matchPaths: string[]): Map<string, number[]> {
const map = new Map<string, number[]>()
matchPaths.forEach((path, globalIndex) => {
const existing = map.get(path)
if (existing) {
existing.push(globalIndex)
} else {
map.set(path, [globalIndex])
}
})
return map
}
interface HighlightedTextProps {
text: string
matchIndices: number[]
path: string
}
/**
* Renders text with search highlights.
* Uses context to access search state and avoid prop drilling.
*/
const HighlightedText = memo(function HighlightedText({
text,
matchIndices,
path,
}: HighlightedTextProps) {
const searchContext = useContext(SearchContext)
if (!searchContext || matchIndices.length === 0) return <>{text}</>
const textMatches = findTextMatches(text, searchContext.query)
if (textMatches.length === 0) return <>{text}</>
const currentMatchIndex = searchContext.currentMatchIndexRef.current
const segments: React.ReactNode[] = []
let lastEnd = 0
textMatches.forEach(([start, end], i) => {
const globalIndex = matchIndices[i]
const isCurrent = globalIndex === currentMatchIndex
if (start > lastEnd) {
segments.push(<span key={`t-${path}-${start}`}>{text.slice(lastEnd, start)}</span>)
}
segments.push(
<mark
key={`m-${path}-${start}`}
data-search-match
data-match-index={globalIndex}
className={cn(
'rounded-sm',
isCurrent ? STYLES.currentMatchHighlight : STYLES.matchHighlight
)}
>
{text.slice(start, end)}
</mark>
)
lastEnd = end
})
if (lastEnd < text.length) {
segments.push(<span key={`t-${path}-${lastEnd}`}>{text.slice(lastEnd)}</span>)
}
return <>{segments}</>
})
interface StructuredNodeProps {
name: string
value: unknown
path: string
expandedPaths: Set<string>
onToggle: (path: string) => void
wrapText: boolean
isError?: boolean
}
/**
* Recursive node component for rendering structured data.
* Uses context for search state to avoid re-renders when currentMatchIndex changes.
*/
const StructuredNode = memo(function StructuredNode({
name,
value,
path,
expandedPaths,
onToggle,
wrapText,
isError = false,
}: StructuredNodeProps) {
const searchContext = useContext(SearchContext)
const type = getTypeLabel(value)
const isPrimitiveValue = isPrimitive(value)
const isEmptyValue = !isPrimitiveValue && isEmpty(value)
const isExpanded = expandedPaths.has(path)
const handleToggle = useCallback(() => onToggle(path), [onToggle, path])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleToggle()
}
},
[handleToggle]
)
const childEntries = useMemo(
() => (isPrimitiveValue || isEmptyValue ? [] : buildEntries(value, path)),
[value, isPrimitiveValue, isEmptyValue, path]
)
const collapsedSummary = useMemo(
() => (isPrimitiveValue ? null : getCollapsedSummary(value)),
[value, isPrimitiveValue]
)
const badgeVariant = isError ? 'red' : BADGE_VARIANTS[type]
const valueText = isPrimitiveValue ? formatPrimitive(value) : ''
const matchIndices = searchContext?.pathToMatchIndices.get(path) ?? EMPTY_MATCH_INDICES
return (
<div className='flex min-w-0 flex-col'>
<div
className={STYLES.row}
onClick={handleToggle}
onKeyDown={handleKeyDown}
role='button'
tabIndex={0}
aria-expanded={isExpanded}
>
<span className={cn(STYLES.keyName, isError && 'text-[var(--text-error)]')}>{name}</span>
<Badge variant={badgeVariant} className={STYLES.badge}>
{type}
</Badge>
{!isExpanded && collapsedSummary && (
<span className={STYLES.summary}>{collapsedSummary}</span>
)}
<ChevronDown className={cn(STYLES.chevron, !isExpanded && '-rotate-90')} />
</div>
{isExpanded && (
<div className={STYLES.indent}>
{isPrimitiveValue ? (
<div
className={cn(
STYLES.value,
wrapText ? '[word-break:break-word]' : 'whitespace-nowrap'
)}
>
<HighlightedText text={valueText} matchIndices={matchIndices} path={path} />
</div>
) : isEmptyValue ? (
<div className={STYLES.emptyValue}>{Array.isArray(value) ? '[]' : '{}'}</div>
) : (
childEntries.map((entry) => (
<StructuredNode
key={entry.path}
name={entry.key}
value={entry.value}
path={entry.path}
expandedPaths={expandedPaths}
onToggle={onToggle}
wrapText={wrapText}
/>
))
)}
</div>
)}
</div>
)
})
export interface StructuredOutputProps {
data: unknown
wrapText?: boolean
isError?: boolean
isRunning?: boolean
className?: string
searchQuery?: string
currentMatchIndex?: number
onMatchCountChange?: (count: number) => void
contentRef?: React.RefObject<HTMLDivElement | null>
}
/**
* Renders structured data as nested collapsible blocks.
* Supports search with highlighting, auto-expand, and scroll-to-match.
* Uses React Context for search state to prevent re-render cascade.
*/
export const StructuredOutput = memo(function StructuredOutput({
data,
wrapText = true,
isError = false,
isRunning = false,
className,
searchQuery,
currentMatchIndex = 0,
onMatchCountChange,
contentRef,
}: StructuredOutputProps) {
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() =>
computeInitialPaths(data, isError)
)
const prevDataRef = useRef(data)
const prevIsErrorRef = useRef(isError)
const internalRef = useRef<HTMLDivElement>(null)
const currentMatchIndexRef = useRef(currentMatchIndex)
// Keep ref in sync
currentMatchIndexRef.current = currentMatchIndex
// Force re-render of highlighted text when currentMatchIndex changes
const [, forceUpdate] = useState(0)
useEffect(() => {
forceUpdate((n) => n + 1)
}, [currentMatchIndex])
const setContainerRef = useCallback(
(node: HTMLDivElement | null) => {
;(internalRef as React.MutableRefObject<HTMLDivElement | null>).current = node
if (contentRef) {
;(contentRef as React.MutableRefObject<HTMLDivElement | null>).current = node
}
},
[contentRef]
)
useEffect(() => {
if (prevDataRef.current !== data || prevIsErrorRef.current !== isError) {
prevDataRef.current = data
prevIsErrorRef.current = isError
setExpandedPaths(computeInitialPaths(data, isError))
}
}, [data, isError])
const allMatchPaths = useMemo(() => {
if (!searchQuery) return []
if (isError) {
const errorText = extractErrorMessage(data)
const count = findTextMatches(errorText, searchQuery).length
return Array(count).fill('root.error') as string[]
}
return collectAllMatchPaths(data, searchQuery, 'root')
}, [data, searchQuery, isError])
useEffect(() => {
onMatchCountChange?.(allMatchPaths.length)
}, [allMatchPaths.length, onMatchCountChange])
const pathToMatchIndices = useMemo(() => buildPathToIndicesMap(allMatchPaths), [allMatchPaths])
useEffect(() => {
if (
allMatchPaths.length === 0 ||
currentMatchIndex < 0 ||
currentMatchIndex >= allMatchPaths.length
) {
return
}
const currentPath = allMatchPaths[currentMatchIndex]
const pathsToExpand = [currentPath, ...getAncestorPaths(currentPath)]
setExpandedPaths((prev) => {
if (pathsToExpand.every((p) => prev.has(p))) return prev
const next = new Set(prev)
pathsToExpand.forEach((p) => next.add(p))
return next
})
}, [currentMatchIndex, allMatchPaths])
useEffect(() => {
if (allMatchPaths.length === 0) return
const rafId = requestAnimationFrame(() => {
const match = internalRef.current?.querySelector(
`[data-match-index="${currentMatchIndex}"]`
) as HTMLElement | null
match?.scrollIntoView({ block: 'center', behavior: 'smooth' })
})
return () => cancelAnimationFrame(rafId)
}, [currentMatchIndex, allMatchPaths.length, expandedPaths])
const handleToggle = useCallback((path: string) => {
setExpandedPaths((prev) => {
const next = new Set(prev)
if (next.has(path)) {
next.delete(path)
} else {
next.add(path)
}
return next
})
}, [])
const rootEntries = useMemo<NodeEntry[]>(() => {
if (isPrimitive(data)) {
return [{ key: 'value', value: data, path: 'root.value' }]
}
return buildEntries(data, 'root')
}, [data])
// Create stable search context value - only changes when query or pathToMatchIndices change
const searchContextValue = useMemo<SearchContextValue | null>(() => {
if (!searchQuery) return null
return {
query: searchQuery,
pathToMatchIndices,
currentMatchIndexRef,
}
}, [searchQuery, pathToMatchIndices])
const containerClass = cn('flex flex-col pl-[20px]', className)
// Show "Running" badge when running with undefined data
if (isRunning && data === undefined) {
return (
<div ref={setContainerRef} className={containerClass}>
<div className={STYLES.row}>
<span className={STYLES.keyName}>running</span>
<Badge variant='green' className={STYLES.badge}>
Running
</Badge>
</div>
</div>
)
}
if (isError) {
return (
<SearchContext.Provider value={searchContextValue}>
<div ref={setContainerRef} className={containerClass}>
<StructuredNode
name='error'
value={extractErrorMessage(data)}
path='root.error'
expandedPaths={expandedPaths}
onToggle={handleToggle}
wrapText={wrapText}
isError
/>
</div>
</SearchContext.Provider>
)
}
if (rootEntries.length === 0) {
return (
<div ref={setContainerRef} className={containerClass}>
<span className={STYLES.emptyValue}>null</span>
</div>
)
}
return (
<SearchContext.Provider value={searchContextValue}>
<div ref={setContainerRef} className={containerClass}>
{rootEntries.map((entry) => (
<StructuredNode
key={entry.path}
name={entry.key}
value={entry.value}
path={entry.path}
expandedPaths={expandedPaths}
onToggle={handleToggle}
wrapText={wrapText}
/>
))}
</div>
</SearchContext.Provider>
)
})

View File

@@ -1,4 +0,0 @@
export { OutputContextMenu, type OutputContextMenuProps } from './components/output-context-menu'
export { StructuredOutput, type StructuredOutputProps } from './components/structured-output'
export type { OutputPanelProps } from './output-panel'
export { OutputPanel } from './output-panel'

View File

@@ -1,643 +0,0 @@
'use client'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import {
ArrowDown,
ArrowDownToLine,
ArrowUp,
Check,
Clipboard,
Database,
MoreHorizontal,
Palette,
Pause,
Search,
Trash2,
X,
} from 'lucide-react'
import Link from 'next/link'
import {
Button,
Code,
Input,
Popover,
PopoverContent,
PopoverItem,
PopoverTrigger,
Tooltip,
} from '@/components/emcn'
import { FilterPopover } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/filter-popover'
import { OutputContextMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/output-context-menu'
import { StructuredOutput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output'
import { ToggleButton } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/toggle-button'
import type {
BlockInfo,
TerminalFilters,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
import type { ConsoleEntry } from '@/stores/terminal'
import { useTerminalStore } from '@/stores/terminal'
interface OutputCodeContentProps {
code: string
language: 'javascript' | 'json'
wrapText: boolean
searchQuery: string | undefined
currentMatchIndex: number
onMatchCountChange: (count: number) => void
contentRef: React.RefObject<HTMLDivElement | null>
}
const OutputCodeContent = React.memo(function OutputCodeContent({
code,
language,
wrapText,
searchQuery,
currentMatchIndex,
onMatchCountChange,
contentRef,
}: OutputCodeContentProps) {
return (
<Code.Viewer
code={code}
showGutter
language={language}
className='m-0 min-h-full rounded-none border-0 bg-[var(--surface-1)] dark:bg-[var(--surface-1)]'
paddingLeft={8}
gutterStyle={{ backgroundColor: 'transparent' }}
wrapText={wrapText}
searchQuery={searchQuery}
currentMatchIndex={currentMatchIndex}
onMatchCountChange={onMatchCountChange}
contentRef={contentRef}
virtualized
showCollapseColumn={language === 'json'}
/>
)
})
/**
* Props for the OutputPanel component
* Store-backed settings (wrapText, openOnRun, structuredView, outputPanelWidth)
* are accessed directly from useTerminalStore to reduce prop drilling.
*/
export interface OutputPanelProps {
selectedEntry: ConsoleEntry
handleOutputPanelResizeMouseDown: (e: React.MouseEvent) => void
handleHeaderClick: () => void
isExpanded: boolean
expandToLastHeight: () => void
showInput: boolean
setShowInput: (show: boolean) => void
hasInputData: boolean
isPlaygroundEnabled: boolean
shouldShowTrainingButton: boolean
isTraining: boolean
handleTrainingClick: (e: React.MouseEvent) => void
showCopySuccess: boolean
handleCopy: () => void
filteredEntries: ConsoleEntry[]
handleExportConsole: (e: React.MouseEvent) => void
hasActiveFilters: boolean
handleClearConsole: (e: React.MouseEvent) => void
shouldShowCodeDisplay: boolean
outputDataStringified: string
outputData: unknown
handleClearConsoleFromMenu: () => void
filters: TerminalFilters
toggleBlock: (blockId: string) => void
toggleStatus: (status: 'error' | 'info') => void
uniqueBlocks: BlockInfo[]
}
/**
* Output panel component that manages its own search state.
* Accesses store-backed settings directly to reduce prop drilling.
*/
export const OutputPanel = React.memo(function OutputPanel({
selectedEntry,
handleOutputPanelResizeMouseDown,
handleHeaderClick,
isExpanded,
expandToLastHeight,
showInput,
setShowInput,
hasInputData,
isPlaygroundEnabled,
shouldShowTrainingButton,
isTraining,
handleTrainingClick,
showCopySuccess,
handleCopy,
filteredEntries,
handleExportConsole,
hasActiveFilters,
handleClearConsole,
shouldShowCodeDisplay,
outputDataStringified,
outputData,
handleClearConsoleFromMenu,
filters,
toggleBlock,
toggleStatus,
uniqueBlocks,
}: OutputPanelProps) {
// Access store-backed settings directly to reduce prop drilling
const outputPanelWidth = useTerminalStore((state) => state.outputPanelWidth)
const wrapText = useTerminalStore((state) => state.wrapText)
const setWrapText = useTerminalStore((state) => state.setWrapText)
const openOnRun = useTerminalStore((state) => state.openOnRun)
const setOpenOnRun = useTerminalStore((state) => state.setOpenOnRun)
const structuredView = useTerminalStore((state) => state.structuredView)
const setStructuredView = useTerminalStore((state) => state.setStructuredView)
const outputContentRef = useRef<HTMLDivElement>(null)
const [filtersOpen, setFiltersOpen] = useState(false)
const [outputOptionsOpen, setOutputOptionsOpen] = useState(false)
const {
isSearchActive: isOutputSearchActive,
searchQuery: outputSearchQuery,
setSearchQuery: setOutputSearchQuery,
matchCount,
currentMatchIndex,
activateSearch: activateOutputSearch,
closeSearch: closeOutputSearch,
goToNextMatch,
goToPreviousMatch,
handleMatchCountChange,
searchInputRef: outputSearchInputRef,
} = useCodeViewerFeatures({
contentRef: outputContentRef,
externalWrapText: wrapText,
onWrapTextChange: setWrapText,
})
// Context menu state for output panel
const [hasSelection, setHasSelection] = useState(false)
const [storedSelectionText, setStoredSelectionText] = useState('')
const {
isOpen: isOutputMenuOpen,
position: outputMenuPosition,
menuRef: outputMenuRef,
handleContextMenu: handleOutputContextMenu,
closeMenu: closeOutputMenu,
} = useContextMenu()
const handleOutputPanelContextMenu = useCallback(
(e: React.MouseEvent) => {
const selection = window.getSelection()
const selectionText = selection?.toString() || ''
setStoredSelectionText(selectionText)
setHasSelection(selectionText.length > 0)
handleOutputContextMenu(e)
},
[handleOutputContextMenu]
)
const handleCopySelection = useCallback(() => {
if (storedSelectionText) {
navigator.clipboard.writeText(storedSelectionText)
}
}, [storedSelectionText])
// Memoized callbacks to avoid inline arrow functions
const handleToggleStructuredView = useCallback(() => {
setStructuredView(!structuredView)
}, [structuredView, setStructuredView])
const handleToggleWrapText = useCallback(() => {
setWrapText(!wrapText)
}, [wrapText, setWrapText])
const handleToggleOpenOnRun = useCallback(() => {
setOpenOnRun(!openOnRun)
}, [openOnRun, setOpenOnRun])
const handleCopyClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
handleCopy()
},
[handleCopy]
)
const handleSearchClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
activateOutputSearch()
},
[activateOutputSearch]
)
const handleCloseSearchClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
closeOutputSearch()
},
[closeOutputSearch]
)
const handleOutputButtonClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
if (!isExpanded) {
expandToLastHeight()
}
if (showInput) setShowInput(false)
},
[isExpanded, expandToLastHeight, showInput, setShowInput]
)
const handleInputButtonClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
if (!isExpanded) {
expandToLastHeight()
}
setShowInput(true)
},
[isExpanded, expandToLastHeight, setShowInput]
)
const handleToggleButtonClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
handleHeaderClick()
},
[handleHeaderClick]
)
/**
* Track text selection state for context menu.
* Skip updates when the context menu is open to prevent the selection
* state from changing mid-click (which would disable the copy button).
*/
useEffect(() => {
const handleSelectionChange = () => {
if (isOutputMenuOpen) return
const selection = window.getSelection()
setHasSelection(Boolean(selection && selection.toString().length > 0))
}
document.addEventListener('selectionchange', handleSelectionChange)
return () => document.removeEventListener('selectionchange', handleSelectionChange)
}, [isOutputMenuOpen])
// Memoize the search query for structured output to avoid re-renders
const structuredSearchQuery = useMemo(
() => (isOutputSearchActive ? outputSearchQuery : undefined),
[isOutputSearchActive, outputSearchQuery]
)
return (
<>
<div
className='absolute top-0 right-0 bottom-0 flex flex-col border-[var(--border)] border-l bg-[var(--surface-1)]'
style={{ width: `${outputPanelWidth}px` }}
>
{/* Horizontal Resize Handle */}
<div
className='-ml-[4px] absolute top-0 bottom-0 left-0 z-20 w-[8px] cursor-ew-resize'
onMouseDown={handleOutputPanelResizeMouseDown}
role='separator'
aria-label='Resize output panel'
aria-orientation='vertical'
/>
{/* Header */}
<div
className='group flex h-[30px] flex-shrink-0 cursor-pointer items-center justify-between bg-[var(--surface-1)] pr-[16px] pl-[10px]'
onClick={handleHeaderClick}
>
<div className='flex items-center'>
<Button
variant='ghost'
className={clsx(
'px-[8px] py-[6px] text-[12px]',
!showInput ? '!text-[var(--text-primary)]' : '!text-[var(--text-tertiary)]'
)}
onClick={handleOutputButtonClick}
aria-label='Show output'
>
Output
</Button>
{hasInputData && (
<Button
variant='ghost'
className={clsx(
'px-[8px] py-[6px] text-[12px]',
showInput ? '!text-[var(--text-primary)]' : '!text-[var(--text-tertiary)]'
)}
onClick={handleInputButtonClick}
aria-label='Show input'
>
Input
</Button>
)}
</div>
<div className='flex flex-shrink-0 items-center gap-[8px]'>
{/* Unified filter popover */}
{filteredEntries.length > 0 && (
<FilterPopover
open={filtersOpen}
onOpenChange={setFiltersOpen}
filters={filters}
toggleStatus={toggleStatus}
toggleBlock={toggleBlock}
uniqueBlocks={uniqueBlocks}
hasActiveFilters={hasActiveFilters}
/>
)}
{isOutputSearchActive ? (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={handleCloseSearchClick}
aria-label='Close search'
className='!p-1.5 -m-1.5'
>
<X className='h-[12px] w-[12px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>Close search</span>
</Tooltip.Content>
</Tooltip.Root>
) : (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={handleSearchClick}
aria-label='Search in output'
className='!p-1.5 -m-1.5'
>
<Search className='h-[12px] w-[12px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>Search</span>
</Tooltip.Content>
</Tooltip.Root>
)}
{isPlaygroundEnabled && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Link href='/playground'>
<Button
variant='ghost'
aria-label='Component Playground'
className='!p-1.5 -m-1.5'
>
<Palette className='h-[12px] w-[12px]' />
</Button>
</Link>
</Tooltip.Trigger>
<Tooltip.Content>
<span>Component Playground</span>
</Tooltip.Content>
</Tooltip.Root>
)}
{shouldShowTrainingButton && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={handleTrainingClick}
aria-label={isTraining ? 'Stop training' : 'Train Copilot'}
className={clsx(
'!p-1.5 -m-1.5',
isTraining && 'text-orange-600 dark:text-orange-400'
)}
>
{isTraining ? (
<Pause className='h-[12px] w-[12px]' />
) : (
<Database className='h-[12px] w-[12px]' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>{isTraining ? 'Stop Training' : 'Train Copilot'}</span>
</Tooltip.Content>
</Tooltip.Root>
)}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={handleCopyClick}
aria-label='Copy output'
className='!p-1.5 -m-1.5'
>
{showCopySuccess ? (
<Check className='h-[12px] w-[12px]' />
) : (
<Clipboard className='h-[12px] w-[12px]' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>{showCopySuccess ? 'Copied' : 'Copy output'}</span>
</Tooltip.Content>
</Tooltip.Root>
{filteredEntries.length > 0 && (
<>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={handleExportConsole}
aria-label='Download console CSV'
className='!p-1.5 -m-1.5'
>
<ArrowDownToLine className='h-3 w-3' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>Download CSV</span>
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={handleClearConsole}
aria-label='Clear console'
className='!p-1.5 -m-1.5'
>
<Trash2 className='h-3 w-3' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<Tooltip.Shortcut keys='⌘D'>Clear console</Tooltip.Shortcut>
</Tooltip.Content>
</Tooltip.Root>
</>
)}
<Popover open={outputOptionsOpen} onOpenChange={setOutputOptionsOpen} size='sm'>
<PopoverTrigger asChild>
<Button
variant='ghost'
onClick={(e) => e.stopPropagation()}
aria-label='Terminal options'
className='!p-1.5 -m-1.5'
>
<MoreHorizontal className='h-3.5 w-3.5' />
</Button>
</PopoverTrigger>
<PopoverContent
side='bottom'
align='end'
sideOffset={4}
collisionPadding={0}
onClick={(e) => e.stopPropagation()}
style={{ minWidth: '140px', maxWidth: '160px' }}
className='gap-[2px]'
>
<PopoverItem
active={structuredView}
showCheck={structuredView}
onClick={handleToggleStructuredView}
>
<span>Structured view</span>
</PopoverItem>
<PopoverItem active={wrapText} showCheck={wrapText} onClick={handleToggleWrapText}>
<span>Wrap text</span>
</PopoverItem>
<PopoverItem
active={openOnRun}
showCheck={openOnRun}
onClick={handleToggleOpenOnRun}
>
<span>Open on run</span>
</PopoverItem>
</PopoverContent>
</Popover>
<ToggleButton isExpanded={isExpanded} onClick={handleToggleButtonClick} />
</div>
</div>
{/* Search Overlay */}
{isOutputSearchActive && (
<div
className='absolute top-[30px] right-[8px] z-30 flex h-[34px] items-center gap-[6px] rounded-b-[4px] border border-[var(--border)] border-t-0 bg-[var(--surface-1)] px-[6px] shadow-sm'
onClick={(e) => e.stopPropagation()}
data-toolbar-root
data-search-active='true'
>
<Input
ref={outputSearchInputRef}
type='text'
value={outputSearchQuery}
onChange={(e) => setOutputSearchQuery(e.target.value)}
placeholder='Search...'
className='mr-[2px] h-[23px] w-[94px] text-[12px]'
/>
<span
className={clsx(
'w-[58px] font-medium text-[11px]',
matchCount > 0 ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]'
)}
>
{matchCount > 0 ? `${currentMatchIndex + 1}/${matchCount}` : 'No results'}
</span>
<Button
variant='ghost'
onClick={goToPreviousMatch}
aria-label='Previous match'
className='!p-1.5 -m-1.5'
disabled={matchCount === 0}
>
<ArrowUp className='h-[12px] w-[12px]' />
</Button>
<Button
variant='ghost'
onClick={goToNextMatch}
aria-label='Next match'
className='!p-1.5 -m-1.5'
disabled={matchCount === 0}
>
<ArrowDown className='h-[12px] w-[12px]' />
</Button>
<Button
variant='ghost'
onClick={closeOutputSearch}
aria-label='Close search'
className='!p-1.5 -m-1.5'
>
<X className='h-[12px] w-[12px]' />
</Button>
</div>
)}
{/* Content */}
<div
className={clsx('flex-1 overflow-y-auto', !wrapText && 'overflow-x-auto')}
onContextMenu={handleOutputPanelContextMenu}
>
{shouldShowCodeDisplay ? (
<OutputCodeContent
code={selectedEntry.input.code}
language={(selectedEntry.input.language as 'javascript' | 'json') || 'javascript'}
wrapText={wrapText}
searchQuery={structuredSearchQuery}
currentMatchIndex={currentMatchIndex}
onMatchCountChange={handleMatchCountChange}
contentRef={outputContentRef}
/>
) : structuredView ? (
<StructuredOutput
data={outputData}
wrapText={wrapText}
isError={!showInput && Boolean(selectedEntry.error)}
isRunning={!showInput && Boolean(selectedEntry.isRunning)}
className='min-h-full'
searchQuery={structuredSearchQuery}
currentMatchIndex={currentMatchIndex}
onMatchCountChange={handleMatchCountChange}
contentRef={outputContentRef}
/>
) : (
<OutputCodeContent
code={outputDataStringified}
language='json'
wrapText={wrapText}
searchQuery={structuredSearchQuery}
currentMatchIndex={currentMatchIndex}
onMatchCountChange={handleMatchCountChange}
contentRef={outputContentRef}
/>
)}
</div>
</div>
{/* Output Panel Context Menu */}
<OutputContextMenu
isOpen={isOutputMenuOpen}
position={outputMenuPosition}
menuRef={outputMenuRef}
onClose={closeOutputMenu}
onCopySelection={handleCopySelection}
onCopyAll={handleCopy}
onSearch={activateOutputSearch}
structuredView={structuredView}
onToggleStructuredView={handleToggleStructuredView}
wrapText={wrapText}
onToggleWrap={handleToggleWrapText}
openOnRun={openOnRun}
onToggleOpenOnRun={handleToggleOpenOnRun}
onClearConsole={handleClearConsoleFromMenu}
hasSelection={hasSelection}
/>
</>
)
})

View File

@@ -1 +0,0 @@
export { RunningBadge, StatusDisplay, type StatusDisplayProps } from './status-display'

View File

@@ -1,43 +0,0 @@
'use client'
import { memo } from 'react'
import { Badge } from '@/components/emcn'
import { BADGE_STYLE } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types'
/**
* Running badge component - displays a consistent "Running" indicator
*/
export const RunningBadge = memo(function RunningBadge() {
return (
<Badge variant='green' className={BADGE_STYLE}>
Running
</Badge>
)
})
/**
* Props for StatusDisplay component
*/
export interface StatusDisplayProps {
isRunning: boolean
isCanceled: boolean
formattedDuration: string
}
/**
* Reusable status display for terminal rows.
* Shows Running badge, 'canceled' text, or formatted duration.
*/
export const StatusDisplay = memo(function StatusDisplay({
isRunning,
isCanceled,
formattedDuration,
}: StatusDisplayProps) {
if (isRunning) {
return <RunningBadge />
}
if (isCanceled) {
return <>canceled</>
}
return <>{formattedDuration}</>
})

View File

@@ -1 +0,0 @@
export { ToggleButton, type ToggleButtonProps } from './toggle-button'

View File

@@ -1,33 +0,0 @@
'use client'
import type React from 'react'
import { memo } from 'react'
import clsx from 'clsx'
import { ChevronDown } from 'lucide-react'
import { Button } from '@/components/emcn'
export interface ToggleButtonProps {
isExpanded: boolean
onClick: (e: React.MouseEvent) => void
}
/**
* Toggle button component for terminal expand/collapse
*/
export const ToggleButton = memo(function ToggleButton({ isExpanded, onClick }: ToggleButtonProps) {
return (
<Button
variant='ghost'
className='!p-1.5 -m-1.5'
onClick={onClick}
aria-label='Toggle terminal'
>
<ChevronDown
className={clsx(
'h-3.5 w-3.5 flex-shrink-0 transition-transform duration-100',
!isExpanded && 'rotate-180'
)}
/>
</Button>
)
})

View File

@@ -1,4 +1,3 @@
export type { SortConfig, SortDirection, SortField, TerminalFilters } from '../types'
export { useOutputPanelResize } from './use-output-panel-resize' export { useOutputPanelResize } from './use-output-panel-resize'
export { useTerminalFilters } from './use-terminal-filters' export { useTerminalFilters } from './use-terminal-filters'
export { useTerminalResize } from './use-terminal-resize' export { useTerminalResize } from './use-terminal-resize'

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