Compare commits

...

28 Commits

Author SHA1 Message Date
Waleed
30377d775b improvement(ui): sidebar (#3832) 2026-03-28 15:24:07 -07:00
Waleed
d013132d0e improvement(home): position @ mention popup at caret and fix icon consistency (#3831)
* improvement(home): position @ mention popup at caret and fix icon consistency

* fix(home): pin mirror div to document origin and guard button anchor

* chore(auth): restore hybrid.ts to staging
2026-03-28 14:48:41 -07:00
Waleed
7b0ce8064a feat(files): interactive markdown checkbox toggling in preview (#3829)
* feat(files): interactive markdown checkbox toggling in preview

* fix(files): handle ordered-list checkboxes and fix index drift

* lint

* fix(files): remove counter offset that prevented checkbox toggling

* fix(files): apply task-list styling to ordered lists too

* fix(files): render single pass when interactive to avoid index drift

* fix(files): move useMemo above conditional return to fix Rules of Hooks

* fix(files): pass content directly to preview when not streaming to avoid stale frame
2026-03-28 14:26:29 -07:00
Theodore Li
0ea73263df feat(ui): handle image paste (#3826)
* feat(ui): handle image paste

* Fix lint

* Fix type error

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-03-28 17:15:33 -04:00
Waleed
edc502384b improvement(sidebar): expand sidebar by hovering and clicking the edge (#3830)
* improvement(sidebar): expand sidebar by hovering and clicking the edge

* improvement(sidebar): add keyboard shortcuts for new workflow/task, center search modal, fix edge ARIA

* improvement(sidebar): use Tooltip.Shortcut for inline shortcut display

* fix(sidebar): change new workflow shortcut from Mod+Shift+W to Mod+Shift+P to avoid browser close-window conflict

* fix(hotkeys): fall back to event.code for international keyboard layout compatibility

* fix(sidebar): guard add-workflow shortcut with canEdit and isCreatingWorkflow checks
2026-03-28 14:06:30 -07:00
Waleed
e2be99263c feat(academy): Sim Academy — interactive partner certification platform (#3824)
* fix(import): dedup workflow name (#3813)

* feat(concurrency): bullmq based concurrency control system (#3605)

* feat(concurrency): bullmq based queueing system

* fix bun lock

* remove manual execs off queues

* address comments

* fix legacy team limits

* cleanup enterprise typing code

* inline child triggers

* fix status check

* address more comments

* optimize reconciler scan

* remove dead code

* add to landing page

* Add load testing framework

* update bullmq

* fix

* fix headless path

---------

Co-authored-by: Theodore Li <teddy@zenobiapay.com>

* fix(linear): add default null for after cursor (#3814)

* fix(knowledge): reject non-alphanumeric file extensions from document names (#3816)

* fix(knowledge): reject non-alphanumeric file extensions from document names

* fix(knowledge): improve error message when extension is non-alphanumeric

* fix(security): SSRF, access control, and info disclosure (#3815)

* fix(security): scope copilot feedback GET endpoint to authenticated user

Add WHERE clause to filter feedback records by the authenticated user's
ID, preventing any authenticated user from reading all users' copilot
interactions, queries, and workflow YAML (IDOR / CWE-639).

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

* fix(smtp): add SSRF validation and genericize network error messages

Prevent SSRF via user-controlled smtpHost by validating with
validateDatabaseHost before creating the nodemailer transporter.
Collapse distinct network error messages (ECONNREFUSED, ECONNRESET,
ETIMEDOUT) into a single generic message to prevent port-state leakage.

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

* fix(security): add SSRF validation to SFTP/SSH and access control to workspace invitations

Add `validateDatabaseHost` checks to SFTP and SSH connection utilities to
block connections to private/reserved IPs and localhost, matching the
existing pattern used by all database tools. Add authorization check to
the workspace invitation GET endpoint so only the invitee or a workspace
admin can view invitation details.

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

* fix(smtp): restore SMTP response code handling for post-connection errors

SMTP 4xx/5xx response codes are application-level errors (invalid
recipient, mailbox full, server error) unrelated to the SSRF hardening
goal. Restore response code differentiation and logging to preserve
actionable user-facing error messages.

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

* fix(security): use session email directly instead of extra DB query

Addresses PR review feedback — align with the workspace invitation
route pattern by using session.user.email instead of re-fetching
from the database.

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

* lint

* fix(auth): revert lint autofix that broke hasExternalApiCredentials return type

Biome auto-fixed `return auth !== null && auth.startsWith(...)` to
`return auth?.startsWith(...)` which returns `boolean | undefined`,
not `boolean`, causing a TypeScript build failure.

* fix(smtp): pin resolved IP to prevent DNS rebinding (TOCTOU)

Use the pre-resolved IP from validateDatabaseHost instead of the
original hostname when creating the nodemailer transporter. Set
servername to the original hostname to preserve TLS SNI validation.

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

* refactor(security): extract createPinnedLookup helper for DNS rebinding prevention

Extract reusable createPinnedLookup from secureFetchWithPinnedIP so
non-HTTP transports (SSH, SFTP, IMAP) can pin resolved IPs at the
socket level. SMTP route uses host+servername pinning instead since
nodemailer doesn't reliably pass lookup to both secure/plaintext paths.

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

* fix(security): pin IMAP connections to validated resolved IP

Pass the resolved IP from validateDatabaseHost to ImapFlow as host,
with the original hostname as servername for TLS SNI verification.
Closes the DNS TOCTOU rebinding window.

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

* lint

* fix(auth): revert lint autofix on hasExternalApiCredentials return type

Also pin SFTP/SSH connections to validated resolved IP to prevent DNS rebinding.

* fix(security): short-circuit admin check when caller is invitee

Skip the hasWorkspaceAdminAccess DB query when the caller is already
the invitee, avoiding an unnecessary round-trip. Aligns with the org
invitation route pattern.

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

---------

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

* fix(worker): dockerfile + helm updates (#3818)

* fix(worker): dockerfile + helm updates

* address comments

* update dockerfile (#3819)

* fix dockerfile

* fix(security): pentest remediation — condition escaping, SSRF hardening, ReDoS protection (#3820)

* fix(executor): escape newline characters in condition expression strings

Unescaped newline/carriage-return characters in resolved string values
cause unterminated string literals in generated JS, crashing condition
evaluation with a SyntaxError.

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

* fix(security): prevent ReDoS in guardrails regex validation

Add safe-regex2 to reject catastrophic backtracking patterns before
execution and cap input length at 10k characters.

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

* fix(security): SSRF localhost hardening and regex DoS protection

Block localhost/loopback URLs in hosted environments using isHosted flag
instead of allowHttp. Add safe-regex2 validation and input length limits
to regex guardrails to prevent catastrophic backtracking.

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

* fix(security): validate regex syntax before safety check

Move new RegExp() before safe() so invalid patterns get a proper syntax
error instead of a misleading "catastrophic backtracking" message.

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

* fix(security): address PR review feedback

- Hoist isLocalhost && isHosted guard to single early-return before
  protocol checks, removing redundant duplicate block
- Move regex syntax validation (new RegExp) before safe-regex2 check
  so invalid patterns get proper syntax error instead of misleading
  "catastrophic backtracking" message

* fix(security): remove input length cap from regex validation

The 10k character cap would block legitimate guardrail checks on long
LLM outputs. Input length doesn't affect ReDoS risk — the safe-regex2
pattern check already prevents catastrophic backtracking.

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

* fix(tests): mock isHosted in input-validation and function-execute tests

Tests that assert self-hosted localhost behavior need isHosted=false,
which is not guaranteed in CI where NEXT_PUBLIC_APP_URL is set to the
hosted domain.

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

---------

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

* improvement(worker): configuration defaults (#3821)

* improvement(worker): configuration defaults

* update readmes

* realtime curl import

* improvement(tour): remove auto-start, only trigger on explicit user action (#3823)

* fix(mcp): use correct modal for creating workflow MCP servers in deploy (#3822)

* fix(mcp): use correct modal for creating workflow MCP servers in deploy

* fix(mcp): show workflows field during loading and when empty

* mock course

* fix(db): use bigint for token counter columns in user_stats (#3755)

* mock course

* updates

* updated X handle for emir

* cleanup: audit and clean academy implementation

* fix(academy): add label to ValidationRule, fix quiz gating, simplify getRuleMessage

* cleanup: remove unnecessary comments across academy files

* refactor(academy): simplify abstractions and fix perf issues

* perf(academy): convert course detail page to server component with client island

* fix(academy): null-safe canAdvance, render exercise instructions, remove stale comments

* fix(academy): remove orphaned migration, fix getCourseById, clean up comments

- Delete 0181_academy_certificate.sql (orphaned duplicate not in journal)
- Add getCourseById() to content/index.ts; use it in certificates API
  (was using getCourse which searches by slug, not stable id)
- Remove JSX comments from catalog page
- Remove redundant `passed` recomputation in LessonQuiz

* chore(db): regenerate academy_certificate migration with drizzle-kit

* chore: include blog mdx and components changes

* fix(blog): correct cn import path

* fix(academy): constrain progress bar to max-w-3xl with proper padding

* feat(academy): show back-to-course button on first lesson

* fix(academy): force dark theme on all /academy routes

* content(academy): rewrite sim-foundations course with full 6-module curriculum

* fix(academy): correct edge handles, quiz explanation, and starter mock outputs

- Fix Exercise 2 initial edge handles: 'starter-1-source'/'agent-1-target' → 'source'/'target' (React Flow actual IDs)
- Fix M1-L4 Q4 quiz explanation: remove non-existent Ctrl/Cmd+D and Alt+drag shortcuts
- Add starter mock output to all exercises so run animation shows feedback on the first block

* refine(academy): fix inaccurate content and improve exercise clarity

- Fix Exercise 3: replace hardcoded <agent-1.content> (invalid UUID-based ref) with reference picker instructions
- Fix M4 Quiz Q5: Loop block (subflow container) is correct answer, not the Workflow block
- Fix M4 Quiz Q4: clarify fan-out vs Parallel block distinction in explanation
- Fix M4-L2 video description: accurately describe Loop and Parallel subflow blocks
- Fix M2 Quiz Q3: make response format question conceptual rather than syntax-specific
- Improve Exercise 4 branching instructions: clarify top=true / bottom=false output handles
- Improve Final Project instructions: step-by-step numbered flow

* fix(academy): remove double border on quiz question cards

* fix(academy): single scroll container on lesson pages — remove nested flex scroll

* fix(academy): remove min-h-screen from root layout — fixes double scrollbar on lesson pages

* fix(academy): use fixed inset-0 on lesson page to eliminate document-level scrollbar

* fix(academy): replace sr-only radio/checkbox inputs with buttons to prevent scroll-on-focus; restore layout min-h-screen

* improvement(academy): polish, security hardening, and certificate claim UI

- Replace raw localStorage with BrowserStorage utility in local-progress
- Pre-compute slug/id Maps in content/index for O(1) course lookups
- Move blockMap construction into edge_exists branch only in validation
- Extract navBtnClass constant and MetaRow/formatDate helpers in UI
- Add rate limiting, server-side completion verification, audit logging, and nanoid cert numbers to certificate issuance endpoint
- Add useIssueCertificate mutation hook with completedLessonIds
- Wire certificate claim UI into CourseProgress: sign-in prompt, claim button with loading state, and post-issuance view with link to certificate page
- Fix lesson page scroll container and quiz scroll-on-focus bug

* fix(academy): validate condition branch handles in edge_exists rules

- Add sourceHandle field to edge_exists ValidationRule type
- Check sourceHandle in validation.ts when specified
- Require both condition-if and condition-else branches to be connected in the branching and final project exercises

* fix(academy): address PR review — isHosted regression, stuck isExecuting, revoked cert 500, certificate SSR

- Restore env-var-based isHosted check (was hardcoded true, breaking self-hosted deployments)
- Fix isExecuting stuck at true when mock run fails validation — set isMockRunningRef immediately and reset both flags on early exit
- Fix revoked/expired certificate causing 500 — any existing record (not just active) now returns 409 instead of falling through to INSERT
- Convert certificate verification page from client component to server component — direct DB fetch, notFound() on missing cert, generateMetadata for SEO/social previews

* fix(auth): restore hybrid.ts from staging to fix CI type error

* fix(academy): mark video lessons complete on visit and fix sign-in path

* fix(academy): replace useEffect+setState with lazy useState initializer in CourseProgress

* fix(academy): reset exerciseComplete on lesson navigation, remove unused useAcademyCertificate hook

* fix(academy): useState for slug-change reset, cache() for cert page, handleMockRunRef for stale closure

* fix(academy): replace shadcn theme vars with explicit hex in LessonVideo fallback

* fix(academy): reset completedRef on exercise change, conditional verified badge, multi-select empty guard

* fix(academy): type safety fixes — null metadata fallbacks, returning() guard, exhaustive union, empty catch

* fix(academy): reset ExerciseView completed banner on nav; fix CourseProgress hydration mismatch

* fix(lightbox): guard effect body with isOpen to prevent spurious overflow reset

* fix(academy): reset LessonQuiz state on lesson change to prevent stale answers persisting

* fix(academy): course not-found metadata title; try-finally guard in mock run loop

* fix(academy): type safety, cert persistence, regex guard, mixed-lesson video, shorts support

- Derive AcademyCertificate from db $inferSelect to prevent schema drift
- Add useCourseCertificate query hook; GET /api/academy/certificates now accepts courseId for authenticated lookup
- Use useCourseCertificate in CourseProgress so certificate state survives page refresh
- Guard new RegExp(valuePattern) in validation.ts with try/catch; log warn on invalid pattern
- Add logger.warn for custom validation rules so content authors are alerted
- Add YouTube Shorts URL support to LessonVideo (youtube.com/shorts/VIDEO_ID)
- Fix mixed-lesson video gap: render videoUrl above quiz when mixed has quiz but no exercise
- Add academy-scoped not-found.tsx with link back to /academy

* fix(academy): reset hintIndex when exercise changes

* chore: remove ban-spam-accounts script (wrong branch)

* fix(academy): enforce availableBlocks in toolbar; fix mixed exercise+quiz rendering

- Add useSandboxBlockConstraints context; SandboxCanvasProvider provides exerciseConfig.availableBlocks so the toolbar only shows permitted block types. Empty array hides all blocks (configure-only exercises); non-null array restricts to listed types; triggers always hidden in sandbox.
- Fix mixed lesson with both exerciseConfig and quizConfig: exercise renders first, quiz reveals after exercise completes (sequential pedagogy). canAdvance now requires both exerciseComplete && quizComplete when both are present.

* chore(academy): remove extraneous inline comments

* fix(academy): blank mixed lesson, quiz canAdvance flag, empty-array valueNotEmpty

* prep for merge

* chore(db): regenerate academy certificate migration after staging merge

* fix(academy): disable auto-connect in sandbox mode

* fix(academy): render video in mixed lesson with no exercise or quiz

* fix(academy): mark mixed video-only lessons complete; handle cert insert race

* fix(canvas): add sandbox and embedded to nodes useMemo deps

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Lakee Sivaraya <71339072+lakeesiv@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
Co-authored-by: Theodore Li <teddy@zenobiapay.com>
2026-03-28 12:46:04 -07:00
Waleed
f6b461ad47 fix(readme): restore readme gifs (#3827)
The static GIFs referenced by README.md were removed in #3803.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 12:36:23 -07:00
Waleed
e4d35735b1 fix(knowledge): give users choice to keep or delete documents when removing connector (#3825)
* fix(knowledge): give users choice to keep or delete documents when removing connector

* refactor(knowledge): clean up connector delete and extract shared extension validator

- Extract `isAlphanumericExtension` helper to deduplicate regex across parser-extension.ts and validation.ts
- Extract `closeDeleteModal` callback to eliminate 4x scattered state resets
- Add archivedAt/deletedAt filters to UPDATE query in keep-docs delete path
- Parallelize storage file cleanup and tag definition cleanup with Promise.all
- Deduplicate URL construction in delete connector hook

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

* refactor(knowledge): remove duplicate extension list from parser-extension

Use SUPPORTED_DOCUMENT_EXTENSIONS and isSupportedExtension from
validation.ts instead of maintaining a separate identical list.

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

* fix(db): change document.connectorId FK from cascade to set null

The cascade behavior meant deleting a connector would always delete
its documents, contradicting the "keep documents" option. With set null,
the database automatically nullifies connectorId when a connector is
removed, and we only need explicit deletion when the user opts in.

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

* chore(db): add migration metadata for connectorId FK change

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

* fix(knowledge): fix connector delete test and use URL-safe searchParams

Use `new URL(request.url).searchParams` instead of `request.nextUrl.searchParams`
for compatibility with test mocks. Add missing `connectorType` to test fixture.

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

* spacing

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 12:04:26 -07:00
Waleed
b4064c57fb fix(mcp): use correct modal for creating workflow MCP servers in deploy (#3822)
* fix(mcp): use correct modal for creating workflow MCP servers in deploy

* fix(mcp): show workflows field during loading and when empty
2026-03-27 20:22:30 -07:00
Waleed
eac41ca105 improvement(tour): remove auto-start, only trigger on explicit user action (#3823) 2026-03-27 19:59:47 -07:00
Vikhyath Mondreti
d2c3c1c39e improvement(worker): configuration defaults (#3821)
* improvement(worker): configuration defaults

* update readmes

* realtime curl import
2026-03-27 19:54:56 -07:00
Waleed
8f3e864751 fix(security): pentest remediation — condition escaping, SSRF hardening, ReDoS protection (#3820)
* fix(executor): escape newline characters in condition expression strings

Unescaped newline/carriage-return characters in resolved string values
cause unterminated string literals in generated JS, crashing condition
evaluation with a SyntaxError.

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

* fix(security): prevent ReDoS in guardrails regex validation

Add safe-regex2 to reject catastrophic backtracking patterns before
execution and cap input length at 10k characters.

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

* fix(security): SSRF localhost hardening and regex DoS protection

Block localhost/loopback URLs in hosted environments using isHosted flag
instead of allowHttp. Add safe-regex2 validation and input length limits
to regex guardrails to prevent catastrophic backtracking.

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

* fix(security): validate regex syntax before safety check

Move new RegExp() before safe() so invalid patterns get a proper syntax
error instead of a misleading "catastrophic backtracking" message.

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

* fix(security): address PR review feedback

- Hoist isLocalhost && isHosted guard to single early-return before
  protocol checks, removing redundant duplicate block
- Move regex syntax validation (new RegExp) before safe-regex2 check
  so invalid patterns get proper syntax error instead of misleading
  "catastrophic backtracking" message

* fix(security): remove input length cap from regex validation

The 10k character cap would block legitimate guardrail checks on long
LLM outputs. Input length doesn't affect ReDoS risk — the safe-regex2
pattern check already prevents catastrophic backtracking.

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

* fix(tests): mock isHosted in input-validation and function-execute tests

Tests that assert self-hosted localhost behavior need isHosted=false,
which is not guaranteed in CI where NEXT_PUBLIC_APP_URL is set to the
hosted domain.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 19:54:00 -07:00
Vikhyath Mondreti
23c3072784 fix dockerfile 2026-03-27 19:11:02 -07:00
Vikhyath Mondreti
33fdb11396 update dockerfile (#3819) 2026-03-27 18:50:57 -07:00
Vikhyath Mondreti
21156dd54a fix(worker): dockerfile + helm updates (#3818)
* fix(worker): dockerfile + helm updates

* address comments
2026-03-27 18:28:36 -07:00
Waleed
c05e2e0fc8 fix(security): SSRF, access control, and info disclosure (#3815)
* fix(security): scope copilot feedback GET endpoint to authenticated user

Add WHERE clause to filter feedback records by the authenticated user's
ID, preventing any authenticated user from reading all users' copilot
interactions, queries, and workflow YAML (IDOR / CWE-639).

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

* fix(smtp): add SSRF validation and genericize network error messages

Prevent SSRF via user-controlled smtpHost by validating with
validateDatabaseHost before creating the nodemailer transporter.
Collapse distinct network error messages (ECONNREFUSED, ECONNRESET,
ETIMEDOUT) into a single generic message to prevent port-state leakage.

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

* fix(security): add SSRF validation to SFTP/SSH and access control to workspace invitations

Add `validateDatabaseHost` checks to SFTP and SSH connection utilities to
block connections to private/reserved IPs and localhost, matching the
existing pattern used by all database tools. Add authorization check to
the workspace invitation GET endpoint so only the invitee or a workspace
admin can view invitation details.

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

* fix(smtp): restore SMTP response code handling for post-connection errors

SMTP 4xx/5xx response codes are application-level errors (invalid
recipient, mailbox full, server error) unrelated to the SSRF hardening
goal. Restore response code differentiation and logging to preserve
actionable user-facing error messages.

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

* fix(security): use session email directly instead of extra DB query

Addresses PR review feedback — align with the workspace invitation
route pattern by using session.user.email instead of re-fetching
from the database.

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

* lint

* fix(auth): revert lint autofix that broke hasExternalApiCredentials return type

Biome auto-fixed `return auth !== null && auth.startsWith(...)` to
`return auth?.startsWith(...)` which returns `boolean | undefined`,
not `boolean`, causing a TypeScript build failure.

* fix(smtp): pin resolved IP to prevent DNS rebinding (TOCTOU)

Use the pre-resolved IP from validateDatabaseHost instead of the
original hostname when creating the nodemailer transporter. Set
servername to the original hostname to preserve TLS SNI validation.

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

* refactor(security): extract createPinnedLookup helper for DNS rebinding prevention

Extract reusable createPinnedLookup from secureFetchWithPinnedIP so
non-HTTP transports (SSH, SFTP, IMAP) can pin resolved IPs at the
socket level. SMTP route uses host+servername pinning instead since
nodemailer doesn't reliably pass lookup to both secure/plaintext paths.

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

* fix(security): pin IMAP connections to validated resolved IP

Pass the resolved IP from validateDatabaseHost to ImapFlow as host,
with the original hostname as servername for TLS SNI verification.
Closes the DNS TOCTOU rebinding window.

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

* lint

* fix(auth): revert lint autofix on hasExternalApiCredentials return type

Also pin SFTP/SSH connections to validated resolved IP to prevent DNS rebinding.

* fix(security): short-circuit admin check when caller is invitee

Skip the hasWorkspaceAdminAccess DB query when the caller is already
the invitee, avoiding an unnecessary round-trip. Aligns with the org
invitation route pattern.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 18:10:47 -07:00
Waleed
a7c1e510e6 fix(knowledge): reject non-alphanumeric file extensions from document names (#3816)
* fix(knowledge): reject non-alphanumeric file extensions from document names

* fix(knowledge): improve error message when extension is non-alphanumeric
2026-03-27 16:58:07 -07:00
Vikhyath Mondreti
271624a402 fix(linear): add default null for after cursor (#3814) 2026-03-27 15:57:40 -07:00
Vikhyath Mondreti
dda012eae9 feat(concurrency): bullmq based concurrency control system (#3605)
* feat(concurrency): bullmq based queueing system

* fix bun lock

* remove manual execs off queues

* address comments

* fix legacy team limits

* cleanup enterprise typing code

* inline child triggers

* fix status check

* address more comments

* optimize reconciler scan

* remove dead code

* add to landing page

* Add load testing framework

* update bullmq

* fix

* fix headless path

---------

Co-authored-by: Theodore Li <teddy@zenobiapay.com>
2026-03-27 13:11:35 -07:00
Vikhyath Mondreti
2dd6d3d1e6 fix(import): dedup workflow name (#3813) 2026-03-27 13:09:49 -07:00
Waleed
b90bb75cda fix(knowledge): connector spinner race condition + connectors column (#3812)
* fix(knowledge): scope sync/update state per-connector to prevent race conditions

* feat(knowledge): add connectors column to knowledge base list

* refactor(knowledge): extract set helpers, handleTogglePause, and filter-before-map

* refactor(knowledge): use onSettled for syncingIds cleanup, consistent with updatingIds
2026-03-27 12:54:14 -07:00
Waleed
fb233d003d fix(flyout): align inline rename with non-rename styling (#3811) 2026-03-27 12:39:23 -07:00
Waleed
34df3333d1 fix(knowledge): fix search input flicker on clear and plan display name fallback (#3810) 2026-03-27 12:23:41 -07:00
Waleed
23677d41a0 improvement(sidebar): collapsed sidebar UX, quick-create, hover consistency, and UI polish (#3807)
* improvement(sidebar): collapsed sidebar UX, quick-create, hover consistency, and UI polish

Made-with: Cursor

* fix(sidebar): use stable handlers for root workflow items instead of inline lambdas

Made-with: Cursor

* fix(sidebar): reset actionsOpen state before triggering rename in collapsed dropdown

Made-with: Cursor
2026-03-27 12:08:17 -07:00
Waleed
a489f91085 fix(knowledge): show spinner on connector chip while syncing (#3808)
* fix(knowledge): show spinner on connector chip while syncing

* fix(knowledge): scope sync spinner to mutation lifetime, not cooldown
2026-03-27 12:04:11 -07:00
Adithya Krishna
ed6e7845cc chore: fix rerenders on files (#3805)
* chore: fix rerenders on files

* chore: fix review changes
2026-03-27 11:48:51 -07:00
Adithya Krishna
e698f9fe14 chore: remove font antialiasing (#3806)
* chore: fix antialiasing

* chore: remove antialiasing
2026-03-27 11:29:37 -07:00
Adithya Krishna
db1798267e feat: update sidebar and knowledge (#3804)
* feat: update sidebar and knowledge

* chore: fix rernders on knowledge

* chore: fix review changes

* chore: fix review changes
2026-03-27 09:39:41 -07:00
262 changed files with 42032 additions and 2872 deletions

View File

@@ -74,6 +74,10 @@ docker compose -f docker-compose.prod.yml up -d
Open [http://localhost:3000](http://localhost:3000)
#### Background worker note
The Docker Compose stack starts a dedicated worker container by default. If `REDIS_URL` is not configured, the worker will start, log that it is idle, and do no queue processing. This is expected. Queue-backed API, webhook, and schedule execution requires Redis; installs without Redis continue to use the inline execution path.
Sim also supports local models via [Ollama](https://ollama.ai) and [vLLM](https://docs.vllm.ai/) — see the [Docker self-hosting docs](https://docs.sim.ai/self-hosting/docker) for setup details.
### Self-hosted: Manual Setup
@@ -113,10 +117,12 @@ cd packages/db && bunx drizzle-kit migrate --config=./drizzle.config.ts
5. Start development servers:
```bash
bun run dev:full # Starts both Next.js app and realtime socket server
bun run dev:full # Starts Next.js app, realtime socket server, and the BullMQ worker
```
Or run separately: `bun run dev` (Next.js) and `cd apps/sim && bun run dev:sockets` (realtime).
If `REDIS_URL` is not configured, the worker will remain idle and execution continues inline.
Or run separately: `bun run dev` (Next.js), `cd apps/sim && bun run dev:sockets` (realtime), and `cd apps/sim && bun run worker` (BullMQ worker).
## Copilot API Keys

View File

@@ -18,7 +18,7 @@ export const metadata = {
metadataBase: new URL('https://docs.sim.ai'),
title: {
default: 'Sim Documentation — Build AI Agents & Run Your Agentic Workforce',
template: '%s',
template: '%s | Sim Docs',
},
description:
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.',

View File

@@ -195,6 +195,17 @@ By default, your usage is capped at the credits included in your plan. To allow
Max (individual) shares the same rate limits as team plans. Team plans (Pro or Max for Teams) use the Max-tier rate limits.
### Concurrent Execution Limits
| Plan | Concurrent Executions |
|------|----------------------|
| **Free** | 5 |
| **Pro** | 50 |
| **Max / Team** | 200 |
| **Enterprise** | 200 (customizable) |
Concurrent execution limits control how many workflow executions can run simultaneously within a workspace. When the limit is reached, new executions are queued and admitted as running executions complete. Manual runs from the editor are not subject to these limits.
### File Storage
| Plan | Storage |

View File

@@ -26,6 +26,8 @@ const RESOURCES_LINKS: FooterItem[] = [
{ label: 'Blog', href: '/blog' },
// { label: 'Templates', href: '/templates' },
{ label: 'Docs', href: 'https://docs.sim.ai', external: true },
{ label: 'Academy', href: '/academy' },
{ label: 'Partners', href: '/partners' },
{ label: 'Careers', href: 'https://jobs.ashbyhq.com/sim', external: true },
{ label: 'Changelog', href: '/changelog' },
]

View File

@@ -25,6 +25,7 @@ const PRICING_TIERS: PricingTier[] = [
'5GB file storage',
'3 tables · 1,000 rows each',
'5 min execution limit',
'5 concurrent/workspace',
'7-day log retention',
'CLI/SDK/MCP Access',
],
@@ -42,6 +43,7 @@ const PRICING_TIERS: PricingTier[] = [
'50GB file storage',
'25 tables · 5,000 rows each',
'50 min execution · 150 runs/min',
'50 concurrent/workspace',
'Unlimited log retention',
'CLI/SDK/MCP Access',
],
@@ -59,6 +61,7 @@ const PRICING_TIERS: PricingTier[] = [
'500GB file storage',
'25 tables · 5,000 rows each',
'50 min execution · 300 runs/min',
'200 concurrent/workspace',
'Unlimited log retention',
'CLI/SDK/MCP Access',
],
@@ -75,6 +78,7 @@ const PRICING_TIERS: PricingTier[] = [
'Custom file storage',
'10,000 tables · 1M rows each',
'Custom execution limits',
'Custom concurrency limits',
'Unlimited log retention',
'SSO & SCIM · SOC2 & HIPAA',
'Self hosting · Dedicated support',

View File

@@ -0,0 +1,43 @@
'use client'
import { useState } from 'react'
import NextImage from 'next/image'
import { cn } from '@/lib/core/utils/cn'
import { Lightbox } from '@/app/(landing)/blog/components/lightbox'
interface BlogImageProps {
src: string
alt?: string
width?: number
height?: number
className?: string
}
export function BlogImage({ src, alt = '', width = 800, height = 450, className }: BlogImageProps) {
const [isLightboxOpen, setIsLightboxOpen] = useState(false)
return (
<>
<NextImage
src={src}
alt={alt}
width={width}
height={height}
className={cn(
'h-auto w-full cursor-pointer rounded-lg transition-opacity hover:opacity-95',
className
)}
sizes='(max-width: 768px) 100vw, 800px'
loading='lazy'
unoptimized
onClick={() => setIsLightboxOpen(true)}
/>
<Lightbox
isOpen={isLightboxOpen}
onClose={() => setIsLightboxOpen(false)}
src={src}
alt={alt}
/>
</>
)
}

View File

@@ -0,0 +1,62 @@
'use client'
import { useEffect, useRef } from 'react'
interface LightboxProps {
isOpen: boolean
onClose: () => void
src: string
alt: string
}
export function Lightbox({ isOpen, onClose, src, alt }: LightboxProps) {
const overlayRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!isOpen) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose()
}
}
const handleClickOutside = (event: MouseEvent) => {
if (overlayRef.current && event.target === overlayRef.current) {
onClose()
}
}
document.addEventListener('keydown', handleKeyDown)
document.addEventListener('click', handleClickOutside)
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('click', handleClickOutside)
document.body.style.overflow = 'unset'
}
}, [isOpen, onClose])
if (!isOpen) return null
return (
<div
ref={overlayRef}
className='fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-12 backdrop-blur-sm'
role='dialog'
aria-modal='true'
aria-label='Image viewer'
>
<div className='relative max-h-full max-w-full overflow-hidden rounded-xl shadow-2xl'>
<img
src={src}
alt={alt}
className='max-h-[75vh] max-w-[75vw] cursor-pointer rounded-xl object-contain'
loading='lazy'
onClick={onClose}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,291 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { getNavBlogPosts } from '@/lib/blog/registry'
import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono'
import { season } from '@/app/_styles/fonts/season/season'
import Footer from '@/app/(home)/components/footer/footer'
import Navbar from '@/app/(home)/components/navbar/navbar'
export const metadata: Metadata = {
title: 'Partner Program',
description:
'Join the Sim partner program. Build, deploy, and sell AI workflow solutions. Earn your certification through Sim Academy.',
metadataBase: new URL('https://sim.ai'),
openGraph: {
title: 'Partner Program | Sim',
description: 'Join the Sim partner program.',
type: 'website',
},
}
const PARTNER_TIERS = [
{
name: 'Certified Partner',
badge: 'Entry',
color: '#3A3A3A',
requirements: ['Complete Sim Academy certification', 'Deploy at least 1 live workflow'],
perks: [
'Official partner badge',
'Listed in partner directory',
'Early access to new features',
],
},
{
name: 'Silver Partner',
badge: 'Growth',
color: '#5A5A5A',
requirements: [
'All Certified requirements',
'3+ active client deployments',
'Sim Academy advanced certification',
],
perks: [
'All Certified perks',
'Dedicated partner Slack channel',
'Co-marketing opportunities',
'Priority support',
],
},
{
name: 'Gold Partner',
badge: 'Premier',
color: '#8B7355',
requirements: [
'All Silver requirements',
'10+ active client deployments',
'Sim solutions architect certification',
],
perks: [
'All Silver perks',
'Revenue share program',
'Joint case studies',
'Dedicated partner success manager',
'Influence product roadmap',
],
},
]
const HOW_IT_WORKS = [
{
step: '01',
title: 'Sign up & complete Sim Academy',
description:
'Create an account and work through the Sim Academy certification program. Learn to build, integrate, and deploy AI workflows through hands-on canvas exercises.',
},
{
step: '02',
title: 'Build & deploy real solutions',
description:
'Put your skills to work. Build workflow automations for clients, integrate Sim into existing products, or create your own Sim-powered applications.',
},
{
step: '03',
title: 'Get certified & grow',
description:
'Earn your partner certification and unlock perks, co-marketing opportunities, and revenue share as you scale your practice.',
},
]
const BENEFITS = [
{
icon: '🎓',
title: 'Interactive Learning',
description:
'Learn on the real Sim canvas with drag-and-drop exercises, instant feedback, and guided exercises — not just videos.',
},
{
icon: '🤝',
title: 'Co-Marketing',
description:
'Get listed in the Sim partner directory, featured in case studies, and promoted to the Sim user base.',
},
{
icon: '💰',
title: 'Revenue Share',
description: 'Gold partners earn revenue share on referred customers and managed deployments.',
},
{
icon: '🚀',
title: 'Early Access',
description:
'Partners get early access to new Sim features, APIs, and integrations before they launch publicly.',
},
{
icon: '🛠️',
title: 'Technical Support',
description:
'Priority technical support, private Slack access, and a dedicated partner success manager for Gold partners.',
},
{
icon: '📣',
title: 'Community',
description:
'Join a growing community of Sim builders. Share workflows, collaborate on solutions, and shape the product roadmap.',
},
]
export default async function PartnersPage() {
const blogPosts = await getNavBlogPosts()
return (
<div
className={`${season.variable} ${martianMono.variable} min-h-screen bg-[#1C1C1C] font-[430] font-season text-[#ECECEC]`}
>
<header>
<Navbar logoOnly={false} blogPosts={blogPosts} />
</header>
<main>
{/* Hero */}
<section className='border-[#2A2A2A] border-b px-[80px] py-[100px]'>
<div className='mx-auto max-w-4xl'>
<div className='mb-4 text-[#666] text-[13px] uppercase tracking-[0.12em]'>
Partner Program
</div>
<h1 className='mb-5 text-[64px] text-white leading-[105%] tracking-[-0.03em]'>
Build the future
<br />
of AI automation
</h1>
<p className='mb-10 max-w-xl text-[#F6F6F0]/60 text-[18px] leading-[160%] tracking-[0.01em]'>
Become a certified Sim partner. Complete Sim Academy, deploy real solutions, and earn
recognition in the growing ecosystem of AI workflow builders.
</p>
<div className='flex items-center gap-4'>
<Link
href='/academy'
className='inline-flex h-[44px] items-center rounded-[5px] bg-white px-6 text-[#1C1C1C] text-[15px] transition-colors hover:bg-[#E8E8E8]'
>
Start Sim Academy
</Link>
<a
href='#how-it-works'
className='inline-flex h-[44px] items-center rounded-[5px] border border-[#3A3A3A] px-6 text-[#ECECEC] text-[15px] transition-colors hover:border-[#4A4A4A]'
>
Learn more
</a>
</div>
</div>
</section>
{/* Benefits grid */}
<section className='border-[#2A2A2A] border-b px-[80px] py-20'>
<div className='mx-auto max-w-5xl'>
<div className='mb-12 text-[#666] text-[13px] uppercase tracking-[0.12em]'>
Why partner with Sim
</div>
<div className='grid gap-6 sm:grid-cols-2 lg:grid-cols-3'>
{BENEFITS.map((b) => (
<div key={b.title} className='rounded-[8px] border border-[#2A2A2A] bg-[#222] p-6'>
<div className='mb-3 text-[24px]'>{b.icon}</div>
<h3 className='mb-2 text-[#ECECEC] text-[15px]'>{b.title}</h3>
<p className='text-[#999] text-[14px] leading-[160%]'>{b.description}</p>
</div>
))}
</div>
</div>
</section>
{/* How it works */}
<section id='how-it-works' className='border-[#2A2A2A] border-b px-[80px] py-20'>
<div className='mx-auto max-w-4xl'>
<div className='mb-12 text-[#666] text-[13px] uppercase tracking-[0.12em]'>
How it works
</div>
<div className='space-y-10'>
{HOW_IT_WORKS.map((step) => (
<div key={step.step} className='flex gap-8'>
<div className='flex-shrink-0 font-[430] text-[#2A2A2A] text-[48px] leading-none'>
{step.step}
</div>
<div className='pt-2'>
<h3 className='mb-2 text-[#ECECEC] text-[18px]'>{step.title}</h3>
<p className='text-[#999] text-[15px] leading-[160%]'>{step.description}</p>
</div>
</div>
))}
</div>
</div>
</section>
{/* Partner tiers */}
<section className='border-[#2A2A2A] border-b px-[80px] py-20'>
<div className='mx-auto max-w-5xl'>
<div className='mb-12 text-[#666] text-[13px] uppercase tracking-[0.12em]'>
Partner tiers
</div>
<div className='grid gap-5 lg:grid-cols-3'>
{PARTNER_TIERS.map((tier) => (
<div
key={tier.name}
className='flex flex-col rounded-[8px] border border-[#2A2A2A] bg-[#222] p-6'
>
<div className='mb-4 flex items-center justify-between'>
<h3 className='text-[#ECECEC] text-[16px]'>{tier.name}</h3>
<span
className='rounded-full px-2.5 py-0.5 text-[11px]'
style={{
backgroundColor: `${tier.color}33`,
color: tier.color === '#8B7355' ? '#C8A96E' : '#999',
border: `1px solid ${tier.color}`,
}}
>
{tier.badge}
</span>
</div>
<div className='mb-4'>
<p className='mb-2 text-[#555] text-[12px] uppercase tracking-[0.1em]'>
Requirements
</p>
<ul className='space-y-1.5'>
{tier.requirements.map((r) => (
<li key={r} className='flex items-start gap-2 text-[#999] text-[13px]'>
<span className='mt-1.5 h-1 w-1 flex-shrink-0 rounded-full bg-[#555]' />
{r}
</li>
))}
</ul>
</div>
<div className='mt-auto'>
<p className='mb-2 text-[#555] text-[12px] uppercase tracking-[0.1em]'>Perks</p>
<ul className='space-y-1.5'>
{tier.perks.map((p) => (
<li key={p} className='flex items-start gap-2 text-[#ECECEC] text-[13px]'>
<span className='mt-1.5 h-1 w-1 flex-shrink-0 rounded-full bg-[#4CAF50]' />
{p}
</li>
))}
</ul>
</div>
</div>
))}
</div>
</div>
</section>
{/* CTA */}
<section className='px-[80px] py-[100px]'>
<div className='mx-auto max-w-3xl text-center'>
<h2 className='mb-4 text-[48px] text-white leading-[110%] tracking-[-0.02em]'>
Ready to get started?
</h2>
<p className='mb-10 text-[#F6F6F0]/60 text-[18px] leading-[160%]'>
Complete Sim Academy to earn your first certification and unlock partner benefits.
It's free to start — no credit card required.
</p>
<Link
href='/academy'
className='inline-flex h-[48px] items-center rounded-[5px] bg-white px-8 font-[430] text-[#1C1C1C] text-[15px] transition-colors hover:bg-[#E8E8E8]'
>
Start Sim Academy
</Link>
</div>
</section>
</main>
<Footer />
</div>
)
}

View File

@@ -25,6 +25,10 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
pathname.startsWith('/form') ||
pathname.startsWith('/oauth')
const isDarkModePage = pathname.startsWith('/academy')
const forcedTheme = isLightModePage ? 'light' : isDarkModePage ? 'dark' : undefined
return (
<NextThemesProvider
attribute='class'
@@ -32,7 +36,7 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
enableSystem
disableTransitionOnChange
storageKey='sim-theme'
forcedTheme={isLightModePage ? 'light' : undefined}
forcedTheme={forcedTheme}
{...props}
>
{children}

View File

@@ -188,7 +188,8 @@ html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn {
--border-1: #e0e0e0; /* stronger border */
--surface-6: #e5e5e5; /* popovers, elevated surfaces */
--surface-7: #d9d9d9;
--surface-active: #ececec; /* hover/active state */
--surface-hover: #f2f2f2; /* hover state */
--surface-active: #ececec; /* active/selected state */
--workflow-edge: #e0e0e0; /* workflow handles/edges - matches border-1 */
@@ -342,7 +343,8 @@ html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn {
--border-1: #3d3d3d;
--surface-6: #454545;
--surface-7: #505050;
--surface-active: #2c2c2c; /* hover/active state */
--surface-hover: #262626; /* hover state */
--surface-active: #2c2c2c; /* active/selected state */
--workflow-edge: #454545; /* workflow handles/edges - same as surface-6 in dark */
@@ -501,9 +503,6 @@ html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn {
caret-color: var(--text-primary);
}
body {
@apply antialiased;
}
::-webkit-scrollbar {
width: var(--scrollbar-size);
height: var(--scrollbar-size);

View File

@@ -0,0 +1,156 @@
'use client'
import { useEffect, useState } from 'react'
import { CheckCircle2, Circle, ExternalLink, GraduationCap, Loader2 } from 'lucide-react'
import Link from 'next/link'
import { getCompletedLessons } from '@/lib/academy/local-progress'
import type { Course } from '@/lib/academy/types'
import { useSession } from '@/lib/auth/auth-client'
import { useCourseCertificate, useIssueCertificate } from '@/hooks/queries/academy'
interface CourseProgressProps {
course: Course
courseSlug: string
}
export function CourseProgress({ course, courseSlug }: CourseProgressProps) {
// Start with an empty set so SSR and initial client render match, then hydrate from localStorage.
const [completedIds, setCompletedIds] = useState<Set<string>>(() => new Set())
useEffect(() => {
setCompletedIds(getCompletedLessons())
}, [])
const { data: session } = useSession()
const { data: fetchedCert } = useCourseCertificate(session ? course.id : undefined)
const { mutate: issueCertificate, isPending, data: issuedCert, error } = useIssueCertificate()
const certificate = fetchedCert ?? issuedCert
const allLessons = course.modules.flatMap((m) => m.lessons)
const totalLessons = allLessons.length
const completedCount = allLessons.filter((l) => completedIds.has(l.id)).length
const percentComplete = totalLessons > 0 ? Math.round((completedCount / totalLessons) * 100) : 0
return (
<>
{completedCount > 0 && (
<div className='px-4 pt-8 sm:px-8 md:px-[80px]'>
<div className='mx-auto max-w-3xl rounded-[8px] border border-[#2A2A2A] bg-[#222] p-4'>
<div className='mb-2 flex items-center justify-between text-[13px]'>
<span className='text-[#999]'>Your progress</span>
<span className='text-[#ECECEC]'>
{completedCount}/{totalLessons} lessons
</span>
</div>
<div className='h-1.5 w-full overflow-hidden rounded-full bg-[#2A2A2A]'>
<div
className='h-full rounded-full bg-[#ECECEC] transition-all'
style={{ width: `${percentComplete}%` }}
/>
</div>
</div>
</div>
)}
<section className='px-4 py-14 sm:px-8 md:px-[80px]'>
<div className='mx-auto max-w-3xl space-y-10'>
{course.modules.map((mod, modIndex) => (
<div key={mod.id}>
<div className='mb-4 flex items-center gap-3'>
<span className='text-[#555] text-[12px]'>Module {modIndex + 1}</span>
<div className='h-px flex-1 bg-[#2A2A2A]' />
</div>
<h2 className='mb-4 font-[430] text-[#ECECEC] text-[18px]'>{mod.title}</h2>
<div className='space-y-2'>
{mod.lessons.map((lesson) => (
<Link
key={lesson.id}
href={`/academy/${courseSlug}/${lesson.slug}`}
className='flex items-center gap-3 rounded-[8px] border border-[#2A2A2A] bg-[#222] px-4 py-3 text-[14px] transition-colors hover:border-[#3A3A3A] hover:bg-[#272727]'
>
{completedIds.has(lesson.id) ? (
<CheckCircle2 className='h-4 w-4 flex-shrink-0 text-[#4CAF50]' />
) : (
<Circle className='h-4 w-4 flex-shrink-0 text-[#444]' />
)}
<span className='flex-1 text-[#ECECEC]'>{lesson.title}</span>
<span className='text-[#555] text-[12px] capitalize'>{lesson.lessonType}</span>
{lesson.videoDurationSeconds && (
<span className='text-[#555] text-[12px]'>
{Math.round(lesson.videoDurationSeconds / 60)} min
</span>
)}
</Link>
))}
</div>
</div>
))}
</div>
</section>
{totalLessons > 0 && completedCount === totalLessons && (
<section className='px-4 pb-16 sm:px-8 md:px-[80px]'>
<div className='mx-auto max-w-3xl rounded-[8px] border border-[#3A4A3A] bg-[#1F2A1F] p-6'>
{certificate ? (
<div className='flex items-center justify-between'>
<div className='flex items-center gap-3'>
<GraduationCap className='h-6 w-6 text-[#4CAF50]' />
<div>
<p className='font-[430] text-[#ECECEC] text-[15px]'>Certificate issued!</p>
<p className='font-mono text-[#666] text-[13px]'>
{certificate.certificateNumber}
</p>
</div>
</div>
<Link
href={`/academy/certificate/${certificate.certificateNumber}`}
className='flex items-center gap-1.5 rounded-[5px] bg-[#4CAF50] px-4 py-2 font-[430] text-[#1C1C1C] text-[13px] transition-colors hover:bg-[#5DBF61]'
>
View certificate
<ExternalLink className='h-3.5 w-3.5' />
</Link>
</div>
) : (
<div className='flex items-center justify-between'>
<div className='flex items-center gap-3'>
<GraduationCap className='h-6 w-6 text-[#4CAF50]' />
<div>
<p className='font-[430] text-[#ECECEC] text-[15px]'>Course Complete!</p>
<p className='text-[#666] text-[13px]'>
{session
? error
? 'Something went wrong. Try again.'
: 'Claim your certificate of completion.'
: 'Sign in to claim your certificate.'}
</p>
</div>
</div>
{session ? (
<button
type='button'
disabled={isPending}
onClick={() =>
issueCertificate({
courseId: course.id,
completedLessonIds: [...completedIds],
})
}
className='flex items-center gap-2 rounded-[5px] bg-[#ECECEC] px-4 py-2 font-[430] text-[#1C1C1C] text-[13px] transition-colors hover:bg-white disabled:opacity-50'
>
{isPending && <Loader2 className='h-3.5 w-3.5 animate-spin' />}
{isPending ? 'Issuing…' : 'Get certificate'}
</button>
) : (
<Link
href='/login'
className='rounded-[5px] bg-[#ECECEC] px-4 py-2 font-[430] text-[#1C1C1C] text-[13px] transition-colors hover:bg-white'
>
Sign in
</Link>
)}
</div>
)}
</div>
</section>
)}
</>
)
}

View File

@@ -0,0 +1,68 @@
import { Clock, GraduationCap } from 'lucide-react'
import type { Metadata } from 'next'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { COURSES, getCourse } from '@/lib/academy/content'
import { CourseProgress } from './components/course-progress'
interface CourseDetailPageProps {
params: Promise<{ courseSlug: string }>
}
export function generateStaticParams() {
return COURSES.map((course) => ({ courseSlug: course.slug }))
}
export async function generateMetadata({ params }: CourseDetailPageProps): Promise<Metadata> {
const { courseSlug } = await params
const course = getCourse(courseSlug)
if (!course) return { title: 'Course Not Found' }
return {
title: course.title,
description: course.description,
}
}
export default async function CourseDetailPage({ params }: CourseDetailPageProps) {
const { courseSlug } = await params
const course = getCourse(courseSlug)
if (!course) notFound()
return (
<main>
<section className='border-[#2A2A2A] border-b px-4 py-16 sm:px-8 md:px-[80px]'>
<div className='mx-auto max-w-3xl'>
<Link
href='/academy'
className='mb-4 inline-flex items-center gap-1.5 text-[#666] text-[13px] transition-colors hover:text-[#999]'
>
All courses
</Link>
<h1 className='mb-3 font-[430] text-[#ECECEC] text-[36px] leading-[115%] tracking-[-0.02em]'>
{course.title}
</h1>
{course.description && (
<p className='mb-6 text-[#F6F6F0]/60 text-[16px] leading-[160%]'>
{course.description}
</p>
)}
<div className='mt-6 flex items-center gap-5 text-[#666] text-[13px]'>
{course.estimatedMinutes && (
<span className='flex items-center gap-1.5'>
<Clock className='h-3.5 w-3.5' />
{course.estimatedMinutes} min total
</span>
)}
<span className='flex items-center gap-1.5'>
<GraduationCap className='h-3.5 w-3.5' />
Certificate upon completion
</span>
</div>
</div>
</section>
<CourseProgress course={course} courseSlug={courseSlug} />
</main>
)
}

View File

@@ -0,0 +1,127 @@
import { cache } from 'react'
import { db } from '@sim/db'
import { academyCertificate } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { CheckCircle2, GraduationCap, XCircle } from 'lucide-react'
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import type { AcademyCertificate } from '@/lib/academy/types'
interface CertificatePageProps {
params: Promise<{ certificateNumber: string }>
}
export async function generateMetadata({ params }: CertificatePageProps): Promise<Metadata> {
const { certificateNumber } = await params
const certificate = await fetchCertificate(certificateNumber)
if (!certificate) return { title: 'Certificate Not Found' }
return {
title: `${certificate.metadata?.courseTitle ?? 'Certificate'} — Certificate`,
description: `Verified certificate of completion awarded to ${certificate.metadata?.recipientName ?? 'a recipient'}.`,
}
}
const fetchCertificate = cache(
async (certificateNumber: string): Promise<AcademyCertificate | null> => {
const [row] = await db
.select()
.from(academyCertificate)
.where(eq(academyCertificate.certificateNumber, certificateNumber))
.limit(1)
return (row as unknown as AcademyCertificate) ?? null
}
)
const DATE_FORMAT: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long', day: 'numeric' }
function formatDate(date: string | Date) {
return new Date(date).toLocaleDateString('en-US', DATE_FORMAT)
}
function MetaRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className='flex items-center justify-between px-5 py-3.5'>
<span className='text-[#666] text-[13px]'>{label}</span>
{children}
</div>
)
}
export default async function CertificatePage({ params }: CertificatePageProps) {
const { certificateNumber } = await params
const certificate = await fetchCertificate(certificateNumber)
if (!certificate) notFound()
return (
<main className='flex flex-1 items-center justify-center px-6 py-20'>
<div className='w-full max-w-2xl'>
<div className='rounded-[12px] border border-[#3A4A3A] bg-[#1C2A1C] p-10 text-center'>
<div className='mb-6 flex justify-center'>
<div className='flex h-16 w-16 items-center justify-center rounded-full border-2 border-[#4CAF50]/40 bg-[#4CAF50]/10'>
<GraduationCap className='h-8 w-8 text-[#4CAF50]' />
</div>
</div>
<div className='mb-2 text-[#4CAF50]/70 text-[13px] uppercase tracking-[0.12em]'>
Certificate of Completion
</div>
<h1 className='mb-1 font-[430] text-[#ECECEC] text-[28px] leading-[120%]'>
{certificate.metadata?.courseTitle}
</h1>
{certificate.metadata?.recipientName && (
<p className='mb-6 text-[#999] text-[16px]'>
Awarded to{' '}
<span className='text-[#ECECEC]'>{certificate.metadata.recipientName}</span>
</p>
)}
{certificate.status === 'active' ? (
<div className='flex items-center justify-center gap-2 text-[#4CAF50]'>
<CheckCircle2 className='h-4 w-4' />
<span className='font-[430] text-[14px]'>Verified</span>
</div>
) : (
<div className='flex items-center justify-center gap-2 text-[#f44336]'>
<XCircle className='h-4 w-4' />
<span className='font-[430] text-[14px] capitalize'>{certificate.status}</span>
</div>
)}
</div>
<div className='mt-6 divide-y divide-[#2A2A2A] rounded-[8px] border border-[#2A2A2A] bg-[#222]'>
<MetaRow label='Certificate number'>
<span className='font-mono text-[#ECECEC] text-[13px]'>
{certificate.certificateNumber}
</span>
</MetaRow>
<MetaRow label='Issued'>
<span className='text-[#ECECEC] text-[13px]'>{formatDate(certificate.issuedAt)}</span>
</MetaRow>
<MetaRow label='Status'>
<span
className={`text-[13px] capitalize ${
certificate.status === 'active' ? 'text-[#4CAF50]' : 'text-[#f44336]'
}`}
>
{certificate.status}
</span>
</MetaRow>
{certificate.expiresAt && (
<MetaRow label='Expires'>
<span className='text-[#ECECEC] text-[13px]'>
{formatDate(certificate.expiresAt)}
</span>
</MetaRow>
)}
</div>
<p className='mt-5 text-center text-[#555] text-[13px]'>
This certificate was issued by Sim AI, Inc. and verifies the holder has completed the{' '}
{certificate.metadata?.courseTitle} program.
</p>
</div>
</main>
)
}

View File

@@ -0,0 +1,16 @@
import type React from 'react'
import { getNavBlogPosts } from '@/lib/blog/registry'
import Footer from '@/app/(home)/components/footer/footer'
import Navbar from '@/app/(home)/components/navbar/navbar'
export default async function AcademyCatalogLayout({ children }: { children: React.ReactNode }) {
const blogPosts = await getNavBlogPosts()
return (
<>
<Navbar blogPosts={blogPosts} />
{children}
<Footer hideCTA />
</>
)
}

View File

@@ -0,0 +1,19 @@
import Link from 'next/link'
export default function AcademyNotFound() {
return (
<main className='flex flex-1 flex-col items-center justify-center px-6 py-32 text-center'>
<p className='mb-2 font-mono text-[#555] text-[13px] uppercase tracking-widest'>404</p>
<h1 className='mb-3 font-[430] text-[#ECECEC] text-[28px] leading-[120%]'>Page not found</h1>
<p className='mb-8 text-[#666] text-[15px]'>
That course or lesson doesn't exist in the Academy.
</p>
<Link
href='/academy'
className='rounded-[5px] bg-[#ECECEC] px-5 py-2.5 font-[430] text-[#1C1C1C] text-[14px] transition-colors hover:bg-white'
>
Back to Academy
</Link>
</main>
)
}

View File

@@ -0,0 +1,76 @@
import { BookOpen, Clock } from 'lucide-react'
import Link from 'next/link'
import { COURSES } from '@/lib/academy/content'
export default function AcademyCatalogPage() {
return (
<main>
<section className='border-[#2A2A2A] border-b px-4 py-20 sm:px-8 md:px-[80px]'>
<div className='mx-auto max-w-3xl'>
<div className='mb-3 text-[#999] text-[13px] uppercase tracking-[0.12em]'>
Sim Academy
</div>
<h1 className='mb-4 font-[430] text-[#ECECEC] text-[48px] leading-[110%] tracking-[-0.02em]'>
Become a certified
<br />
Sim partner
</h1>
<p className='text-[#F6F6F0]/60 text-[18px] leading-[160%] tracking-[0.01em]'>
Master AI workflow automation with hands-on interactive exercises on the real Sim
canvas. Complete the program to earn your partner certification.
</p>
</div>
</section>
<section className='px-4 py-16 sm:px-8 md:px-[80px]'>
<div className='mx-auto max-w-6xl'>
<h2 className='mb-8 text-[#999] text-[13px] uppercase tracking-[0.12em]'>Courses</h2>
<div className='grid gap-5 sm:grid-cols-2 lg:grid-cols-3'>
{COURSES.map((course) => {
const totalLessons = course.modules.reduce((n, m) => n + m.lessons.length, 0)
return (
<Link
key={course.id}
href={`/academy/${course.slug}`}
className='group flex flex-col rounded-[8px] border border-[#2A2A2A] bg-[#232323] p-5 transition-colors hover:border-[#3A3A3A] hover:bg-[#282828]'
>
{course.imageUrl && (
<div className='mb-4 aspect-video w-full overflow-hidden rounded-[6px] bg-[#1A1A1A]'>
<img
src={course.imageUrl}
alt={course.title}
className='h-full w-full object-cover opacity-80'
/>
</div>
)}
<div className='flex-1'>
<h3 className='mb-2 font-[430] text-[#ECECEC] text-[16px] leading-[130%] group-hover:text-white'>
{course.title}
</h3>
{course.description && (
<p className='mb-4 line-clamp-2 text-[#999] text-[14px] leading-[150%]'>
{course.description}
</p>
)}
</div>
<div className='mt-auto flex items-center gap-4 text-[#666] text-[12px]'>
{course.estimatedMinutes && (
<span className='flex items-center gap-1.5'>
<Clock className='h-3 w-3' />
{course.estimatedMinutes} min
</span>
)}
<span className='flex items-center gap-1.5'>
<BookOpen className='h-3 w-3' />
{totalLessons} lessons
</span>
</div>
</Link>
)
})}
</div>
</div>
</section>
</main>
)
}

View File

@@ -0,0 +1,66 @@
'use client'
import { useCallback, useState } from 'react'
import { CheckCircle2 } from 'lucide-react'
import { markLessonComplete } from '@/lib/academy/local-progress'
import type { ExerciseBlockState, ExerciseDefinition, ExerciseEdgeState } from '@/lib/academy/types'
import { SandboxCanvasProvider } from '@/app/academy/components/sandbox-canvas-provider'
interface ExerciseViewProps {
lessonId: string
exerciseConfig: ExerciseDefinition
onComplete?: () => void
videoUrl?: string
description?: string
}
/**
* Orchestrates the sandbox canvas for an exercise lesson.
* Completion is determined client-side by the validation engine and persisted to localStorage.
*/
export function ExerciseView({
lessonId,
exerciseConfig,
onComplete,
videoUrl,
description,
}: ExerciseViewProps) {
const [completed, setCompleted] = useState(false)
// Reset completion banner when the lesson changes (component is reused across exercise navigations).
const [prevLessonId, setPrevLessonId] = useState(lessonId)
if (prevLessonId !== lessonId) {
setPrevLessonId(lessonId)
setCompleted(false)
}
const handleComplete = useCallback(
(_blocks: ExerciseBlockState[], _edges: ExerciseEdgeState[]) => {
setCompleted(true)
markLessonComplete(lessonId)
onComplete?.()
},
[lessonId, onComplete]
)
return (
<div className='relative flex h-full w-full flex-col overflow-hidden'>
<SandboxCanvasProvider
exerciseId={lessonId}
exerciseConfig={exerciseConfig}
onComplete={handleComplete}
videoUrl={videoUrl}
description={description}
className='flex-1'
/>
{completed && (
<div className='pointer-events-none absolute inset-0 flex items-start justify-center pt-5'>
<div className='pointer-events-auto flex items-center gap-2 rounded-full border border-[#3A4A3A] bg-[#1F2A1F]/95 px-4 py-2 font-[430] text-[#4CAF50] text-[13px] shadow-lg backdrop-blur-sm'>
<CheckCircle2 className='h-4 w-4' />
Exercise complete!
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,256 @@
'use client'
import { useState } from 'react'
import { CheckCircle2, XCircle } from 'lucide-react'
import { markLessonComplete } from '@/lib/academy/local-progress'
import type { QuizDefinition, QuizQuestion } from '@/lib/academy/types'
import { cn } from '@/lib/core/utils/cn'
interface LessonQuizProps {
lessonId: string
quizConfig: QuizDefinition
onPass?: () => void
}
type Answers = Record<number, number | number[] | boolean>
interface QuizResult {
score: number
passed: boolean
feedback: Array<{ correct: boolean; explanation?: string }>
}
function scoreQuiz(questions: QuizQuestion[], answers: Answers, passingScore: number): QuizResult {
const feedback = questions.map((q, i) => {
const answer = answers[i]
let correct = false
if (q.type === 'multiple_choice') correct = answer === q.correctIndex
else if (q.type === 'true_false') correct = answer === q.correctAnswer
else if (q.type === 'multi_select') {
const selected = (answer as number[] | undefined) ?? []
correct =
selected.length === q.correctIndices.length &&
selected.every((v) => q.correctIndices.includes(v))
} else {
const _exhaustive: never = q
void _exhaustive
}
return { correct, explanation: 'explanation' in q ? q.explanation : undefined }
})
const score = Math.round((feedback.filter((f) => f.correct).length / questions.length) * 100)
return { score, passed: score >= passingScore, feedback }
}
const optionBase =
'w-full text-left rounded-[6px] border px-4 py-3 text-[14px] transition-colors disabled:cursor-default'
/**
* Interactive quiz component with per-question feedback and retry support.
* Scoring is performed entirely client-side.
*/
export function LessonQuiz({ lessonId, quizConfig, onPass }: LessonQuizProps) {
const [answers, setAnswers] = useState<Answers>({})
const [result, setResult] = useState<QuizResult | null>(null)
// Reset quiz state when the lesson changes (component is reused across quiz-lesson navigations).
const [prevLessonId, setPrevLessonId] = useState(lessonId)
if (prevLessonId !== lessonId) {
setPrevLessonId(lessonId)
setAnswers({})
setResult(null)
}
const handleAnswer = (qi: number, value: number | boolean) => {
if (!result) setAnswers((prev) => ({ ...prev, [qi]: value }))
}
const handleMultiSelect = (qi: number, oi: number) => {
if (result) return
setAnswers((prev) => {
const current = (prev[qi] as number[] | undefined) ?? []
const next = current.includes(oi) ? current.filter((i) => i !== oi) : [...current, oi]
return { ...prev, [qi]: next }
})
}
const allAnswered = quizConfig.questions.every((q, i) => {
if (q.type === 'multi_select')
return Array.isArray(answers[i]) && (answers[i] as number[]).length > 0
return answers[i] !== undefined
})
const handleSubmit = () => {
const scored = scoreQuiz(quizConfig.questions, answers, quizConfig.passingScore)
setResult(scored)
if (scored.passed) {
markLessonComplete(lessonId)
onPass?.()
}
}
return (
<div className='space-y-6'>
<div>
<h2 className='font-[430] text-[#ECECEC] text-[20px]'>Quiz</h2>
<p className='mt-1 text-[#666] text-[14px]'>
Score {quizConfig.passingScore}% or higher to pass.
</p>
</div>
{quizConfig.questions.map((q, qi) => {
const feedback = result?.feedback[qi]
const isCorrect = feedback?.correct
return (
<div key={qi} className='rounded-[8px] bg-[#222] p-5'>
<p className='mb-4 font-[430] text-[#ECECEC] text-[15px]'>{q.question}</p>
{q.type === 'multiple_choice' && (
<div className='space-y-2'>
{q.options.map((opt, oi) => (
<button
key={oi}
type='button'
onClick={() => handleAnswer(qi, oi)}
disabled={Boolean(result)}
className={cn(
optionBase,
answers[qi] === oi
? 'border-[#ECECEC]/40 bg-[#ECECEC]/5 text-[#ECECEC]'
: 'border-[#2A2A2A] text-[#999] hover:border-[#3A3A3A] hover:bg-[#272727]',
result &&
oi === q.correctIndex &&
'border-[#4CAF50]/50 bg-[#4CAF50]/5 text-[#4CAF50]',
result &&
answers[qi] === oi &&
oi !== q.correctIndex &&
'border-[#f44336]/40 bg-[#f44336]/5 text-[#f44336]'
)}
>
{opt}
</button>
))}
</div>
)}
{q.type === 'true_false' && (
<div className='flex gap-3'>
{(['True', 'False'] as const).map((label) => {
const val = label === 'True'
return (
<button
key={label}
type='button'
onClick={() => handleAnswer(qi, val)}
disabled={Boolean(result)}
className={cn(
'flex-1 rounded-[6px] border px-4 py-3 text-[14px] transition-colors disabled:cursor-default',
answers[qi] === val
? 'border-[#ECECEC]/40 bg-[#ECECEC]/5 text-[#ECECEC]'
: 'border-[#2A2A2A] text-[#999] hover:border-[#3A3A3A] hover:bg-[#272727]',
result &&
val === q.correctAnswer &&
'border-[#4CAF50]/50 bg-[#4CAF50]/5 text-[#4CAF50]',
result &&
answers[qi] === val &&
val !== q.correctAnswer &&
'border-[#f44336]/40 bg-[#f44336]/5 text-[#f44336]'
)}
>
{label}
</button>
)
})}
</div>
)}
{q.type === 'multi_select' && (
<div className='space-y-2'>
{q.options.map((opt, oi) => {
const selected = ((answers[qi] as number[]) ?? []).includes(oi)
return (
<button
key={oi}
type='button'
onClick={() => handleMultiSelect(qi, oi)}
disabled={Boolean(result)}
className={cn(
optionBase,
selected
? 'border-[#ECECEC]/40 bg-[#ECECEC]/5 text-[#ECECEC]'
: 'border-[#2A2A2A] text-[#999] hover:border-[#3A3A3A] hover:bg-[#272727]',
result &&
q.correctIndices.includes(oi) &&
'border-[#4CAF50]/50 bg-[#4CAF50]/5 text-[#4CAF50]',
result &&
selected &&
!q.correctIndices.includes(oi) &&
'border-[#f44336]/40 bg-[#f44336]/5 text-[#f44336]'
)}
>
{opt}
</button>
)
})}
</div>
)}
{feedback && (
<div
className={cn(
'mt-3 flex items-start gap-2 rounded-[6px] px-3 py-2.5 text-[13px]',
isCorrect ? 'bg-[#4CAF50]/10 text-[#4CAF50]' : 'bg-[#f44336]/10 text-[#f44336]'
)}
>
{isCorrect ? (
<CheckCircle2 className='mt-0.5 h-3.5 w-3.5 flex-shrink-0' />
) : (
<XCircle className='mt-0.5 h-3.5 w-3.5 flex-shrink-0' />
)}
<span>{isCorrect ? 'Correct!' : (feedback.explanation ?? 'Incorrect.')}</span>
</div>
)}
</div>
)
})}
{result && (
<div
className={cn(
'rounded-[8px] border p-5',
result.passed
? 'border-[#3A4A3A] bg-[#1F2A1F] text-[#4CAF50]'
: 'border-[#3A2A2A] bg-[#2A1F1F] text-[#f44336]'
)}
>
<p className='font-[430] text-[15px]'>{result.passed ? 'Passed!' : 'Keep trying!'}</p>
<p className='mt-1 text-[13px] opacity-80'>
Score: {result.score}% (passing: {quizConfig.passingScore}%)
</p>
{!result.passed && (
<button
type='button'
onClick={() => {
setAnswers({})
setResult(null)
}}
className='mt-3 rounded-[5px] border border-[#3A2A2A] bg-[#2A1F1F] px-3 py-1.5 text-[#999] text-[13px] transition-colors hover:border-[#4A3A3A] hover:text-[#ECECEC]'
>
Retry
</button>
)}
</div>
)}
{!result && (
<button
type='button'
onClick={handleSubmit}
disabled={!allAnswered}
className='rounded-[5px] bg-[#ECECEC] px-5 py-2.5 font-[430] text-[#1C1C1C] text-[14px] transition-colors hover:bg-white disabled:opacity-40'
>
Submit answers
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,23 @@
import type React from 'react'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
interface LessonLayoutProps {
children: React.ReactNode
params: Promise<{ courseSlug: string; lessonSlug: string }>
}
/**
* Server-side auth gate for lesson pages.
* Redirects unauthenticated users to login before any client JS runs.
*/
export default async function LessonLayout({ children, params }: LessonLayoutProps) {
const session = await getSession()
if (!session?.user?.id) {
const { courseSlug, lessonSlug } = await params
redirect(`/login?callbackUrl=/academy/${courseSlug}/${lessonSlug}`)
}
return <>{children}</>
}

View File

@@ -0,0 +1,219 @@
'use client'
import { use, useCallback, useEffect, useMemo, useState } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { getCourse } from '@/lib/academy/content'
import { markLessonComplete } from '@/lib/academy/local-progress'
import type { Lesson } from '@/lib/academy/types'
import { LessonVideo } from '@/app/academy/components/lesson-video'
import { ExerciseView } from './components/exercise-view'
import { LessonQuiz } from './components/lesson-quiz'
const navBtnClass =
'flex items-center gap-1 rounded-[5px] border border-[#2A2A2A] px-3 py-1.5 text-[#999] text-[12px] transition-colors hover:border-[#3A3A3A] hover:text-[#ECECEC]'
interface LessonPageProps {
params: Promise<{ courseSlug: string; lessonSlug: string }>
}
export default function LessonPage({ params }: LessonPageProps) {
const { courseSlug, lessonSlug } = use(params)
const course = getCourse(courseSlug)
const [exerciseComplete, setExerciseComplete] = useState(false)
const [quizComplete, setQuizComplete] = useState(false)
// Reset completion state when the lesson changes (Next.js reuses the component across navigations).
const [prevLessonSlug, setPrevLessonSlug] = useState(lessonSlug)
if (prevLessonSlug !== lessonSlug) {
setPrevLessonSlug(lessonSlug)
setExerciseComplete(false)
setQuizComplete(false)
}
const allLessons = useMemo<Lesson[]>(
() => course?.modules.flatMap((m) => m.lessons) ?? [],
[course]
)
const currentIndex = allLessons.findIndex((l) => l.slug === lessonSlug)
const lesson = allLessons[currentIndex]
const prevLesson = currentIndex > 0 ? allLessons[currentIndex - 1] : null
const nextLesson = currentIndex < allLessons.length - 1 ? allLessons[currentIndex + 1] : null
const handleExerciseComplete = useCallback(() => setExerciseComplete(true), [])
const handleQuizPass = useCallback(() => setQuizComplete(true), [])
const canAdvance =
(!lesson?.exerciseConfig && !lesson?.quizConfig) ||
(Boolean(lesson?.exerciseConfig) && Boolean(lesson?.quizConfig)
? exerciseComplete && quizComplete
: lesson?.exerciseConfig
? exerciseComplete
: quizComplete)
const isUngatedLesson =
lesson?.lessonType === 'video' ||
(lesson?.lessonType === 'mixed' && !lesson.exerciseConfig && !lesson.quizConfig)
useEffect(() => {
if (isUngatedLesson && lesson) {
markLessonComplete(lesson.id)
}
}, [lesson?.id, isUngatedLesson])
if (!course || !lesson) {
return (
<div className='flex h-screen items-center justify-center bg-[#1C1C1C]'>
<p className='text-[#666] text-[14px]'>Lesson not found.</p>
</div>
)
}
const hasVideo = Boolean(lesson.videoUrl)
const hasExercise = Boolean(lesson.exerciseConfig)
const hasQuiz = Boolean(lesson.quizConfig)
return (
<div className='fixed inset-0 flex flex-col overflow-hidden bg-[#1C1C1C]'>
<header className='flex h-[52px] flex-shrink-0 items-center justify-between border-[#2A2A2A] border-b bg-[#1C1C1C] px-5'>
<div className='flex items-center gap-3 text-[13px]'>
<Link href='/' aria-label='Sim home'>
<Image
src='/logo/b&w/text/b&w.svg'
alt='Sim'
width={40}
height={14}
className='opacity-70 invert transition-opacity hover:opacity-100'
/>
</Link>
<span className='text-[#333]'>/</span>
<Link href='/academy' className='text-[#666] transition-colors hover:text-[#999]'>
Academy
</Link>
<span className='text-[#333]'>/</span>
<Link
href={`/academy/${courseSlug}`}
className='max-w-[160px] truncate text-[#666] transition-colors hover:text-[#999]'
>
{course.title}
</Link>
<span className='text-[#333]'>/</span>
<span className='max-w-[200px] truncate text-[#ECECEC]'>{lesson.title}</span>
</div>
<div className='flex items-center gap-2'>
{prevLesson ? (
<Link href={`/academy/${courseSlug}/${prevLesson.slug}`} className={navBtnClass}>
<ChevronLeft className='h-3.5 w-3.5' />
Previous
</Link>
) : (
<Link href={`/academy/${courseSlug}`} className={navBtnClass}>
<ChevronLeft className='h-3.5 w-3.5' />
Course
</Link>
)}
{nextLesson && (
<Link
href={`/academy/${courseSlug}/${nextLesson.slug}`}
onClick={(e) => {
if (!canAdvance) e.preventDefault()
}}
className={`flex items-center gap-1 rounded-[5px] px-3 py-1.5 text-[12px] transition-colors ${
canAdvance
? 'bg-[#ECECEC] text-[#1C1C1C] hover:bg-white'
: 'cursor-not-allowed border border-[#2A2A2A] text-[#444]'
}`}
>
Next
<ChevronRight className='h-3.5 w-3.5' />
</Link>
)}
</div>
</header>
<div className='flex min-h-0 flex-1 overflow-hidden'>
{lesson.lessonType === 'video' && hasVideo && (
<div className='flex-1 overflow-y-auto p-10'>
<div className='mx-auto w-full max-w-3xl'>
<LessonVideo url={lesson.videoUrl!} title={lesson.title} />
{lesson.description && (
<p className='mt-5 text-[#999] text-[15px] leading-[160%]'>{lesson.description}</p>
)}
</div>
</div>
)}
{lesson.lessonType === 'exercise' && hasExercise && (
<ExerciseView
lessonId={lesson.id}
exerciseConfig={lesson.exerciseConfig!}
onComplete={handleExerciseComplete}
/>
)}
{lesson.lessonType === 'quiz' && hasQuiz && (
<div className='flex-1 overflow-y-auto p-10'>
<div className='mx-auto w-full max-w-2xl'>
<LessonQuiz
lessonId={lesson.id}
quizConfig={lesson.quizConfig!}
onPass={handleQuizPass}
/>
</div>
</div>
)}
{lesson.lessonType === 'mixed' && (
<>
{hasExercise && (!exerciseComplete || !hasQuiz) && (
<ExerciseView
lessonId={lesson.id}
exerciseConfig={lesson.exerciseConfig!}
onComplete={handleExerciseComplete}
videoUrl={!hasQuiz ? lesson.videoUrl : undefined}
description={!hasQuiz ? lesson.description : undefined}
/>
)}
{hasExercise && exerciseComplete && hasQuiz && (
<div className='flex-1 overflow-y-auto p-8'>
<div className='mx-auto w-full max-w-xl space-y-8'>
{hasVideo && <LessonVideo url={lesson.videoUrl!} title={lesson.title} />}
<LessonQuiz
lessonId={lesson.id}
quizConfig={lesson.quizConfig!}
onPass={handleQuizPass}
/>
</div>
</div>
)}
{!hasExercise && hasQuiz && (
<div className='flex-1 overflow-y-auto p-8'>
<div className='mx-auto w-full max-w-xl space-y-8'>
{hasVideo && <LessonVideo url={lesson.videoUrl!} title={lesson.title} />}
<LessonQuiz
lessonId={lesson.id}
quizConfig={lesson.quizConfig!}
onPass={handleQuizPass}
/>
</div>
</div>
)}
{!hasExercise && !hasQuiz && hasVideo && (
<div className='flex-1 overflow-y-auto p-10'>
<div className='mx-auto w-full max-w-3xl'>
<LessonVideo url={lesson.videoUrl!} title={lesson.title} />
{lesson.description && (
<p className='mt-5 text-[#999] text-[15px] leading-[160%]'>
{lesson.description}
</p>
)}
</div>
</div>
)}
</>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,56 @@
'use client'
interface LessonVideoProps {
url: string
title: string
}
export function LessonVideo({ url, title }: LessonVideoProps) {
const embedUrl = resolveEmbedUrl(url)
if (!embedUrl) {
return (
<div className='flex aspect-video items-center justify-center rounded-lg bg-[#1A1A1A] text-[#666] text-sm'>
Video unavailable
</div>
)
}
return (
<div className='aspect-video w-full overflow-hidden rounded-lg bg-black'>
<iframe
src={embedUrl}
title={title}
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture'
allowFullScreen
className='h-full w-full border-0'
/>
</div>
)
}
function resolveEmbedUrl(url: string): string | null {
try {
const parsed = new URL(url)
if (parsed.hostname === 'youtu.be') {
return `https://www.youtube.com/embed${parsed.pathname}`
}
if (parsed.hostname.includes('youtube.com')) {
// Shorts: youtube.com/shorts/VIDEO_ID
const shortsMatch = parsed.pathname.match(/^\/shorts\/([^/?]+)/)
if (shortsMatch) return `https://www.youtube.com/embed/${shortsMatch[1]}`
const v = parsed.searchParams.get('v')
if (v) return `https://www.youtube.com/embed/${v}`
}
if (parsed.hostname === 'vimeo.com') {
const id = parsed.pathname.replace(/^\//, '')
if (id) return `https://player.vimeo.com/video/${id}`
}
return null
} catch (_e: unknown) {
return null
}
}

View File

@@ -0,0 +1,432 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import type { Edge } from 'reactflow'
import { buildMockExecutionPlan } from '@/lib/academy/mock-execution'
import type {
ExerciseBlockState,
ExerciseDefinition,
ExerciseEdgeState,
ValidationResult,
} from '@/lib/academy/types'
import { validateExercise } from '@/lib/academy/validation'
import { cn } from '@/lib/core/utils/cn'
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { SandboxWorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import Workflow from '@/app/workspace/[workspaceId]/w/[workflowId]/workflow'
import { getBlock } from '@/blocks/registry'
import { SandboxBlockConstraintsContext } from '@/hooks/use-sandbox-block-constraints'
import { useExecutionStore } from '@/stores/execution/store'
import { useTerminalConsoleStore } from '@/stores/terminal/console/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { BlockState, SubBlockState, WorkflowState } from '@/stores/workflows/workflow/types'
import { LessonVideo } from './lesson-video'
import { ValidationChecklist } from './validation-checklist'
const logger = createLogger('SandboxCanvasProvider')
const SANDBOX_WORKSPACE_ID = 'sandbox'
interface SandboxCanvasProviderProps {
/** Unique ID for this exercise instance */
exerciseId: string
/** Full exercise configuration */
exerciseConfig: ExerciseDefinition
/**
* Called when all validation rules pass for the first time.
* Receives the current canvas state so the caller can persist it.
*/
onComplete?: (blocks: ExerciseBlockState[], edges: ExerciseEdgeState[]) => void
/** Optional video URL (YouTube/Vimeo) shown above the checklist — used for mixed lessons */
videoUrl?: string
/** Optional description shown below the video (or below checklist if no video) */
description?: string
className?: string
}
/**
* Builds a Zustand-compatible WorkflowState from exercise block/edge definitions.
* Looks up each block type in the registry to construct proper sub-block and output maps.
*/
function buildWorkflowState(
initialBlocks: ExerciseBlockState[],
initialEdges: ExerciseEdgeState[]
): WorkflowState {
const blocks: Record<string, BlockState> = {}
for (const exerciseBlock of initialBlocks) {
const config = getBlock(exerciseBlock.type)
if (!config) {
logger.warn(`Unknown block type "${exerciseBlock.type}" in exercise config`)
continue
}
const subBlocks: Record<string, SubBlockState> = {}
for (const sb of config.subBlocks ?? []) {
const overrideValue = exerciseBlock.subBlocks?.[sb.id]
subBlocks[sb.id] = {
id: sb.id,
type: sb.type,
value: (overrideValue !== undefined ? overrideValue : null) as SubBlockState['value'],
}
}
const outputs = getEffectiveBlockOutputs(exerciseBlock.type, subBlocks, {
triggerMode: false,
preferToolOutputs: true,
})
blocks[exerciseBlock.id] = {
id: exerciseBlock.id,
type: exerciseBlock.type,
name: config.name,
position: exerciseBlock.position,
subBlocks,
outputs,
enabled: true,
horizontalHandles: true,
advancedMode: false,
triggerMode: false,
height: 0,
locked: exerciseBlock.locked ?? false,
}
}
const edges: Edge[] = initialEdges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
sourceHandle: e.sourceHandle,
targetHandle: e.targetHandle,
type: 'default',
data: {},
}))
return { blocks, edges, loops: {}, parallels: {}, lastSaved: Date.now() }
}
/**
* Reads the current canvas state from the workflow store and converts it to
* the exercise block/edge format used by the validation engine.
*/
function readCurrentCanvasState(workflowId: string): {
blocks: ExerciseBlockState[]
edges: ExerciseEdgeState[]
} {
const workflowStore = useWorkflowStore.getState()
const subBlockStore = useSubBlockStore.getState()
const blocks: ExerciseBlockState[] = Object.values(workflowStore.blocks).map((block) => {
const storedValues = subBlockStore.workflowValues[workflowId] ?? {}
const blockValues = storedValues[block.id] ?? {}
const subBlocks: Record<string, unknown> = { ...blockValues }
return {
id: block.id,
type: block.type,
position: block.position,
subBlocks,
}
})
const edges: ExerciseEdgeState[] = workflowStore.edges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
sourceHandle: e.sourceHandle ?? undefined,
targetHandle: e.targetHandle ?? undefined,
}))
return { blocks, edges }
}
/**
* Wraps the real Sim canvas in sandbox mode for Sim Academy exercises.
*
* - Pre-hydrates workflow stores directly (no API calls)
* - Provides sandbox permissions (canEdit: true, no workspace dependency)
* - Displays a constrained block toolbar and live validation checklist
* - Supports mock execution to simulate workflow runs
*/
export function SandboxCanvasProvider({
exerciseId,
exerciseConfig,
onComplete,
videoUrl,
description,
className,
}: SandboxCanvasProviderProps) {
const [isReady, setIsReady] = useState(false)
const [validationResult, setValidationResult] = useState<ValidationResult>({
passed: false,
results: [],
})
const [hintIndex, setHintIndex] = useState(-1)
const completedRef = useRef(false)
const onCompleteRef = useRef(onComplete)
onCompleteRef.current = onComplete
const isMockRunningRef = useRef(false)
const handleMockRunRef = useRef<() => Promise<void>>(async () => {})
// Stable exercise ID — used as the workflow ID in the stores
const workflowId = `sandbox-${exerciseId}`
const runValidation = useCallback(() => {
const { blocks, edges } = readCurrentCanvasState(workflowId)
const result = validateExercise(blocks, edges, exerciseConfig.validationRules)
setValidationResult((prev) => {
if (
prev.passed === result.passed &&
prev.results.length === result.results.length &&
prev.results.every((r, i) => r.passed === result.results[i].passed)
) {
return prev
}
return result
})
if (result.passed && !completedRef.current) {
completedRef.current = true
onCompleteRef.current?.(blocks, edges)
}
}, [workflowId, exerciseConfig.validationRules])
useEffect(() => {
completedRef.current = false
setHintIndex(-1)
const workflowState = buildWorkflowState(
exerciseConfig.initialBlocks ?? [],
exerciseConfig.initialEdges ?? []
)
const syntheticMetadata: WorkflowMetadata = {
id: workflowId,
name: 'Exercise',
lastModified: new Date(),
createdAt: new Date(),
color: '#3972F6',
workspaceId: SANDBOX_WORKSPACE_ID,
sortOrder: 0,
isSandbox: true,
}
useWorkflowStore.getState().replaceWorkflowState(workflowState)
useSubBlockStore.getState().initializeFromWorkflow(workflowId, workflowState.blocks)
useWorkflowRegistry.setState((state) => ({
workflows: { ...state.workflows, [workflowId]: syntheticMetadata },
activeWorkflowId: workflowId,
hydration: {
phase: 'ready',
workspaceId: SANDBOX_WORKSPACE_ID,
workflowId,
requestId: null,
error: null,
},
}))
logger.info('Sandbox stores hydrated', { workflowId })
setIsReady(true)
// Coalesce rapid store updates so validation runs at most once per animation frame.
let rafId: number | null = null
const scheduleValidation = () => {
if (rafId !== null) return
rafId = requestAnimationFrame(() => {
rafId = null
runValidation()
})
}
const unsubWorkflow = useWorkflowStore.subscribe(scheduleValidation)
const unsubSubBlock = useSubBlockStore.subscribe(scheduleValidation)
// When the panel's Run button is clicked, useWorkflowExecution sets isExecuting=true
// and returns immediately (no API call). Detect that signal here and run mock execution.
const unsubExecution = useExecutionStore.subscribe((state) => {
const isExec = state.workflowExecutions.get(workflowId)?.isExecuting
if (isExec && !isMockRunningRef.current) {
void handleMockRunRef.current()
}
})
runValidation()
return () => {
if (rafId !== null) cancelAnimationFrame(rafId)
unsubWorkflow()
unsubSubBlock()
unsubExecution()
useWorkflowRegistry.setState((state) => {
const { [workflowId]: _removed, ...rest } = state.workflows
return {
workflows: rest,
activeWorkflowId: state.activeWorkflowId === workflowId ? null : state.activeWorkflowId,
hydration:
state.hydration.workflowId === workflowId
? { phase: 'idle', workspaceId: null, workflowId: null, requestId: null, error: null }
: state.hydration,
}
})
useWorkflowStore.setState({ blocks: {}, edges: [], loops: {}, parallels: {} })
useSubBlockStore.setState((state) => {
const { [workflowId]: _removed, ...rest } = state.workflowValues
return { workflowValues: rest }
})
}
}, [workflowId, exerciseConfig.initialBlocks, exerciseConfig.initialEdges, runValidation])
const handleMockRun = useCallback(async () => {
if (isMockRunningRef.current) return
isMockRunningRef.current = true
const { setActiveBlocks, setIsExecuting } = useExecutionStore.getState()
const { blocks, edges } = readCurrentCanvasState(workflowId)
const result = validateExercise(blocks, edges, exerciseConfig.validationRules)
setValidationResult(result)
if (!result.passed) {
isMockRunningRef.current = false
setIsExecuting(workflowId, false)
return
}
const plan = buildMockExecutionPlan(blocks, edges, exerciseConfig.mockOutputs ?? {})
if (plan.length === 0) {
isMockRunningRef.current = false
setIsExecuting(workflowId, false)
return
}
const { addConsole, clearWorkflowConsole } = useTerminalConsoleStore.getState()
const workflowBlocks = useWorkflowStore.getState().blocks
setIsExecuting(workflowId, true)
clearWorkflowConsole(workflowId)
useTerminalConsoleStore.setState({ isOpen: true })
try {
for (let i = 0; i < plan.length; i++) {
const step = plan[i]
setActiveBlocks(workflowId, new Set([step.blockId]))
await new Promise((resolve) => setTimeout(resolve, step.delay))
addConsole({
workflowId,
blockId: step.blockId,
blockName: workflowBlocks[step.blockId]?.name ?? step.blockType,
blockType: step.blockType,
executionOrder: i,
output: step.output,
success: true,
durationMs: step.delay,
})
setActiveBlocks(workflowId, new Set())
}
} finally {
setIsExecuting(workflowId, false)
isMockRunningRef.current = false
}
}, [workflowId, exerciseConfig.validationRules, exerciseConfig.mockOutputs])
handleMockRunRef.current = handleMockRun
const handleShowHint = useCallback(() => {
const hints = exerciseConfig.hints ?? []
if (hints.length === 0) return
setHintIndex((i) => Math.min(i + 1, hints.length - 1))
}, [exerciseConfig.hints])
const handlePrevHint = useCallback(() => {
setHintIndex((i) => Math.max(i - 1, 0))
}, [])
if (!isReady) {
return (
<div className='flex h-full w-full items-center justify-center bg-[#0e0e0e]'>
<div className='h-5 w-5 animate-spin rounded-full border-2 border-[#ECECEC] border-t-transparent' />
</div>
)
}
const hints = exerciseConfig.hints ?? []
const currentHint = hintIndex >= 0 ? hints[hintIndex] : null
return (
<SandboxBlockConstraintsContext.Provider value={exerciseConfig.availableBlocks}>
<GlobalCommandsProvider>
<SandboxWorkspacePermissionsProvider>
<div className={cn('flex h-full w-full overflow-hidden', className)}>
<div className='flex w-56 flex-shrink-0 flex-col gap-3 overflow-y-auto border-[#1F1F1F] border-r bg-[#141414] p-3'>
{(videoUrl || description) && (
<div className='flex flex-col gap-2'>
{videoUrl && <LessonVideo url={videoUrl} title='Lesson video' />}
{description && (
<p className='text-[#666] text-[11px] leading-relaxed'>{description}</p>
)}
<div className='border-[#1F1F1F] border-t' />
</div>
)}
{exerciseConfig.instructions && (
<p className='text-[#999] text-[11px] leading-relaxed'>
{exerciseConfig.instructions}
</p>
)}
<ValidationChecklist
results={validationResult.results}
allPassed={validationResult.passed}
/>
<div className='mt-auto flex flex-col gap-2'>
{currentHint && (
<div className='rounded-[6px] border border-[#2A2A2A] bg-[#1A1A1A] px-3 py-2 text-[11px]'>
<div className='mb-1 flex items-center justify-between'>
<span className='font-[430] text-[#666]'>
Hint {hintIndex + 1}/{hints.length}
</span>
<div className='flex gap-1'>
<button
type='button'
onClick={handlePrevHint}
disabled={hintIndex === 0}
className='rounded px-1 text-[#666] transition-colors hover:text-[#ECECEC] disabled:opacity-30'
aria-label='Previous hint'
>
</button>
<button
type='button'
onClick={handleShowHint}
disabled={hintIndex === hints.length - 1}
className='rounded px-1 text-[#666] transition-colors hover:text-[#ECECEC] disabled:opacity-30'
aria-label='Next hint'
>
</button>
</div>
</div>
<span className='text-[#ECECEC]'>{currentHint}</span>
</div>
)}
{hints.length > 0 && hintIndex < 0 && (
<button
type='button'
onClick={handleShowHint}
className='w-full rounded-[5px] border border-[#2A2A2A] bg-[#1A1A1A] px-3 py-1.5 text-[#999] text-[12px] transition-colors hover:border-[#3A3A3A] hover:text-[#ECECEC]'
>
Show hint
</button>
)}
</div>
</div>
<div className='relative flex-1 overflow-hidden'>
<Workflow workspaceId={SANDBOX_WORKSPACE_ID} workflowId={workflowId} sandbox />
</div>
</div>
</SandboxWorkspacePermissionsProvider>
</GlobalCommandsProvider>
</SandboxBlockConstraintsContext.Provider>
)
}

View File

@@ -0,0 +1,50 @@
'use client'
import { CheckCircle2, Circle } from 'lucide-react'
import type { ValidationRuleResult } from '@/lib/academy/types'
import { cn } from '@/lib/core/utils/cn'
interface ValidationChecklistProps {
results: ValidationRuleResult[]
allPassed: boolean
}
/**
* Checklist showing exercise validation rules and their current pass/fail state.
* Rendered inside the exercise sidebar, not as a canvas overlay.
*/
export function ValidationChecklist({ results, allPassed }: ValidationChecklistProps) {
if (results.length === 0) return null
return (
<div>
<div className='mb-2.5 flex items-center gap-1.5'>
<span className='font-[430] text-[#ECECEC] text-[12px]'>Checklist</span>
{allPassed && (
<span className='ml-auto rounded-full bg-[#4CAF50]/15 px-2 py-0.5 font-[430] text-[#4CAF50] text-[10px]'>
Complete
</span>
)}
</div>
<ul className='space-y-1.5'>
{results.map((result, i) => (
<li key={i} className='flex items-start gap-2'>
{result.passed ? (
<CheckCircle2 className='mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-[#4CAF50]' />
) : (
<Circle className='mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-[#444]' />
)}
<span
className={cn(
'text-[11px] leading-tight',
result.passed ? 'text-[#555] line-through' : 'text-[#ECECEC]'
)}
>
{result.message}
</span>
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,25 @@
import type React from 'react'
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: {
absolute: 'Sim Academy',
template: '%s | Sim Academy',
},
description:
'Become a certified Sim partner — learn to build, integrate, and deploy AI workflows.',
metadataBase: new URL('https://sim.ai'),
openGraph: {
title: 'Sim Academy',
description: 'Become a certified Sim partner.',
type: 'website',
},
}
export default function AcademyLayout({ children }: { children: React.ReactNode }) {
return (
<div className='min-h-screen bg-[#1C1C1C] font-[430] font-season text-[#ECECEC]'>
{children}
</div>
)
}

View File

@@ -0,0 +1,215 @@
import { db } from '@sim/db'
import { academyCertificate, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getCourseById } from '@/lib/academy/content'
import type { CertificateMetadata } from '@/lib/academy/types'
import { getSession } from '@/lib/auth'
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
import { RateLimiter } from '@/lib/core/rate-limiter'
const logger = createLogger('AcademyCertificatesAPI')
const rateLimiter = new RateLimiter()
const CERT_RATE_LIMIT: TokenBucketConfig = {
maxTokens: 5,
refillRate: 1,
refillIntervalMs: 60 * 60_000, // 1 per hour refill
}
const IssueCertificateSchema = z.object({
courseId: z.string(),
completedLessonIds: z.array(z.string()),
})
/**
* POST /api/academy/certificates
* Issues a certificate for the given course after verifying all lessons are completed.
* Completion is client-attested: the client sends completed lesson IDs and the server
* validates them against the full lesson list for the course.
*/
export async function POST(req: NextRequest) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { allowed } = await rateLimiter.checkRateLimitDirect(
`academy:cert:${session.user.id}`,
CERT_RATE_LIMIT
)
if (!allowed) {
return NextResponse.json({ error: 'Too many requests' }, { status: 429 })
}
const body = await req.json()
const parsed = IssueCertificateSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
}
const { courseId, completedLessonIds } = parsed.data
const course = getCourseById(courseId)
if (!course) {
return NextResponse.json({ error: 'Course not found' }, { status: 404 })
}
// Verify all lessons in the course are reported as completed
const allLessonIds = course.modules.flatMap((m) => m.lessons.map((l) => l.id))
const completedSet = new Set(completedLessonIds)
const incomplete = allLessonIds.filter((id) => !completedSet.has(id))
if (incomplete.length > 0) {
return NextResponse.json({ error: 'Course not fully completed', incomplete }, { status: 422 })
}
const [existing, learner] = await Promise.all([
db
.select()
.from(academyCertificate)
.where(
and(
eq(academyCertificate.userId, session.user.id),
eq(academyCertificate.courseId, courseId)
)
)
.limit(1)
.then((rows) => rows[0] ?? null),
db
.select({ name: user.name })
.from(user)
.where(eq(user.id, session.user.id))
.limit(1)
.then((rows) => rows[0] ?? null),
])
if (existing) {
if (existing.status === 'active') {
return NextResponse.json({ certificate: existing })
}
return NextResponse.json(
{ error: 'A certificate for this course already exists but is not active.' },
{ status: 409 }
)
}
const certificateNumber = generateCertificateNumber()
const metadata: CertificateMetadata = {
recipientName: learner?.name ?? session.user.name ?? 'Partner',
courseTitle: course.title,
}
const [certificate] = await db
.insert(academyCertificate)
.values({
id: nanoid(),
userId: session.user.id,
courseId,
status: 'active',
certificateNumber,
metadata,
})
.onConflictDoNothing()
.returning()
if (!certificate) {
const [race] = await db
.select()
.from(academyCertificate)
.where(
and(
eq(academyCertificate.userId, session.user.id),
eq(academyCertificate.courseId, courseId)
)
)
.limit(1)
if (race?.status === 'active') {
return NextResponse.json({ certificate: race })
}
if (race) {
return NextResponse.json(
{ error: 'A certificate for this course already exists but is not active.' },
{ status: 409 }
)
}
return NextResponse.json({ error: 'Failed to issue certificate' }, { status: 500 })
}
logger.info('Certificate issued', {
userId: session.user.id,
courseId,
certificateNumber,
})
return NextResponse.json({ certificate }, { status: 201 })
} catch (error) {
logger.error('Failed to issue certificate', { error })
return NextResponse.json({ error: 'Failed to issue certificate' }, { status: 500 })
}
}
/**
* GET /api/academy/certificates?certificateNumber=SIM-2026-00042
* Public endpoint for verifying a certificate by its number.
*
* GET /api/academy/certificates?courseId=...
* Authenticated endpoint for looking up the current user's certificate for a course.
*/
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url)
const certificateNumber = searchParams.get('certificateNumber')
const courseId = searchParams.get('courseId')
if (certificateNumber) {
const [certificate] = await db
.select()
.from(academyCertificate)
.where(eq(academyCertificate.certificateNumber, certificateNumber))
.limit(1)
if (!certificate) {
return NextResponse.json({ error: 'Certificate not found' }, { status: 404 })
}
return NextResponse.json({ certificate })
}
if (courseId) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const [certificate] = await db
.select()
.from(academyCertificate)
.where(
and(
eq(academyCertificate.userId, session.user.id),
eq(academyCertificate.courseId, courseId)
)
)
.limit(1)
return NextResponse.json({ certificate: certificate ?? null })
}
return NextResponse.json(
{ error: 'certificateNumber or courseId query parameter is required' },
{ status: 400 }
)
} catch (error) {
logger.error('Failed to verify certificate', { error })
return NextResponse.json({ error: 'Failed to verify certificate' }, { status: 500 })
}
}
/** Generates a human-readable certificate number, e.g. SIM-2026-A3K9XZ2P */
function generateCertificateNumber(): string {
const year = new Date().getFullYear()
return `SIM-${year}-${nanoid(8).toUpperCase()}`
}

View File

@@ -5,6 +5,7 @@ import { and, desc, eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { createRunSegment } from '@/lib/copilot/async-runs/repository'
import { getAccessibleCopilotChat, resolveOrCreateChat } from '@/lib/copilot/chat-lifecycle'
import { buildCopilotRequestPayload } from '@/lib/copilot/chat-payload'
import {
@@ -539,10 +540,26 @@ export async function POST(req: NextRequest) {
return new Response(sseStream, { headers: SSE_RESPONSE_HEADERS })
}
const nsExecutionId = crypto.randomUUID()
const nsRunId = crypto.randomUUID()
if (actualChatId) {
await createRunSegment({
id: nsRunId,
executionId: nsExecutionId,
chatId: actualChatId,
userId: authenticatedUserId,
workflowId,
streamId: userMessageIdToUse,
}).catch(() => {})
}
const nonStreamingResult = await orchestrateCopilotStream(requestPayload, {
userId: authenticatedUserId,
workflowId,
chatId: actualChatId,
executionId: nsExecutionId,
runId: nsRunId,
goRoute: '/api/copilot',
autoExecuteTools: true,
interactive: true,

View File

@@ -12,6 +12,7 @@ const {
mockReturning,
mockSelect,
mockFrom,
mockWhere,
mockAuthenticate,
mockCreateUnauthorizedResponse,
mockCreateBadRequestResponse,
@@ -23,6 +24,7 @@ const {
mockReturning: vi.fn(),
mockSelect: vi.fn(),
mockFrom: vi.fn(),
mockWhere: vi.fn(),
mockAuthenticate: vi.fn(),
mockCreateUnauthorizedResponse: vi.fn(),
mockCreateBadRequestResponse: vi.fn(),
@@ -81,7 +83,8 @@ describe('Copilot Feedback API Route', () => {
mockValues.mockReturnValue({ returning: mockReturning })
mockReturning.mockResolvedValue([])
mockSelect.mockReturnValue({ from: mockFrom })
mockFrom.mockResolvedValue([])
mockFrom.mockReturnValue({ where: mockWhere })
mockWhere.mockResolvedValue([])
mockCreateRequestTracker.mockReturnValue({
requestId: 'test-request-id',
@@ -386,7 +389,7 @@ edges:
isAuthenticated: true,
})
mockFrom.mockResolvedValueOnce([])
mockWhere.mockResolvedValueOnce([])
const request = new Request('http://localhost:3000/api/copilot/feedback')
const response = await GET(request as any)
@@ -397,7 +400,7 @@ edges:
expect(responseData.feedback).toEqual([])
})
it('should return all feedback records', async () => {
it('should only return feedback records for the authenticated user', async () => {
mockAuthenticate.mockResolvedValueOnce({
userId: 'user-123',
isAuthenticated: true,
@@ -415,19 +418,8 @@ edges:
workflowYaml: null,
createdAt: new Date('2024-01-01'),
},
{
feedbackId: 'feedback-2',
userId: 'user-456',
chatId: 'chat-2',
userQuery: 'Query 2',
agentResponse: 'Response 2',
isPositive: false,
feedback: 'Not helpful',
workflowYaml: 'yaml: content',
createdAt: new Date('2024-01-02'),
},
]
mockFrom.mockResolvedValueOnce(mockFeedback)
mockWhere.mockResolvedValueOnce(mockFeedback)
const request = new Request('http://localhost:3000/api/copilot/feedback')
const response = await GET(request as any)
@@ -435,9 +427,14 @@ edges:
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData.success).toBe(true)
expect(responseData.feedback).toHaveLength(2)
expect(responseData.feedback).toHaveLength(1)
expect(responseData.feedback[0].feedbackId).toBe('feedback-1')
expect(responseData.feedback[1].feedbackId).toBe('feedback-2')
expect(responseData.feedback[0].userId).toBe('user-123')
// Verify the where clause was called with the authenticated user's ID
const { eq } = await import('drizzle-orm')
expect(mockWhere).toHaveBeenCalled()
expect(eq).toHaveBeenCalledWith('userId', 'user-123')
})
it('should handle database errors gracefully', async () => {
@@ -446,7 +443,7 @@ edges:
isAuthenticated: true,
})
mockFrom.mockRejectedValueOnce(new Error('Database connection failed'))
mockWhere.mockRejectedValueOnce(new Error('Database connection failed'))
const request = new Request('http://localhost:3000/api/copilot/feedback')
const response = await GET(request as any)
@@ -462,7 +459,7 @@ edges:
isAuthenticated: true,
})
mockFrom.mockResolvedValueOnce([])
mockWhere.mockResolvedValueOnce([])
const request = new Request('http://localhost:3000/api/copilot/feedback')
const response = await GET(request as any)

View File

@@ -1,6 +1,7 @@
import { db } from '@sim/db'
import { copilotFeedback } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import {
@@ -109,7 +110,7 @@ export async function POST(req: NextRequest) {
/**
* GET /api/copilot/feedback
* Get all feedback records (for analytics)
* Get feedback records for the authenticated user
*/
export async function GET(req: NextRequest) {
const tracker = createRequestTracker()
@@ -123,7 +124,7 @@ export async function GET(req: NextRequest) {
return createUnauthorizedResponse()
}
// Get all feedback records
// Get feedback records for the authenticated user only
const feedbackRecords = await db
.select({
feedbackId: copilotFeedback.feedbackId,
@@ -137,6 +138,7 @@ export async function GET(req: NextRequest) {
createdAt: copilotFeedback.createdAt,
})
.from(copilotFeedback)
.where(eq(copilotFeedback.userId, authenticatedUserId))
logger.info(`[${tracker.requestId}] Retrieved ${feedbackRecords.length} feedback records`)

View File

@@ -1,6 +1,10 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import {
authenticateCopilotRequestSessionOnly,
createUnauthorizedResponse,
} from '@/lib/copilot/request-helpers'
import { env } from '@/lib/core/config/env'
const logger = createLogger('CopilotTrainingExamplesAPI')
@@ -16,6 +20,11 @@ const TrainingExampleSchema = z.object({
})
export async function POST(request: NextRequest) {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
const baseUrl = env.AGENT_INDEXER_URL
if (!baseUrl) {
logger.error('Missing AGENT_INDEXER_URL environment variable')

View File

@@ -1,6 +1,10 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import {
authenticateCopilotRequestSessionOnly,
createUnauthorizedResponse,
} from '@/lib/copilot/request-helpers'
import { env } from '@/lib/core/config/env'
const logger = createLogger('CopilotTrainingAPI')
@@ -22,6 +26,11 @@ const TrainingDataSchema = z.object({
})
export async function POST(request: NextRequest) {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
try {
const baseUrl = env.AGENT_INDEXER_URL
if (!baseUrl) {

View File

@@ -26,6 +26,14 @@ vi.mock('@/lib/execution/e2b', () => ({
executeInE2B: mockExecuteInE2B,
}))
vi.mock('@/lib/core/config/feature-flags', () => ({
isHosted: false,
isE2bEnabled: false,
isProd: false,
isDev: false,
isTest: true,
}))
import { validateProxyUrl } from '@/lib/core/security/input-validation'
import { POST } from '@/app/api/function/execute/route'

View File

@@ -0,0 +1,160 @@
/**
* @vitest-environment node
*/
import type { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockCheckHybridAuth,
mockGetDispatchJobRecord,
mockGetJobQueue,
mockVerifyWorkflowAccess,
mockGetWorkflowById,
} = vi.hoisted(() => ({
mockCheckHybridAuth: vi.fn(),
mockGetDispatchJobRecord: vi.fn(),
mockGetJobQueue: vi.fn(),
mockVerifyWorkflowAccess: vi.fn(),
mockGetWorkflowById: vi.fn(),
}))
vi.mock('@sim/logger', () => ({
createLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
}))
vi.mock('@/lib/auth/hybrid', () => ({
checkHybridAuth: mockCheckHybridAuth,
}))
vi.mock('@/lib/core/async-jobs', () => ({
JOB_STATUS: {
PENDING: 'pending',
PROCESSING: 'processing',
COMPLETED: 'completed',
FAILED: 'failed',
},
getJobQueue: mockGetJobQueue,
}))
vi.mock('@/lib/core/workspace-dispatch/store', () => ({
getDispatchJobRecord: mockGetDispatchJobRecord,
}))
vi.mock('@/lib/core/utils/request', () => ({
generateRequestId: vi.fn().mockReturnValue('request-1'),
}))
vi.mock('@/socket/middleware/permissions', () => ({
verifyWorkflowAccess: mockVerifyWorkflowAccess,
}))
vi.mock('@/lib/workflows/utils', () => ({
getWorkflowById: mockGetWorkflowById,
}))
import { GET } from './route'
function createMockRequest(): NextRequest {
return {
headers: {
get: () => null,
},
} as NextRequest
}
describe('GET /api/jobs/[jobId]', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCheckHybridAuth.mockResolvedValue({
success: true,
userId: 'user-1',
apiKeyType: undefined,
workspaceId: undefined,
})
mockVerifyWorkflowAccess.mockResolvedValue({ hasAccess: true })
mockGetWorkflowById.mockResolvedValue({
id: 'workflow-1',
workspaceId: 'workspace-1',
})
mockGetJobQueue.mockResolvedValue({
getJob: vi.fn().mockResolvedValue(null),
})
})
it('returns dispatcher-aware waiting status with metadata', async () => {
mockGetDispatchJobRecord.mockResolvedValue({
id: 'dispatch-1',
workspaceId: 'workspace-1',
lane: 'runtime',
queueName: 'workflow-execution',
bullmqJobName: 'workflow-execution',
bullmqPayload: {},
metadata: {
workflowId: 'workflow-1',
},
priority: 10,
status: 'waiting',
createdAt: 1000,
admittedAt: 2000,
})
const response = await GET(createMockRequest(), {
params: Promise.resolve({ jobId: 'dispatch-1' }),
})
const body = await response.json()
expect(response.status).toBe(200)
expect(body.status).toBe('waiting')
expect(body.metadata.queueName).toBe('workflow-execution')
expect(body.metadata.lane).toBe('runtime')
expect(body.metadata.workspaceId).toBe('workspace-1')
})
it('returns completed output from dispatch state', async () => {
mockGetDispatchJobRecord.mockResolvedValue({
id: 'dispatch-2',
workspaceId: 'workspace-1',
lane: 'interactive',
queueName: 'workflow-execution',
bullmqJobName: 'direct-workflow-execution',
bullmqPayload: {},
metadata: {
workflowId: 'workflow-1',
},
priority: 1,
status: 'completed',
createdAt: 1000,
startedAt: 2000,
completedAt: 7000,
output: { success: true },
})
const response = await GET(createMockRequest(), {
params: Promise.resolve({ jobId: 'dispatch-2' }),
})
const body = await response.json()
expect(response.status).toBe(200)
expect(body.status).toBe('completed')
expect(body.output).toEqual({ success: true })
expect(body.metadata.duration).toBe(5000)
})
it('returns 404 when neither dispatch nor BullMQ job exists', async () => {
mockGetDispatchJobRecord.mockResolvedValue(null)
const response = await GET(createMockRequest(), {
params: Promise.resolve({ jobId: 'missing-job' }),
})
expect(response.status).toBe(404)
})
})

View File

@@ -1,8 +1,10 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { getJobQueue, JOB_STATUS } from '@/lib/core/async-jobs'
import { getJobQueue } from '@/lib/core/async-jobs'
import { generateRequestId } from '@/lib/core/utils/request'
import { presentDispatchOrJobStatus } from '@/lib/core/workspace-dispatch/status'
import { getDispatchJobRecord } from '@/lib/core/workspace-dispatch/store'
import { createErrorResponse } from '@/app/api/workflows/utils'
const logger = createLogger('TaskStatusAPI')
@@ -23,68 +25,54 @@ export async function GET(
const authenticatedUserId = authResult.userId
const dispatchJob = await getDispatchJobRecord(taskId)
const jobQueue = await getJobQueue()
const job = await jobQueue.getJob(taskId)
const job = dispatchJob ? null : await jobQueue.getJob(taskId)
if (!job) {
if (!job && !dispatchJob) {
return createErrorResponse('Task not found', 404)
}
if (job.metadata?.workflowId) {
const metadataToCheck = dispatchJob?.metadata ?? job?.metadata
if (metadataToCheck?.workflowId) {
const { verifyWorkflowAccess } = await import('@/socket/middleware/permissions')
const accessCheck = await verifyWorkflowAccess(
authenticatedUserId,
job.metadata.workflowId as string
metadataToCheck.workflowId as string
)
if (!accessCheck.hasAccess) {
logger.warn(`[${requestId}] Access denied to workflow ${job.metadata.workflowId}`)
logger.warn(`[${requestId}] Access denied to workflow ${metadataToCheck.workflowId}`)
return createErrorResponse('Access denied', 403)
}
if (authResult.apiKeyType === 'workspace' && authResult.workspaceId) {
const { getWorkflowById } = await import('@/lib/workflows/utils')
const workflow = await getWorkflowById(job.metadata.workflowId as string)
const workflow = await getWorkflowById(metadataToCheck.workflowId as string)
if (!workflow?.workspaceId || workflow.workspaceId !== authResult.workspaceId) {
return createErrorResponse('API key is not authorized for this workspace', 403)
}
}
} else if (job.metadata?.userId && job.metadata.userId !== authenticatedUserId) {
logger.warn(`[${requestId}] Access denied to user ${job.metadata.userId}`)
} else if (metadataToCheck?.userId && metadataToCheck.userId !== authenticatedUserId) {
logger.warn(`[${requestId}] Access denied to user ${metadataToCheck.userId}`)
return createErrorResponse('Access denied', 403)
} else if (!job.metadata?.userId && !job.metadata?.workflowId) {
} else if (!metadataToCheck?.userId && !metadataToCheck?.workflowId) {
logger.warn(`[${requestId}] Access denied to job ${taskId}`)
return createErrorResponse('Access denied', 403)
}
const mappedStatus = job.status === JOB_STATUS.PENDING ? 'queued' : job.status
const presented = presentDispatchOrJobStatus(dispatchJob, job)
const response: any = {
success: true,
taskId,
status: mappedStatus,
metadata: {
startedAt: job.startedAt,
},
status: presented.status,
metadata: presented.metadata,
}
if (job.status === JOB_STATUS.COMPLETED) {
response.output = job.output
response.metadata.completedAt = job.completedAt
if (job.startedAt && job.completedAt) {
response.metadata.duration = job.completedAt.getTime() - job.startedAt.getTime()
}
}
if (job.status === JOB_STATUS.FAILED) {
response.error = job.error
response.metadata.completedAt = job.completedAt
if (job.startedAt && job.completedAt) {
response.metadata.duration = job.completedAt.getTime() - job.startedAt.getTime()
}
}
if (job.status === JOB_STATUS.PROCESSING || job.status === JOB_STATUS.PENDING) {
response.estimatedDuration = 300000
if (presented.output !== undefined) response.output = presented.output
if (presented.error !== undefined) response.error = presented.error
if (presented.estimatedDuration !== undefined) {
response.estimatedDuration = presented.estimatedDuration
}
return NextResponse.json(response)

View File

@@ -237,7 +237,7 @@ describe('Knowledge Connector By ID API Route', () => {
.mockReturnValueOnce(mockDbChain)
.mockResolvedValueOnce([{ id: 'doc-1', fileUrl: '/api/uploads/test.txt' }])
.mockReturnValueOnce(mockDbChain)
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456', connectorType: 'jira' }])
mockDbChain.returning.mockResolvedValueOnce([{ id: 'conn-456' }])
const req = createMockRequest('DELETE')

View File

@@ -292,7 +292,10 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
return NextResponse.json({ error: 'Connector not found' }, { status: 404 })
}
const connectorDocuments = await db.transaction(async (tx) => {
const { searchParams } = new URL(request.url)
const deleteDocuments = searchParams.get('deleteDocuments') === 'true'
const { deletedDocs, docCount } = await db.transaction(async (tx) => {
await tx.execute(sql`SELECT 1 FROM knowledge_connector WHERE id = ${connectorId} FOR UPDATE`)
const docs = await tx
@@ -306,10 +309,12 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
)
)
const documentIds = docs.map((doc) => doc.id)
if (documentIds.length > 0) {
await tx.delete(embedding).where(inArray(embedding.documentId, documentIds))
await tx.delete(document).where(inArray(document.id, documentIds))
if (deleteDocuments) {
const documentIds = docs.map((doc) => doc.id)
if (documentIds.length > 0) {
await tx.delete(embedding).where(inArray(embedding.documentId, documentIds))
await tx.delete(document).where(inArray(document.id, documentIds))
}
}
const deletedConnectors = await tx
@@ -328,16 +333,23 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
throw new Error('Connector not found')
}
return docs
return { deletedDocs: deleteDocuments ? docs : [], docCount: docs.length }
})
await deleteDocumentStorageFiles(connectorDocuments, requestId)
if (deleteDocuments) {
await Promise.all([
deletedDocs.length > 0
? deleteDocumentStorageFiles(deletedDocs, requestId)
: Promise.resolve(),
cleanupUnusedTagDefinitions(knowledgeBaseId, requestId).catch((error) => {
logger.warn(`[${requestId}] Failed to cleanup tag definitions`, error)
}),
])
}
await cleanupUnusedTagDefinitions(knowledgeBaseId, requestId).catch((error) => {
logger.warn(`[${requestId}] Failed to cleanup tag definitions`, error)
})
logger.info(`[${requestId}] Hard-deleted connector ${connectorId} and its documents`)
logger.info(
`[${requestId}] Deleted connector ${connectorId}${deleteDocuments ? ` and ${docCount} documents` : `, kept ${docCount} documents`}`
)
recordAudit({
workspaceId: writeCheck.knowledgeBase.workspaceId,
@@ -349,7 +361,11 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
resourceId: connectorId,
resourceName: existingConnector[0].connectorType,
description: `Deleted connector from knowledge base "${writeCheck.knowledgeBase.name}"`,
metadata: { knowledgeBaseId, documentsDeleted: connectorDocuments.length },
metadata: {
knowledgeBaseId,
documentsDeleted: deleteDocuments ? docCount : 0,
documentsKept: deleteDocuments ? 0 : docCount,
},
request,
})

View File

@@ -18,6 +18,7 @@ import { eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { validateOAuthAccessToken } from '@/lib/auth/oauth-token'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { createRunSegment } from '@/lib/copilot/async-runs/repository'
import { ORCHESTRATION_TIMEOUT_MS, SIM_AGENT_API_URL } from '@/lib/copilot/constants'
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
import { orchestrateSubagentStream } from '@/lib/copilot/orchestrator/subagent'
@@ -727,10 +728,25 @@ async function handleBuildToolCall(
chatId,
}
const executionId = crypto.randomUUID()
const runId = crypto.randomUUID()
const messageId = requestPayload.messageId as string
await createRunSegment({
id: runId,
executionId,
chatId,
userId,
workflowId: resolved.workflowId,
streamId: messageId,
}).catch(() => {})
const result = await orchestrateCopilotStream(requestPayload, {
userId,
workflowId: resolved.workflowId,
chatId,
executionId,
runId,
goRoute: '/api/mcp',
autoExecuteTools: true,
timeout: ORCHESTRATION_TIMEOUT_MS,

View File

@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createRunSegment } from '@/lib/copilot/async-runs/repository'
import { buildIntegrationToolSchemas } from '@/lib/copilot/chat-payload'
import { appendCopilotLogContext } from '@/lib/copilot/logging'
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
@@ -71,10 +72,24 @@ export async function POST(req: NextRequest) {
...(userPermission ? { userPermission } : {}),
}
const executionId = crypto.randomUUID()
const runId = crypto.randomUUID()
await createRunSegment({
id: runId,
executionId,
chatId: effectiveChatId,
userId,
workspaceId,
streamId: messageId,
}).catch(() => {})
const result = await orchestrateCopilotStream(requestPayload, {
userId,
workspaceId,
chatId: effectiveChatId,
executionId,
runId,
goRoute: '/api/mothership/execute',
autoExecuteTools: true,
interactive: false,

View File

@@ -61,6 +61,21 @@ export async function GET(
return NextResponse.json({ error: 'Invitation not found' }, { status: 404 })
}
// Verify caller is either an org member or the invitee
const isInvitee = session.user.email?.toLowerCase() === orgInvitation.email.toLowerCase()
if (!isInvitee) {
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' }, { status: 403 })
}
}
const org = await db
.select()
.from(organization)

View File

@@ -9,10 +9,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockVerifyCronAuth,
mockExecuteScheduleJob,
mockExecuteJobInline,
mockFeatureFlags,
mockDbReturning,
mockDbUpdate,
mockEnqueue,
mockEnqueueWorkspaceDispatch,
mockStartJob,
mockCompleteJob,
mockMarkJobFailed,
@@ -22,6 +24,7 @@ const {
const mockDbSet = vi.fn().mockReturnValue({ where: mockDbWhere })
const mockDbUpdate = vi.fn().mockReturnValue({ set: mockDbSet })
const mockEnqueue = vi.fn().mockResolvedValue('job-id-1')
const mockEnqueueWorkspaceDispatch = vi.fn().mockResolvedValue('job-id-1')
const mockStartJob = vi.fn().mockResolvedValue(undefined)
const mockCompleteJob = vi.fn().mockResolvedValue(undefined)
const mockMarkJobFailed = vi.fn().mockResolvedValue(undefined)
@@ -29,6 +32,7 @@ const {
return {
mockVerifyCronAuth: vi.fn().mockReturnValue(null),
mockExecuteScheduleJob: vi.fn().mockResolvedValue(undefined),
mockExecuteJobInline: vi.fn().mockResolvedValue(undefined),
mockFeatureFlags: {
isTriggerDevEnabled: false,
isHosted: false,
@@ -38,6 +42,7 @@ const {
mockDbReturning,
mockDbUpdate,
mockEnqueue,
mockEnqueueWorkspaceDispatch,
mockStartJob,
mockCompleteJob,
mockMarkJobFailed,
@@ -50,6 +55,8 @@ vi.mock('@/lib/auth/internal', () => ({
vi.mock('@/background/schedule-execution', () => ({
executeScheduleJob: mockExecuteScheduleJob,
executeJobInline: mockExecuteJobInline,
releaseScheduleLock: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/lib/core/config/feature-flags', () => mockFeatureFlags)
@@ -68,6 +75,22 @@ vi.mock('@/lib/core/async-jobs', () => ({
shouldExecuteInline: vi.fn().mockReturnValue(false),
}))
vi.mock('@/lib/core/bullmq', () => ({
isBullMQEnabled: vi.fn().mockReturnValue(true),
createBullMQJobData: vi.fn((payload: unknown) => ({ payload })),
}))
vi.mock('@/lib/core/workspace-dispatch', () => ({
enqueueWorkspaceDispatch: mockEnqueueWorkspaceDispatch,
}))
vi.mock('@/lib/workflows/utils', () => ({
getWorkflowById: vi.fn().mockResolvedValue({
id: 'workflow-1',
workspaceId: 'workspace-1',
}),
}))
vi.mock('drizzle-orm', () => ({
and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })),
eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
@@ -142,6 +165,18 @@ const MULTIPLE_SCHEDULES = [
},
]
const SINGLE_JOB = [
{
id: 'job-1',
cronExpression: '0 * * * *',
failedCount: 0,
lastQueuedAt: undefined,
sourceUserId: 'user-1',
sourceWorkspaceId: 'workspace-1',
sourceType: 'job',
},
]
function createMockRequest(): NextRequest {
const mockHeaders = new Map([
['authorization', 'Bearer test-cron-secret'],
@@ -211,30 +246,44 @@ describe('Scheduled Workflow Execution API Route', () => {
expect(data).toHaveProperty('executedCount', 2)
})
it('should queue mothership jobs to BullMQ when available', async () => {
mockDbReturning.mockReturnValueOnce([]).mockReturnValueOnce(SINGLE_JOB)
const response = await GET(createMockRequest())
expect(response.status).toBe(200)
expect(mockEnqueueWorkspaceDispatch).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: 'workspace-1',
lane: 'runtime',
queueName: 'mothership-job-execution',
bullmqJobName: 'mothership-job-execution',
bullmqPayload: {
payload: {
scheduleId: 'job-1',
cronExpression: '0 * * * *',
failedCount: 0,
now: expect.any(String),
},
},
})
)
expect(mockExecuteJobInline).not.toHaveBeenCalled()
})
it('should enqueue preassigned correlation metadata for schedules', async () => {
mockDbReturning.mockReturnValue(SINGLE_SCHEDULE)
const response = await GET(createMockRequest())
expect(response.status).toBe(200)
expect(mockEnqueue).toHaveBeenCalledWith(
'schedule-execution',
expect(mockEnqueueWorkspaceDispatch).toHaveBeenCalledWith(
expect.objectContaining({
scheduleId: 'schedule-1',
workflowId: 'workflow-1',
executionId: 'schedule-execution-1',
requestId: 'test-request-id',
correlation: {
executionId: 'schedule-execution-1',
requestId: 'test-request-id',
source: 'schedule',
workflowId: 'workflow-1',
scheduleId: 'schedule-1',
triggerType: 'schedule',
scheduledFor: '2025-01-01T00:00:00.000Z',
},
}),
{
id: 'schedule-execution-1',
workspaceId: 'workspace-1',
lane: 'runtime',
queueName: 'schedule-execution',
bullmqJobName: 'schedule-execution',
metadata: {
workflowId: 'workflow-1',
correlation: {
@@ -247,7 +296,7 @@ describe('Scheduled Workflow Execution API Route', () => {
scheduledFor: '2025-01-01T00:00:00.000Z',
},
},
}
})
)
})
})

View File

@@ -5,7 +5,9 @@ import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { verifyCronAuth } from '@/lib/auth/internal'
import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs'
import { createBullMQJobData, isBullMQEnabled } from '@/lib/core/bullmq'
import { generateRequestId } from '@/lib/core/utils/request'
import { enqueueWorkspaceDispatch } from '@/lib/core/workspace-dispatch'
import {
executeJobInline,
executeScheduleJob,
@@ -73,6 +75,8 @@ export async function GET(request: NextRequest) {
cronExpression: workflowSchedule.cronExpression,
failedCount: workflowSchedule.failedCount,
lastQueuedAt: workflowSchedule.lastQueuedAt,
sourceWorkspaceId: workflowSchedule.sourceWorkspaceId,
sourceUserId: workflowSchedule.sourceUserId,
sourceType: workflowSchedule.sourceType,
})
@@ -111,9 +115,40 @@ export async function GET(request: NextRequest) {
}
try {
const jobId = await jobQueue.enqueue('schedule-execution', payload, {
metadata: { workflowId: schedule.workflowId ?? undefined, correlation },
})
const { getWorkflowById } = await import('@/lib/workflows/utils')
const resolvedWorkflow = schedule.workflowId
? await getWorkflowById(schedule.workflowId)
: null
const resolvedWorkspaceId = resolvedWorkflow?.workspaceId
let jobId: string
if (isBullMQEnabled()) {
if (!resolvedWorkspaceId) {
throw new Error(
`Missing workspace for scheduled workflow ${schedule.workflowId}; refusing to bypass workspace admission`
)
}
jobId = await enqueueWorkspaceDispatch({
id: executionId,
workspaceId: resolvedWorkspaceId,
lane: 'runtime',
queueName: 'schedule-execution',
bullmqJobName: 'schedule-execution',
bullmqPayload: createBullMQJobData(payload, {
workflowId: schedule.workflowId ?? undefined,
correlation,
}),
metadata: {
workflowId: schedule.workflowId ?? undefined,
correlation,
},
})
} else {
jobId = await jobQueue.enqueue('schedule-execution', payload, {
metadata: { workflowId: schedule.workflowId ?? undefined, correlation },
})
}
logger.info(
`[${requestId}] Queued schedule execution task ${jobId} for workflow ${schedule.workflowId}`
)
@@ -165,7 +200,7 @@ export async function GET(request: NextRequest) {
}
})
// Jobs always execute inline (no TriggerDev)
// Mothership jobs use BullMQ when available, otherwise direct inline execution.
const jobPromises = dueJobs.map(async (job) => {
const queueTime = job.lastQueuedAt ?? queuedAt
const payload = {
@@ -176,7 +211,24 @@ export async function GET(request: NextRequest) {
}
try {
await executeJobInline(payload)
if (isBullMQEnabled()) {
if (!job.sourceWorkspaceId || !job.sourceUserId) {
throw new Error(`Mothership job ${job.id} is missing workspace/user ownership`)
}
await enqueueWorkspaceDispatch({
workspaceId: job.sourceWorkspaceId!,
lane: 'runtime',
queueName: 'mothership-job-execution',
bullmqJobName: 'mothership-job-execution',
bullmqPayload: createBullMQJobData(payload),
metadata: {
userId: job.sourceUserId,
},
})
} else {
await executeJobInline(payload)
}
} catch (error) {
logger.error(`[${requestId}] Job execution failed for ${job.id}`, {
error: error instanceof Error ? error.message : String(error),

View File

@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
import { ImapFlow } from 'imapflow'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
const logger = createLogger('ImapMailboxesAPI')
@@ -9,7 +10,6 @@ interface ImapMailboxRequest {
host: string
port: number
secure: boolean
rejectUnauthorized: boolean
username: string
password: string
}
@@ -22,7 +22,7 @@ export async function POST(request: NextRequest) {
try {
const body = (await request.json()) as ImapMailboxRequest
const { host, port, secure, rejectUnauthorized, username, password } = body
const { host, port, secure, username, password } = body
if (!host || !username || !password) {
return NextResponse.json(
@@ -31,8 +31,14 @@ export async function POST(request: NextRequest) {
)
}
const hostValidation = await validateDatabaseHost(host, 'host')
if (!hostValidation.isValid) {
return NextResponse.json({ success: false, message: hostValidation.error }, { status: 400 })
}
const client = new ImapFlow({
host,
host: hostValidation.resolvedIP!,
servername: host,
port: port || 993,
secure: secure ?? true,
auth: {
@@ -40,7 +46,7 @@ export async function POST(request: NextRequest) {
pass: password,
},
tls: {
rejectUnauthorized: rejectUnauthorized ?? true,
rejectUnauthorized: true,
},
logger: false,
})
@@ -79,21 +85,12 @@ export async function POST(request: NextRequest) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
logger.error('Error fetching IMAP mailboxes:', errorMessage)
let userMessage = 'Failed to connect to IMAP server'
let userMessage = 'Failed to connect to IMAP server. Please check your connection settings.'
if (
errorMessage.includes('AUTHENTICATIONFAILED') ||
errorMessage.includes('Invalid credentials')
) {
userMessage = 'Invalid username or password. For Gmail, use an App Password.'
} else if (errorMessage.includes('ENOTFOUND') || errorMessage.includes('getaddrinfo')) {
userMessage = 'Could not find IMAP server. Please check the hostname.'
} else if (errorMessage.includes('ECONNREFUSED')) {
userMessage = 'Connection refused. Please check the port and SSL settings.'
} else if (errorMessage.includes('certificate') || errorMessage.includes('SSL')) {
userMessage =
'TLS/SSL error. Try disabling "Verify TLS Certificate" for self-signed certificates.'
} else if (errorMessage.includes('timeout')) {
userMessage = 'Connection timed out. Please check your network and server settings.'
}
return NextResponse.json({ success: false, message: userMessage }, { status: 500 })

View File

@@ -1,4 +1,5 @@
import { type Attributes, Client, type ConnectConfig, type SFTPWrapper } from 'ssh2'
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
const S_IFMT = 0o170000
const S_IFDIR = 0o040000
@@ -91,16 +92,23 @@ function formatSftpError(err: Error, config: { host: string; port: number }): Er
* Creates an SSH connection for SFTP using the provided configuration.
* Uses ssh2 library defaults which align with OpenSSH standards.
*/
export function createSftpConnection(config: SftpConnectionConfig): Promise<Client> {
export async function createSftpConnection(config: SftpConnectionConfig): Promise<Client> {
const host = config.host
if (!host || host.trim() === '') {
throw new Error('Host is required. Please provide a valid hostname or IP address.')
}
const hostValidation = await validateDatabaseHost(host, 'host')
if (!hostValidation.isValid) {
throw new Error(hostValidation.error)
}
const resolvedHost = hostValidation.resolvedIP ?? host.trim()
return new Promise((resolve, reject) => {
const client = new Client()
const port = config.port || 22
const host = config.host
if (!host || host.trim() === '') {
reject(new Error('Host is required. Please provide a valid hostname or IP address.'))
return
}
const hasPassword = config.password && config.password.trim() !== ''
const hasPrivateKey = config.privateKey && config.privateKey.trim() !== ''
@@ -111,7 +119,7 @@ export function createSftpConnection(config: SftpConnectionConfig): Promise<Clie
}
const connectConfig: ConnectConfig = {
host: host.trim(),
host: resolvedHost,
port,
username: config.username,
}

View File

@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import nodemailer from 'nodemailer'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
import { generateRequestId } from '@/lib/core/utils/request'
import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
@@ -56,6 +57,15 @@ export async function POST(request: NextRequest) {
const body = await request.json()
const validatedData = SmtpSendSchema.parse(body)
const hostValidation = await validateDatabaseHost(validatedData.smtpHost, 'smtpHost')
if (!hostValidation.isValid) {
logger.warn(`[${requestId}] SMTP host validation failed`, {
host: validatedData.smtpHost,
error: hostValidation.error,
})
return NextResponse.json({ success: false, error: hostValidation.error }, { status: 400 })
}
logger.info(`[${requestId}] Sending email via SMTP`, {
host: validatedData.smtpHost,
port: validatedData.smtpPort,
@@ -64,8 +74,13 @@ export async function POST(request: NextRequest) {
secure: validatedData.smtpSecure,
})
// Pin the pre-resolved IP to prevent DNS rebinding (TOCTOU) attacks.
// Pass resolvedIP as the host so nodemailer connects to the validated address,
// and set servername for correct TLS SNI/certificate validation.
const pinnedHost = hostValidation.resolvedIP ?? validatedData.smtpHost
const transporter = nodemailer.createTransport({
host: validatedData.smtpHost,
host: pinnedHost,
port: validatedData.smtpPort,
secure: validatedData.smtpSecure === 'SSL',
auth: {
@@ -74,12 +89,8 @@ export async function POST(request: NextRequest) {
},
tls:
validatedData.smtpSecure === 'None'
? {
rejectUnauthorized: false,
}
: {
rejectUnauthorized: true,
},
? { rejectUnauthorized: false, servername: validatedData.smtpHost }
: { rejectUnauthorized: true, servername: validatedData.smtpHost },
})
const contentType = validatedData.contentType || 'text'
@@ -189,16 +200,16 @@ export async function POST(request: NextRequest) {
if (isNodeError(error)) {
if (error.code === 'EAUTH') {
errorMessage = 'SMTP authentication failed - check username and password'
} else if (error.code === 'ECONNECTION' || error.code === 'ECONNREFUSED') {
} else if (
error.code === 'ECONNECTION' ||
error.code === 'ECONNREFUSED' ||
error.code === 'ECONNRESET' ||
error.code === 'ETIMEDOUT'
) {
errorMessage = 'Could not connect to SMTP server - check host and port'
} else if (error.code === 'ECONNRESET') {
errorMessage = 'Connection was reset by SMTP server'
} else if (error.code === 'ETIMEDOUT') {
errorMessage = 'SMTP server connection timeout'
}
}
// Check for SMTP response codes
const hasResponseCode = (err: unknown): err is { responseCode: number } => {
return typeof err === 'object' && err !== null && 'responseCode' in err
}

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { type Attributes, Client, type ConnectConfig } from 'ssh2'
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
const logger = createLogger('SSHUtils')
@@ -108,16 +109,23 @@ function formatSSHError(err: Error, config: { host: string; port: number }): Err
* - keepaliveInterval: 0 (disabled, same as OpenSSH ServerAliveInterval)
* - keepaliveCountMax: 3 (same as OpenSSH ServerAliveCountMax)
*/
export function createSSHConnection(config: SSHConnectionConfig): Promise<Client> {
export async function createSSHConnection(config: SSHConnectionConfig): Promise<Client> {
const host = config.host
if (!host || host.trim() === '') {
throw new Error('Host is required. Please provide a valid hostname or IP address.')
}
const hostValidation = await validateDatabaseHost(host, 'host')
if (!hostValidation.isValid) {
throw new Error(hostValidation.error)
}
const resolvedHost = hostValidation.resolvedIP ?? host.trim()
return new Promise((resolve, reject) => {
const client = new Client()
const port = config.port || 22
const host = config.host
if (!host || host.trim() === '') {
reject(new Error('Host is required. Please provide a valid hostname or IP address.'))
return
}
const hasPassword = config.password && config.password.trim() !== ''
const hasPrivateKey = config.privateKey && config.privateKey.trim() !== ''
@@ -128,7 +136,7 @@ export function createSSHConnection(config: SSHConnectionConfig): Promise<Client
}
const connectConfig: ConnectConfig = {
host: host.trim(),
host: resolvedHost,
port,
username: config.username,
}

View File

@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createRunSegment } from '@/lib/copilot/async-runs/repository'
import { appendCopilotLogContext } from '@/lib/copilot/logging'
import { COPILOT_REQUEST_MODES } from '@/lib/copilot/models'
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
@@ -104,10 +105,24 @@ export async function POST(req: NextRequest) {
chatId,
}
const executionId = crypto.randomUUID()
const runId = crypto.randomUUID()
await createRunSegment({
id: runId,
executionId,
chatId,
userId: auth.userId,
workflowId: resolved.workflowId,
streamId: messageId,
}).catch(() => {})
const result = await orchestrateCopilotStream(requestPayload, {
userId: auth.userId,
workflowId: resolved.workflowId,
chatId,
executionId,
runId,
goRoute: '/api/mcp',
autoExecuteTools: parsed.autoExecuteTools,
timeout: parsed.timeout,

View File

@@ -1,6 +1,8 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate'
import { generateRequestId } from '@/lib/core/utils/request'
import { DispatchQueueFullError } from '@/lib/core/workspace-dispatch'
import {
checkWebhookPreprocessing,
findAllWebhooksForPath,
@@ -41,10 +43,25 @@ export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path: string }> }
) {
const ticket = tryAdmit()
if (!ticket) {
return admissionRejectedResponse()
}
try {
return await handleWebhookPost(request, params)
} finally {
ticket.release()
}
}
async function handleWebhookPost(
request: NextRequest,
params: Promise<{ path: string }>
): Promise<NextResponse> {
const requestId = generateRequestId()
const { path } = await params
// Handle provider challenges before body parsing (Microsoft Graph validationToken, etc.)
const earlyChallenge = await handleProviderChallenges({}, request, requestId, path)
if (earlyChallenge) {
return earlyChallenge
@@ -140,17 +157,30 @@ export async function POST(
continue
}
const response = await queueWebhookExecution(foundWebhook, foundWorkflow, body, request, {
requestId,
path,
actorUserId: preprocessResult.actorUserId,
executionId: preprocessResult.executionId,
correlation: preprocessResult.correlation,
})
responses.push(response)
try {
const response = await queueWebhookExecution(foundWebhook, foundWorkflow, body, request, {
requestId,
path,
actorUserId: preprocessResult.actorUserId,
executionId: preprocessResult.executionId,
correlation: preprocessResult.correlation,
})
responses.push(response)
} catch (error) {
if (error instanceof DispatchQueueFullError) {
return NextResponse.json(
{
error: 'Service temporarily at capacity',
message: error.message,
retryAfterSeconds: 10,
},
{ status: 503, headers: { 'Retry-After': '10' } }
)
}
throw error
}
}
// Return the last successful response, or a combined response for multiple webhooks
if (responses.length === 0) {
return new NextResponse('No webhooks processed successfully', { status: 500 })
}

View File

@@ -10,15 +10,18 @@ const {
mockAuthorizeWorkflowByWorkspacePermission,
mockPreprocessExecution,
mockEnqueue,
mockEnqueueWorkspaceDispatch,
} = vi.hoisted(() => ({
mockCheckHybridAuth: vi.fn(),
mockAuthorizeWorkflowByWorkspacePermission: vi.fn(),
mockPreprocessExecution: vi.fn(),
mockEnqueue: vi.fn().mockResolvedValue('job-123'),
mockEnqueueWorkspaceDispatch: vi.fn().mockResolvedValue('job-123'),
}))
vi.mock('@/lib/auth/hybrid', () => ({
checkHybridAuth: mockCheckHybridAuth,
hasExternalApiCredentials: vi.fn().mockReturnValue(true),
AuthType: {
SESSION: 'session',
API_KEY: 'api_key',
@@ -44,6 +47,16 @@ vi.mock('@/lib/core/async-jobs', () => ({
markJobFailed: vi.fn(),
}),
shouldExecuteInline: vi.fn().mockReturnValue(false),
shouldUseBullMQ: vi.fn().mockReturnValue(true),
}))
vi.mock('@/lib/core/bullmq', () => ({
createBullMQJobData: vi.fn((payload: unknown, metadata?: unknown) => ({ payload, metadata })),
}))
vi.mock('@/lib/core/workspace-dispatch', () => ({
enqueueWorkspaceDispatch: mockEnqueueWorkspaceDispatch,
waitForDispatchJob: vi.fn(),
}))
vi.mock('@/lib/core/utils/request', () => ({
@@ -132,22 +145,13 @@ describe('workflow execute async route', () => {
expect(response.status).toBe(202)
expect(body.executionId).toBe('execution-123')
expect(body.jobId).toBe('job-123')
expect(mockEnqueue).toHaveBeenCalledWith(
'workflow-execution',
expect(mockEnqueueWorkspaceDispatch).toHaveBeenCalledWith(
expect.objectContaining({
workflowId: 'workflow-1',
userId: 'actor-1',
executionId: 'execution-123',
requestId: 'req-12345678',
correlation: {
executionId: 'execution-123',
requestId: 'req-12345678',
source: 'workflow',
workflowId: 'workflow-1',
triggerType: 'manual',
},
}),
{
id: 'execution-123',
workspaceId: 'workspace-1',
lane: 'runtime',
queueName: 'workflow-execution',
bullmqJobName: 'workflow-execution',
metadata: {
workflowId: 'workflow-1',
userId: 'actor-1',
@@ -159,7 +163,7 @@ describe('workflow execute async route', () => {
triggerType: 'manual',
},
},
}
})
)
})
})

View File

@@ -2,8 +2,10 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { validate as uuidValidate, v4 as uuidv4 } from 'uuid'
import { z } from 'zod'
import { AuthType, checkHybridAuth } from '@/lib/auth/hybrid'
import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs'
import { AuthType, checkHybridAuth, hasExternalApiCredentials } from '@/lib/auth/hybrid'
import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate'
import { getJobQueue, shouldExecuteInline, shouldUseBullMQ } from '@/lib/core/async-jobs'
import { createBullMQJobData } from '@/lib/core/bullmq'
import {
createTimeoutAbortController,
getTimeoutErrorMessage,
@@ -12,6 +14,13 @@ import {
import { generateRequestId } from '@/lib/core/utils/request'
import { SSE_HEADERS } from '@/lib/core/utils/sse'
import { getBaseUrl } from '@/lib/core/utils/urls'
import {
DispatchQueueFullError,
enqueueWorkspaceDispatch,
type WorkspaceDispatchLane,
waitForDispatchJob,
} from '@/lib/core/workspace-dispatch'
import { createBufferedExecutionStream } from '@/lib/execution/buffered-stream'
import {
buildNextCallChain,
parseCallChain,
@@ -33,6 +42,11 @@ import {
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
import { type ExecutionEvent, encodeSSEEvent } from '@/lib/workflows/executor/execution-events'
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
import {
DIRECT_WORKFLOW_JOB_NAME,
type QueuedWorkflowExecutionPayload,
type QueuedWorkflowExecutionResult,
} from '@/lib/workflows/executor/queued-workflow-execution'
import {
loadDeployedWorkflowState,
loadWorkflowFromNormalizedTables,
@@ -104,6 +118,8 @@ const ExecuteWorkflowSchema = z.object({
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
const INLINE_TRIGGER_TYPES = new Set<CoreTriggerType>(['manual', 'workflow'])
function resolveOutputIds(
selectedOutputs: string[] | undefined,
blocks: Record<string, any>
@@ -161,6 +177,7 @@ type AsyncExecutionParams = {
requestId: string
workflowId: string
userId: string
workspaceId: string
input: any
triggerType: CoreTriggerType
executionId: string
@@ -168,7 +185,8 @@ type AsyncExecutionParams = {
}
async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextResponse> {
const { requestId, workflowId, userId, input, triggerType, executionId, callChain } = params
const { requestId, workflowId, userId, workspaceId, input, triggerType, executionId, callChain } =
params
const correlation = {
executionId,
@@ -181,6 +199,7 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextR
const payload: WorkflowExecutionPayload = {
workflowId,
userId,
workspaceId,
input,
triggerType,
executionId,
@@ -190,22 +209,42 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextR
}
try {
const jobQueue = await getJobQueue()
const jobId = await jobQueue.enqueue('workflow-execution', payload, {
metadata: { workflowId, userId, correlation },
})
const useBullMQ = shouldUseBullMQ()
const jobQueue = useBullMQ ? null : await getJobQueue()
const jobId = useBullMQ
? await enqueueWorkspaceDispatch({
id: executionId,
workspaceId,
lane: 'runtime',
queueName: 'workflow-execution',
bullmqJobName: 'workflow-execution',
bullmqPayload: createBullMQJobData(payload, {
workflowId,
userId,
correlation,
}),
metadata: {
workflowId,
userId,
correlation,
},
})
: await jobQueue!.enqueue('workflow-execution', payload, {
metadata: { workflowId, userId, correlation },
})
logger.info(`[${requestId}] Queued async workflow execution`, {
workflowId,
jobId,
})
if (shouldExecuteInline()) {
if (shouldExecuteInline() && jobQueue) {
const inlineJobQueue = jobQueue
void (async () => {
try {
await jobQueue.startJob(jobId)
await inlineJobQueue.startJob(jobId)
const output = await executeWorkflowJob(payload)
await jobQueue.completeJob(jobId, output)
await inlineJobQueue.completeJob(jobId, output)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
logger.error(`[${requestId}] Async workflow execution failed`, {
@@ -213,7 +252,7 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextR
error: errorMessage,
})
try {
await jobQueue.markJobFailed(jobId, errorMessage)
await inlineJobQueue.markJobFailed(jobId, errorMessage)
} catch (markFailedError) {
logger.error(`[${requestId}] Failed to mark job as failed`, {
jobId,
@@ -239,6 +278,17 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextR
{ status: 202 }
)
} catch (error: any) {
if (error instanceof DispatchQueueFullError) {
return NextResponse.json(
{
error: 'Service temporarily at capacity',
message: error.message,
retryAfterSeconds: 10,
},
{ status: 503, headers: { 'Retry-After': '10' } }
)
}
logger.error(`[${requestId}] Failed to queue async execution`, error)
return NextResponse.json(
{ error: `Failed to queue async execution: ${error.message}` },
@@ -247,6 +297,31 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextR
}
}
async function enqueueDirectWorkflowExecution(
payload: QueuedWorkflowExecutionPayload,
priority: number,
lane: WorkspaceDispatchLane
) {
return enqueueWorkspaceDispatch({
id: payload.metadata.executionId,
workspaceId: payload.metadata.workspaceId,
lane,
queueName: 'workflow-execution',
bullmqJobName: DIRECT_WORKFLOW_JOB_NAME,
bullmqPayload: createBullMQJobData(payload, {
workflowId: payload.metadata.workflowId,
userId: payload.metadata.userId,
correlation: payload.metadata.correlation,
}),
metadata: {
workflowId: payload.metadata.workflowId,
userId: payload.metadata.userId,
correlation: payload.metadata.correlation,
},
priority,
})
}
/**
* POST /api/workflows/[id]/execute
*
@@ -254,6 +329,27 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextR
* Supports both SSE streaming (for interactive/manual runs) and direct JSON responses (for background jobs).
*/
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const isSessionRequest = req.headers.has('cookie') && !hasExternalApiCredentials(req.headers)
if (isSessionRequest) {
return handleExecutePost(req, params)
}
const ticket = tryAdmit()
if (!ticket) {
return admissionRejectedResponse()
}
try {
return await handleExecutePost(req, params)
} finally {
ticket.release()
}
}
async function handleExecutePost(
req: NextRequest,
params: Promise<{ id: string }>
): Promise<NextResponse | Response> {
const requestId = generateRequestId()
const { id: workflowId } = await params
@@ -584,6 +680,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
requestId,
workflowId,
userId: actorUserId,
workspaceId,
input,
triggerType: loggingTriggerType,
executionId,
@@ -676,30 +773,116 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
if (!enableSSE) {
logger.info(`[${requestId}] Using non-SSE execution (direct JSON response)`)
const metadata: ExecutionMetadata = {
requestId,
executionId,
workflowId,
workspaceId,
userId: actorUserId,
sessionUserId: isClientSession ? userId : undefined,
workflowUserId: workflow.userId,
triggerType,
useDraftState: shouldUseDraftState,
startTime: new Date().toISOString(),
isClientSession,
enforceCredentialAccess: useAuthenticatedUserAsActor,
workflowStateOverride: effectiveWorkflowStateOverride,
callChain,
}
const executionVariables = cachedWorkflowData?.variables ?? workflow.variables ?? {}
if (shouldUseBullMQ() && !INLINE_TRIGGER_TYPES.has(triggerType)) {
try {
const dispatchJobId = await enqueueDirectWorkflowExecution(
{
workflow,
metadata,
input: processedInput,
variables: executionVariables,
selectedOutputs,
includeFileBase64,
base64MaxBytes,
stopAfterBlockId,
timeoutMs: preprocessResult.executionTimeout?.sync,
runFromBlock: resolvedRunFromBlock,
},
5,
'interactive'
)
const resultRecord = await waitForDispatchJob(
dispatchJobId,
(preprocessResult.executionTimeout?.sync ?? 300000) + 30000
)
if (resultRecord.status === 'failed') {
return NextResponse.json(
{
success: false,
executionId,
error: resultRecord.error ?? 'Workflow execution failed',
},
{ status: 500 }
)
}
const result = resultRecord.output as QueuedWorkflowExecutionResult
const resultForResponseBlock = {
success: result.success,
logs: result.logs,
output: result.output,
}
if (
auth.authType !== AuthType.INTERNAL_JWT &&
workflowHasResponseBlock(resultForResponseBlock)
) {
return createHttpResponseFromBlock(resultForResponseBlock)
}
return NextResponse.json(
{
success: result.success,
executionId,
output: result.output,
error: result.error,
metadata: result.metadata,
},
{ status: result.statusCode ?? 200 }
)
} catch (error: unknown) {
if (error instanceof DispatchQueueFullError) {
return NextResponse.json(
{
error: 'Service temporarily at capacity',
message: error.message,
retryAfterSeconds: 10,
},
{ status: 503, headers: { 'Retry-After': '10' } }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
logger.error(`[${requestId}] Queued non-SSE execution failed: ${errorMessage}`)
return NextResponse.json(
{
success: false,
error: errorMessage,
},
{ status: 500 }
)
}
}
const timeoutController = createTimeoutAbortController(
preprocessResult.executionTimeout?.sync
)
try {
const metadata: ExecutionMetadata = {
requestId,
executionId,
workflowId,
workspaceId,
userId: actorUserId,
sessionUserId: isClientSession ? userId : undefined,
workflowUserId: workflow.userId,
triggerType,
useDraftState: shouldUseDraftState,
startTime: new Date().toISOString(),
isClientSession,
enforceCredentialAccess: useAuthenticatedUserAsActor,
workflowStateOverride: effectiveWorkflowStateOverride,
callChain,
}
const executionVariables = cachedWorkflowData?.variables ?? workflow.variables ?? {}
const snapshot = new ExecutionSnapshot(
metadata,
workflow,
@@ -809,6 +992,53 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
}
if (shouldUseDraftState) {
const shouldDispatchViaQueue = shouldUseBullMQ() && !INLINE_TRIGGER_TYPES.has(triggerType)
if (shouldDispatchViaQueue) {
const metadata: ExecutionMetadata = {
requestId,
executionId,
workflowId,
workspaceId,
userId: actorUserId,
sessionUserId: isClientSession ? userId : undefined,
workflowUserId: workflow.userId,
triggerType,
useDraftState: shouldUseDraftState,
startTime: new Date().toISOString(),
isClientSession,
enforceCredentialAccess: useAuthenticatedUserAsActor,
workflowStateOverride: effectiveWorkflowStateOverride,
callChain,
}
const executionVariables = cachedWorkflowData?.variables ?? workflow.variables ?? {}
await enqueueDirectWorkflowExecution(
{
workflow,
metadata,
input: processedInput,
variables: executionVariables,
selectedOutputs,
includeFileBase64,
base64MaxBytes,
stopAfterBlockId,
timeoutMs: preprocessResult.executionTimeout?.sync,
runFromBlock: resolvedRunFromBlock,
streamEvents: true,
},
1,
'interactive'
)
return new NextResponse(createBufferedExecutionStream(executionId), {
headers: {
...SSE_HEADERS,
'X-Execution-Id': executionId,
},
})
}
logger.info(`[${requestId}] Using SSE console log streaming (manual execution)`)
} else {
logger.info(`[${requestId}] Using streaming API response`)
@@ -1277,6 +1507,17 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
},
})
} catch (error: any) {
if (error instanceof DispatchQueueFullError) {
return NextResponse.json(
{
error: 'Service temporarily at capacity',
message: error.message,
retryAfterSeconds: 10,
},
{ status: 503, headers: { 'Retry-After': '10' } }
)
}
logger.error(`[${requestId}] Failed to start workflow execution:`, error)
return NextResponse.json(
{ error: error.message || 'Failed to start workflow execution' },

View File

@@ -6,7 +6,6 @@ import {
updateApiKeyLastUsed,
} from '@/lib/api-key/service'
import { type AuthResult, checkHybridAuth } from '@/lib/auth/hybrid'
import { env } from '@/lib/core/config/env'
import { authorizeWorkflowByWorkspacePermission, getWorkflowById } from '@/lib/workflows/utils'
const logger = createLogger('WorkflowMiddleware')
@@ -81,11 +80,6 @@ export async function validateWorkflowAccess(
}
}
const internalSecret = request.headers.get('X-Internal-Secret')
if (env.INTERNAL_API_SECRET && internalSecret === env.INTERNAL_API_SECRET) {
return { workflow }
}
let apiKeyHeader = null
for (const [key, value] of request.headers.entries()) {
if (key.toLowerCase() === 'x-api-key' && value) {

View File

@@ -8,7 +8,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { getNextWorkflowColor } from '@/lib/workflows/colors'
import { listWorkflows, type WorkflowScope } from '@/lib/workflows/utils'
import { deduplicateWorkflowName, listWorkflows, type WorkflowScope } from '@/lib/workflows/utils'
import { getUserEntityPermissions, workspaceExists } from '@/lib/workspaces/permissions/utils'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
@@ -25,6 +25,7 @@ const CreateWorkflowSchema = z.object({
workspaceId: z.string().optional(),
folderId: z.string().nullable().optional(),
sortOrder: z.number().int().optional(),
deduplicate: z.boolean().optional(),
})
// GET /api/workflows - Get workflows for user (optionally filtered by workspaceId)
@@ -126,12 +127,13 @@ export async function POST(req: NextRequest) {
const body = await req.json()
const {
id: clientId,
name,
name: requestedName,
description,
color,
workspaceId,
folderId,
sortOrder: providedSortOrder,
deduplicate,
} = CreateWorkflowSchema.parse(body)
if (!workspaceId) {
@@ -162,19 +164,6 @@ export async function POST(req: NextRequest) {
logger.info(`[${requestId}] Creating workflow ${workflowId} for user ${userId}`)
import('@/lib/core/telemetry')
.then(({ PlatformEvents }) => {
PlatformEvents.workflowCreated({
workflowId,
name,
workspaceId: workspaceId || undefined,
folderId: folderId || undefined,
})
})
.catch(() => {
// Silently fail
})
let sortOrder: number
if (providedSortOrder !== undefined) {
sortOrder = providedSortOrder
@@ -214,30 +203,49 @@ export async function POST(req: NextRequest) {
sortOrder = minSortOrder != null ? minSortOrder - 1 : 0
}
const duplicateConditions = [
eq(workflow.workspaceId, workspaceId),
isNull(workflow.archivedAt),
eq(workflow.name, name),
]
let name = requestedName
if (folderId) {
duplicateConditions.push(eq(workflow.folderId, folderId))
if (deduplicate) {
name = await deduplicateWorkflowName(requestedName, workspaceId, folderId)
} else {
duplicateConditions.push(isNull(workflow.folderId))
const duplicateConditions = [
eq(workflow.workspaceId, workspaceId),
isNull(workflow.archivedAt),
eq(workflow.name, requestedName),
]
if (folderId) {
duplicateConditions.push(eq(workflow.folderId, folderId))
} else {
duplicateConditions.push(isNull(workflow.folderId))
}
const [duplicateWorkflow] = await db
.select({ id: workflow.id })
.from(workflow)
.where(and(...duplicateConditions))
.limit(1)
if (duplicateWorkflow) {
return NextResponse.json(
{ error: `A workflow named "${requestedName}" already exists in this folder` },
{ status: 409 }
)
}
}
const [duplicateWorkflow] = await db
.select({ id: workflow.id })
.from(workflow)
.where(and(...duplicateConditions))
.limit(1)
if (duplicateWorkflow) {
return NextResponse.json(
{ error: `A workflow named "${name}" already exists in this folder` },
{ status: 409 }
)
}
import('@/lib/core/telemetry')
.then(({ PlatformEvents }) => {
PlatformEvents.workflowCreated({
workflowId,
name,
workspaceId: workspaceId || undefined,
folderId: folderId || undefined,
})
})
.catch(() => {
// Silently fail
})
await db.insert(workflow).values({
id: workflowId,

View File

@@ -79,6 +79,22 @@ vi.mock('@/lib/core/utils/urls', () => ({
getBaseUrl: vi.fn().mockReturnValue('https://test.sim.ai'),
}))
vi.mock('@/components/emails', () => ({
WorkspaceInvitationEmail: vi.fn().mockReturnValue(null),
}))
vi.mock('@/lib/messaging/email/mailer', () => ({
sendEmail: vi.fn().mockResolvedValue({ success: true }),
}))
vi.mock('@/lib/messaging/email/utils', () => ({
getFromEmailAddress: vi.fn().mockReturnValue('noreply@test.com'),
}))
vi.mock('@react-email/render', () => ({
render: vi.fn().mockResolvedValue('<html></html>'),
}))
vi.mock('@sim/db', () => ({
db: {
select: () => mockDbSelect(),
@@ -171,9 +187,10 @@ describe('Workspace Invitation [invitationId] API Route', () => {
})
describe('GET /api/workspaces/invitations/[invitationId]', () => {
it('should return invitation details when called without token', async () => {
const session = createSession({ userId: mockUser.id, email: mockUser.email })
it('should return invitation details when caller is the invitee', async () => {
const session = createSession({ userId: mockUser.id, email: 'invited@example.com' })
mockGetSession.mockResolvedValue(session)
mockHasWorkspaceAdminAccess.mockResolvedValue(false)
dbSelectResults = [[mockInvitation], [mockWorkspace]]
const request = new NextRequest('http://localhost/api/workspaces/invitations/invitation-789')
@@ -191,6 +208,43 @@ describe('Workspace Invitation [invitationId] API Route', () => {
})
})
it('should return invitation details when caller is a workspace admin', async () => {
const session = createSession({ userId: mockUser.id, email: mockUser.email })
mockGetSession.mockResolvedValue(session)
mockHasWorkspaceAdminAccess.mockResolvedValue(true)
dbSelectResults = [[mockInvitation], [mockWorkspace]]
const request = new NextRequest('http://localhost/api/workspaces/invitations/invitation-789')
const params = Promise.resolve({ invitationId: 'invitation-789' })
const response = await GET(request, { params })
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toMatchObject({
id: 'invitation-789',
email: 'invited@example.com',
status: 'pending',
workspaceName: 'Test Workspace',
})
})
it('should return 403 when caller is neither invitee nor workspace admin', async () => {
const session = createSession({ userId: mockUser.id, email: 'unrelated@example.com' })
mockGetSession.mockResolvedValue(session)
mockHasWorkspaceAdminAccess.mockResolvedValue(false)
dbSelectResults = [[mockInvitation], [mockWorkspace]]
const request = new NextRequest('http://localhost/api/workspaces/invitations/invitation-789')
const params = Promise.resolve({ invitationId: 'invitation-789' })
const response = await GET(request, { params })
const data = await response.json()
expect(response.status).toBe(403)
expect(data).toEqual({ error: 'Insufficient permissions' })
})
it('should redirect to login when unauthenticated with token', async () => {
mockGetSession.mockResolvedValue(null)

View File

@@ -198,6 +198,15 @@ export async function GET(
)
}
const isInvitee = session.user.email?.toLowerCase() === invitation.email.toLowerCase()
if (!isInvitee) {
const hasAdminAccess = await hasWorkspaceAdminAccess(session.user.id, invitation.workspaceId)
if (!hasAdminAccess) {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
}
return NextResponse.json({
...invitation,
workspaceName: workspaceDetails.name,

View File

@@ -8,7 +8,9 @@ const baseUrl = getBaseUrl()
export const metadata: Metadata = {
metadataBase: new URL(baseUrl),
title: 'Sim — Build AI Agents & Run Your Agentic Workforce',
title: {
absolute: 'Sim — Build AI Agents & Run Your Agentic Workforce',
},
description:
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to orchestrate agentic workflows.',
keywords:

View File

@@ -16,7 +16,6 @@ const Joyride = dynamic(() => import('react-joyride'), {
ssr: false,
})
const NAV_TOUR_STORAGE_KEY = 'sim-nav-tour-completed-v1'
export const START_NAV_TOUR_EVENT = 'start-nav-tour'
export function NavTour() {
@@ -25,9 +24,6 @@ export function NavTour() {
const { run, stepIndex, tourKey, isTooltipVisible, isEntrance, handleCallback } = useTour({
steps: navTourSteps,
storageKey: NAV_TOUR_STORAGE_KEY,
autoStartDelay: 1200,
resettable: true,
triggerEvent: START_NAV_TOUR_EVENT,
tourName: 'Navigation tour',
disabled: isWorkflowPage,

View File

@@ -12,17 +12,11 @@ const FADE_OUT_MS = 80
interface UseTourOptions {
/** Tour step definitions */
steps: Step[]
/** localStorage key for completion persistence */
storageKey: string
/** Delay before auto-starting the tour (ms) */
autoStartDelay?: number
/** Whether this tour can be reset/retriggered */
resettable?: boolean
/** Custom event name to listen for manual triggers */
triggerEvent?: string
/** Identifier for logging */
tourName?: string
/** When true, suppresses auto-start (e.g. to avoid overlapping with another active tour) */
/** When true, stops a running tour (e.g. navigating away from the relevant page) */
disabled?: boolean
}
@@ -41,49 +35,14 @@ interface UseTourReturn {
handleCallback: (data: CallBackProps) => void
}
function isTourCompleted(storageKey: string): boolean {
try {
return localStorage.getItem(storageKey) === 'true'
} catch {
return false
}
}
function markTourCompleted(storageKey: string): void {
try {
localStorage.setItem(storageKey, 'true')
} catch {
logger.warn('Failed to persist tour completion', { storageKey })
}
}
function clearTourCompletion(storageKey: string): void {
try {
localStorage.removeItem(storageKey)
} catch {
logger.warn('Failed to clear tour completion', { storageKey })
}
}
/**
* Tracks which tours have already attempted auto-start in this page session.
* Module-level so it survives component remounts (e.g. navigating between
* workflows remounts WorkflowTour), while still resetting on full page reload.
*/
const autoStartAttempted = new Set<string>()
/**
* Shared hook for managing product tour state with smooth transitions.
*
* Handles auto-start on first visit, localStorage persistence,
* manual triggering via custom events, and coordinated fade
* Handles manual triggering via custom events and coordinated fade
* transitions between steps to prevent layout shift.
*/
export function useTour({
steps,
storageKey,
autoStartDelay = 1200,
resettable = false,
triggerEvent,
tourName = 'tour',
disabled = false,
@@ -94,15 +53,10 @@ export function useTour({
const [isTooltipVisible, setIsTooltipVisible] = useState(true)
const [isEntrance, setIsEntrance] = useState(true)
const disabledRef = useRef(disabled)
const retriggerTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const transitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const rafRef = useRef<number | null>(null)
useEffect(() => {
disabledRef.current = disabled
}, [disabled])
/**
* Schedules a two-frame rAF to reveal the tooltip after the browser
* finishes repositioning. Stores the outer frame ID in `rafRef` so
@@ -137,8 +91,7 @@ export function useTour({
setRun(false)
setIsTooltipVisible(true)
setIsEntrance(true)
markTourCompleted(storageKey)
}, [storageKey, cancelPendingTransitions])
}, [cancelPendingTransitions])
/** Transition to a new step with a coordinated fade-out/fade-in */
const transitionToStep = useCallback(
@@ -164,40 +117,17 @@ export function useTour({
/** Stop the tour when disabled becomes true (e.g. navigating away from the relevant page) */
useEffect(() => {
if (disabled && run) {
cancelPendingTransitions()
setRun(false)
setIsTooltipVisible(true)
setIsEntrance(true)
stopTour()
logger.info(`${tourName} paused — disabled became true`)
}
}, [disabled, run, tourName, cancelPendingTransitions])
/** Auto-start on first visit (once per page session per tour) */
useEffect(() => {
if (disabled || autoStartAttempted.has(storageKey) || isTourCompleted(storageKey)) return
const timer = setTimeout(() => {
if (disabledRef.current) return
autoStartAttempted.add(storageKey)
setStepIndex(0)
setIsEntrance(true)
setIsTooltipVisible(false)
setRun(true)
logger.info(`Auto-starting ${tourName}`)
scheduleReveal()
}, autoStartDelay)
return () => clearTimeout(timer)
}, [disabled, storageKey, autoStartDelay, tourName, scheduleReveal])
}, [disabled, run, tourName, stopTour])
/** Listen for manual trigger events */
useEffect(() => {
if (!triggerEvent || !resettable) return
if (!triggerEvent) return
const handleTrigger = () => {
setRun(false)
clearTourCompletion(storageKey)
setTourKey((k) => k + 1)
if (retriggerTimerRef.current) {
@@ -222,7 +152,7 @@ export function useTour({
clearTimeout(retriggerTimerRef.current)
}
}
}, [triggerEvent, resettable, storageKey, tourName, scheduleReveal])
}, [triggerEvent, tourName, scheduleReveal])
/** Clean up all pending async work on unmount */
useEffect(() => {

View File

@@ -15,19 +15,15 @@ const Joyride = dynamic(() => import('react-joyride'), {
ssr: false,
})
const WORKFLOW_TOUR_STORAGE_KEY = 'sim-workflow-tour-completed-v1'
export const START_WORKFLOW_TOUR_EVENT = 'start-workflow-tour'
/**
* Workflow tour that covers the canvas, blocks, copilot, and deployment.
* Runs on first workflow visit and can be retriggered via "Take a tour".
* Triggered via "Take a tour" in the sidebar menu.
*/
export function WorkflowTour() {
const { run, stepIndex, tourKey, isTooltipVisible, isEntrance, handleCallback } = useTour({
steps: workflowTourSteps,
storageKey: WORKFLOW_TOUR_STORAGE_KEY,
autoStartDelay: 800,
resettable: true,
triggerEvent: START_WORKFLOW_TOUR_EVENT,
tourName: 'Workflow tour',
})

View File

@@ -1,7 +1,13 @@
import { memo } from 'react'
import type { ResourceCell } from '@/app/workspace/[workspaceId]/components/resource/resource'
import type { WorkspaceMember } from '@/hooks/queries/workspace'
function OwnerAvatar({ name, image }: { name: string; image: string | null }) {
interface OwnerAvatarProps {
name: string
image: string | null
}
const OwnerAvatar = memo(function OwnerAvatar({ name, image }: OwnerAvatarProps) {
if (image) {
return (
<img
@@ -18,7 +24,7 @@ function OwnerAvatar({ name, image }: { name: string; image: string | null }) {
{name.charAt(0).toUpperCase()}
</span>
)
}
})
/**
* Resolves a user ID into a ResourceCell with an avatar icon and display name.

View File

@@ -11,6 +11,8 @@ import {
import { cn } from '@/lib/core/utils/cn'
import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inline-rename-input'
const HEADER_PLUS_ICON = <Plus className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
export interface DropdownOption {
label: string
icon?: React.ElementType
@@ -122,7 +124,7 @@ export const ResourceHeader = memo(function ResourceHeader({
variant='subtle'
className='px-2 py-1 text-caption'
>
<Plus className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
{HEADER_PLUS_ICON}
{create.label}
</Button>
)}
@@ -132,19 +134,21 @@ export const ResourceHeader = memo(function ResourceHeader({
)
})
function BreadcrumbSegment({
icon: Icon,
label,
onClick,
dropdownItems,
editing,
}: {
interface BreadcrumbSegmentProps {
icon?: React.ElementType
label: string
onClick?: () => void
dropdownItems?: DropdownOption[]
editing?: BreadcrumbEditing
}) {
}
const BreadcrumbSegment = memo(function BreadcrumbSegment({
icon: Icon,
label,
onClick,
dropdownItems,
editing,
}: BreadcrumbSegmentProps) {
if (editing?.isEditing) {
return (
<span className='inline-flex items-center px-2 py-1'>
@@ -203,4 +207,4 @@ function BreadcrumbSegment({
{content}
</span>
)
}
})

View File

@@ -1,4 +1,4 @@
import { memo, type ReactNode } from 'react'
import { memo, type ReactNode, useCallback, useRef, useState } from 'react'
import * as PopoverPrimitive from '@radix-ui/react-popover'
import {
ArrowDown,
@@ -16,6 +16,12 @@ import {
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
const SEARCH_ICON = (
<Search className='pointer-events-none h-[14px] w-[14px] shrink-0 text-[var(--text-icon)]' />
)
const FILTER_ICON = <ListFilter className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
const SORT_ICON = <ArrowUpDown className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
type SortDirection = 'asc' | 'desc'
export interface ColumnOption {
@@ -79,56 +85,7 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({
return (
<div className={cn('border-[var(--border)] border-b py-2.5', search ? 'px-6' : 'px-4')}>
<div className='flex items-center justify-between'>
{search && (
<div className='relative flex flex-1 items-center'>
<Search className='pointer-events-none h-[14px] w-[14px] shrink-0 text-[var(--text-icon)]' />
<div className='flex flex-1 items-center gap-1.5 overflow-x-auto pl-2.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
{search.tags?.map((tag, i) => (
<Button
key={`${tag.label}-${tag.value}-${i}`}
variant='subtle'
className={cn(
'shrink-0 px-2 py-1 text-caption',
search.highlightedTagIndex === i &&
'ring-1 ring-[var(--border-focus)] ring-offset-1'
)}
onClick={tag.onRemove}
>
{tag.label}: {tag.value}
<span className='ml-1 text-[var(--text-icon)] text-micro'></span>
</Button>
))}
<input
ref={search.inputRef}
type='text'
value={search.value}
onChange={(e) => search.onChange(e.target.value)}
onKeyDown={search.onKeyDown}
onFocus={search.onFocus}
onBlur={search.onBlur}
placeholder={search.tags?.length ? '' : (search.placeholder ?? 'Search...')}
className='min-w-[80px] flex-1 bg-transparent py-1 text-[var(--text-secondary)] text-caption outline-none placeholder:text-[var(--text-subtle)]'
/>
</div>
{search.tags?.length || search.value ? (
<button
type='button'
className='mr-0.5 flex h-[14px] w-[14px] shrink-0 items-center justify-center text-[var(--text-subtle)] transition-colors hover-hover:text-[var(--text-secondary)]'
onClick={search.onClearAll}
>
<span className='text-caption'></span>
</button>
) : null}
{search.dropdown && (
<div
ref={search.dropdownRef}
className='absolute top-full left-0 z-50 mt-1.5 w-full rounded-lg border border-[var(--border)] bg-[var(--bg)] shadow-sm'
>
{search.dropdown}
</div>
)}
</div>
)}
{search && <SearchSection search={search} />}
<div className='flex items-center gap-1.5'>
{extras}
{filterTags?.map((tag) => (
@@ -146,7 +103,7 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({
<PopoverPrimitive.Root>
<PopoverPrimitive.Trigger asChild>
<Button variant='subtle' className='px-2 py-1 text-caption'>
<ListFilter className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
{FILTER_ICON}
Filter
</Button>
</PopoverPrimitive.Trigger>
@@ -170,14 +127,94 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({
)
})
function SortDropdown({ config }: { config: SortConfig }) {
const SearchSection = memo(function SearchSection({ search }: { search: SearchConfig }) {
const [localValue, setLocalValue] = useState(search.value)
const lastReportedRef = useRef(search.value)
if (search.value !== lastReportedRef.current) {
setLocalValue(search.value)
lastReportedRef.current = search.value
}
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const next = e.target.value
setLocalValue(next)
search.onChange(next)
},
[search.onChange]
)
const handleClearAll = useCallback(() => {
setLocalValue('')
lastReportedRef.current = ''
if (search.onClearAll) {
search.onClearAll()
} else {
search.onChange('')
}
}, [search.onClearAll, search.onChange])
return (
<div className='relative flex flex-1 items-center'>
{SEARCH_ICON}
<div className='flex flex-1 items-center gap-1.5 overflow-x-auto pl-2.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
{search.tags?.map((tag, i) => (
<Button
key={`${tag.label}-${tag.value}-${i}`}
variant='subtle'
className={cn(
'shrink-0 px-2 py-1 text-caption',
search.highlightedTagIndex === i && 'ring-1 ring-[var(--border-focus)] ring-offset-1'
)}
onClick={tag.onRemove}
>
{tag.label}: {tag.value}
<span className='ml-1 text-[var(--text-icon)] text-micro'></span>
</Button>
))}
<input
ref={search.inputRef}
type='text'
value={localValue}
onChange={handleInputChange}
onKeyDown={search.onKeyDown}
onFocus={search.onFocus}
onBlur={search.onBlur}
placeholder={search.tags?.length ? '' : (search.placeholder ?? 'Search...')}
className='min-w-[80px] flex-1 bg-transparent py-1 text-[var(--text-secondary)] text-caption outline-none placeholder:text-[var(--text-subtle)]'
/>
</div>
{search.tags?.length || localValue ? (
<button
type='button'
className='mr-0.5 flex h-[14px] w-[14px] shrink-0 items-center justify-center text-[var(--text-subtle)] transition-colors hover-hover:text-[var(--text-secondary)]'
onClick={handleClearAll}
>
<span className='text-caption'></span>
</button>
) : null}
{search.dropdown && (
<div
ref={search.dropdownRef}
className='absolute top-full left-0 z-50 mt-1.5 w-full rounded-lg border border-[var(--border)] bg-[var(--bg)] shadow-sm'
>
{search.dropdown}
</div>
)}
</div>
)
})
const SortDropdown = memo(function SortDropdown({ config }: { config: SortConfig }) {
const { options, active, onSort, onClear } = config
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='subtle' className='px-2 py-1 text-caption'>
<ArrowUpDown className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
{SORT_ICON}
Sort
</Button>
</DropdownMenuTrigger>
@@ -218,4 +255,4 @@ function SortDropdown({ config }: { config: SortConfig }) {
</DropdownMenuContent>
</DropdownMenu>
)
}
})

View File

@@ -8,6 +8,8 @@ import { ResourceHeader } from './components/resource-header'
import type { FilterTag, SearchConfig, SortConfig } from './components/resource-options-bar'
import { ResourceOptionsBar } from './components/resource-options-bar'
const CREATE_ROW_PLUS_ICON = <Plus className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
export interface ResourceColumn {
id: string
header: string
@@ -69,11 +71,13 @@ interface ResourceProps {
const EMPTY_CELL_PLACEHOLDER = '- - -'
const SKELETON_ROW_COUNT = 5
const stopPropagation = (e: React.MouseEvent) => e.stopPropagation()
/**
* Shared page shell for resource list pages (tables, files, knowledge, schedules, logs).
* Renders the header, toolbar with search, and a data table from column/row definitions.
*/
export function Resource({
export const Resource = memo(function Resource({
icon,
title,
breadcrumbs,
@@ -135,7 +139,7 @@ export function Resource({
/>
</div>
)
}
})
export interface ResourceTableProps {
columns: ResourceColumn[]
@@ -229,6 +233,13 @@ export const ResourceTable = memo(function ResourceTable({
const hasCheckbox = selectable != null
const totalColSpan = columns.length + (hasCheckbox ? 1 : 0)
const handleSelectAll = useCallback(
(checked: boolean | 'indeterminate') => {
selectable?.onSelectAll(checked as boolean)
},
[selectable]
)
if (isLoading) {
return (
<DataTableSkeleton
@@ -259,7 +270,7 @@ export const ResourceTable = memo(function ResourceTable({
<Checkbox
size='sm'
checked={selectable.isAllSelected}
onCheckedChange={(checked) => selectable.onSelectAll(checked as boolean)}
onCheckedChange={handleSelectAll}
disabled={selectable.disabled}
aria-label='Select all'
/>
@@ -306,68 +317,20 @@ export const ResourceTable = memo(function ResourceTable({
<table className='w-full table-fixed text-small'>
<ResourceColGroup columns={columns} hasCheckbox={hasCheckbox} />
<tbody>
{displayRows.map((row) => {
const isSelected = selectable?.selectedIds.has(row.id) ?? false
return (
<tr
key={row.id}
data-resource-row
data-row-id={row.id}
className={cn(
'transition-colors hover-hover:bg-[var(--surface-3)]',
onRowClick && 'cursor-pointer',
(selectedRowId === row.id || isSelected) && 'bg-[var(--surface-3)]'
)}
onClick={() => onRowClick?.(row.id)}
onMouseEnter={onRowHover ? () => onRowHover(row.id) : undefined}
onContextMenu={(e) => onRowContextMenu?.(e, row.id)}
>
{hasCheckbox && (
<td className='w-[52px] py-2.5 pr-0 pl-5 align-middle'>
<Checkbox
size='sm'
checked={isSelected}
onCheckedChange={(checked) =>
selectable.onSelectRow(row.id, checked as boolean)
}
disabled={selectable.disabled}
aria-label='Select row'
onClick={(e) => e.stopPropagation()}
/>
</td>
)}
{columns.map((col, colIdx) => {
const cell = row.cells[col.id]
return (
<td key={col.id} className='px-6 py-2.5 align-middle'>
<CellContent
cell={{ ...cell, label: cell?.label || EMPTY_CELL_PLACEHOLDER }}
primary={colIdx === 0}
/>
</td>
)
})}
</tr>
)
})}
{create && (
<tr
className={cn(
'transition-colors',
create.disabled
? 'cursor-not-allowed'
: 'cursor-pointer hover-hover:bg-[var(--surface-3)]'
)}
onClick={create.disabled ? undefined : create.onClick}
>
<td colSpan={totalColSpan} className='px-6 py-2.5 align-middle'>
<span className='flex items-center gap-3 font-medium text-[var(--text-secondary)] text-sm'>
<Plus className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
{create.label}
</span>
</td>
</tr>
)}
{displayRows.map((row) => (
<DataRow
key={row.id}
row={row}
columns={columns}
selectedRowId={selectedRowId}
selectable={selectable}
onRowClick={onRowClick}
onRowHover={onRowHover}
onRowContextMenu={onRowContextMenu}
hasCheckbox={hasCheckbox}
/>
))}
{create && <CreateRow create={create} totalColSpan={totalColSpan} />}
</tbody>
</table>
{hasMore && (
@@ -390,7 +353,7 @@ export const ResourceTable = memo(function ResourceTable({
)
})
function Pagination({
const Pagination = memo(function Pagination({
currentPage,
totalPages,
onPageChange,
@@ -447,10 +410,17 @@ function Pagination({
</div>
</div>
)
})
interface CellContentProps {
icon?: ReactNode
label: string
content?: ReactNode
primary?: boolean
}
function CellContent({ cell, primary }: { cell: ResourceCell; primary?: boolean }) {
if (cell.content) return <>{cell.content}</>
const CellContent = memo(function CellContent({ icon, label, content, primary }: CellContentProps) {
if (content) return <>{content}</>
return (
<span
className={cn(
@@ -458,19 +428,132 @@ function CellContent({ cell, primary }: { cell: ResourceCell; primary?: boolean
primary ? 'text-[var(--text-body)]' : 'text-[var(--text-secondary)]'
)}
>
{cell.icon && <span className='flex-shrink-0 text-[var(--text-icon)]'>{cell.icon}</span>}
<span className='truncate'>{cell.label}</span>
{icon && <span className='flex-shrink-0 text-[var(--text-icon)]'>{icon}</span>}
<span className='truncate'>{label}</span>
</span>
)
})
interface DataRowProps {
row: ResourceRow
columns: ResourceColumn[]
selectedRowId?: string | null
selectable?: SelectableConfig
onRowClick?: (rowId: string) => void
onRowHover?: (rowId: string) => void
onRowContextMenu?: (e: React.MouseEvent, rowId: string) => void
hasCheckbox: boolean
}
function ResourceColGroup({
const DataRow = memo(function DataRow({
row,
columns,
selectedRowId,
selectable,
onRowClick,
onRowHover,
onRowContextMenu,
hasCheckbox,
}: {
}: DataRowProps) {
const isSelected = selectable?.selectedIds.has(row.id) ?? false
const handleClick = useCallback(() => {
onRowClick?.(row.id)
}, [onRowClick, row.id])
const handleMouseEnter = useCallback(() => {
onRowHover?.(row.id)
}, [onRowHover, row.id])
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
onRowContextMenu?.(e, row.id)
},
[onRowContextMenu, row.id]
)
const handleSelectRow = useCallback(
(checked: boolean | 'indeterminate') => {
selectable?.onSelectRow(row.id, checked as boolean)
},
[selectable, row.id]
)
return (
<tr
data-resource-row
data-row-id={row.id}
className={cn(
'transition-colors hover-hover:bg-[var(--surface-3)]',
onRowClick && 'cursor-pointer',
(selectedRowId === row.id || isSelected) && 'bg-[var(--surface-3)]'
)}
onClick={onRowClick ? handleClick : undefined}
onMouseEnter={handleMouseEnter}
onContextMenu={onRowContextMenu ? handleContextMenu : undefined}
>
{hasCheckbox && selectable && (
<td className='w-[52px] py-2.5 pr-0 pl-5 align-middle'>
<Checkbox
size='sm'
checked={isSelected}
onCheckedChange={handleSelectRow}
disabled={selectable.disabled}
aria-label='Select row'
onClick={stopPropagation}
/>
</td>
)}
{columns.map((col, colIdx) => {
const cell = row.cells[col.id]
return (
<td key={col.id} className='px-6 py-2.5 align-middle'>
<CellContent
icon={cell?.icon}
label={cell?.label || EMPTY_CELL_PLACEHOLDER}
content={cell?.content}
primary={colIdx === 0}
/>
</td>
)
})}
</tr>
)
})
interface CreateRowProps {
create: CreateAction
totalColSpan: number
}
const CreateRow = memo(function CreateRow({ create, totalColSpan }: CreateRowProps) {
return (
<tr
className={cn(
'transition-colors',
create.disabled ? 'cursor-not-allowed' : 'cursor-pointer hover-hover:bg-[var(--surface-3)]'
)}
onClick={create.disabled ? undefined : create.onClick}
>
<td colSpan={totalColSpan} className='px-6 py-2.5 align-middle'>
<span className='flex items-center gap-3 font-medium text-[var(--text-secondary)] text-sm'>
{CREATE_ROW_PLUS_ICON}
{create.label}
</span>
</td>
</tr>
)
})
interface ResourceColGroupProps {
columns: ResourceColumn[]
hasCheckbox?: boolean
}) {
}
const ResourceColGroup = memo(function ResourceColGroup({
columns,
hasCheckbox,
}: ResourceColGroupProps) {
return (
<colgroup>
{hasCheckbox && <col className='w-[52px]' />}
@@ -486,17 +569,19 @@ function ResourceColGroup({
))}
</colgroup>
)
}
})
function DataTableSkeleton({
columns,
rowCount,
hasCheckbox,
}: {
interface DataTableSkeletonProps {
columns: ResourceColumn[]
rowCount: number
hasCheckbox?: boolean
}) {
}
const DataTableSkeleton = memo(function DataTableSkeleton({
columns,
rowCount,
hasCheckbox,
}: DataTableSkeletonProps) {
return (
<>
<div className='overflow-hidden'>
@@ -549,4 +634,4 @@ function DataTableSkeleton({
</div>
</>
)
}
})

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Skeleton } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
@@ -183,6 +183,8 @@ function TextEditor({
} = useWorkspaceFileContent(workspaceId, file.id, file.key, file.type === 'text/x-pptxgenjs')
const updateContent = useUpdateWorkspaceFileContent()
const updateContentRef = useRef(updateContent)
updateContentRef.current = updateContent
const [content, setContent] = useState('')
const [savedContent, setSavedContent] = useState('')
@@ -230,14 +232,14 @@ function TextEditor({
const currentContent = contentRef.current
if (currentContent === savedContentRef.current) return
await updateContent.mutateAsync({
await updateContentRef.current.mutateAsync({
workspaceId,
fileId: file.id,
content: currentContent,
})
setSavedContent(currentContent)
savedContentRef.current = currentContent
}, [workspaceId, file.id, updateContent])
}, [workspaceId, file.id])
const { saveStatus, saveImmediately, isDirty } = useAutosave({
content,
@@ -288,6 +290,16 @@ function TextEditor({
}
}, [isResizing])
const handleCheckboxToggle = useCallback(
(checkboxIndex: number, checked: boolean) => {
const toggled = toggleMarkdownCheckbox(contentRef.current, checkboxIndex, checked)
if (toggled !== contentRef.current) {
handleContentChange(toggled)
}
},
[handleContentChange]
)
const isStreaming = streamingContent !== undefined
const revealedContent = useStreamingText(content, isStreaming)
@@ -390,10 +402,11 @@ function TextEditor({
className={cn('min-w-0 flex-1 overflow-hidden', isResizing && 'pointer-events-none')}
>
<PreviewPanel
content={revealedContent}
content={isStreaming ? revealedContent : content}
mimeType={file.type}
filename={file.name}
isStreaming={isStreaming}
onCheckboxToggle={canEdit && !isStreaming ? handleCheckboxToggle : undefined}
/>
</div>
</>
@@ -402,7 +415,7 @@ function TextEditor({
)
}
function IframePreview({ file }: { file: WorkspaceFileRecord }) {
const IframePreview = memo(function IframePreview({ file }: { file: WorkspaceFileRecord }) {
const serveUrl = `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace`
return (
@@ -417,9 +430,9 @@ function IframePreview({ file }: { file: WorkspaceFileRecord }) {
/>
</div>
)
}
})
function ImagePreview({ file }: { file: WorkspaceFileRecord }) {
const ImagePreview = memo(function ImagePreview({ file }: { file: WorkspaceFileRecord }) {
const serveUrl = `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace`
return (
@@ -432,7 +445,7 @@ function ImagePreview({ file }: { file: WorkspaceFileRecord }) {
/>
</div>
)
}
})
const pptxSlideCache = new Map<string, string[]>()
@@ -701,7 +714,19 @@ function PptxPreview({
)
}
function UnsupportedPreview({ file }: { file: WorkspaceFileRecord }) {
function toggleMarkdownCheckbox(markdown: string, targetIndex: number, checked: boolean): string {
let currentIndex = 0
return markdown.replace(/^(\s*(?:[-*+]|\d+[.)]) +)\[([ xX])\]/gm, (match, prefix: string) => {
if (currentIndex++ !== targetIndex) return match
return `${prefix}[${checked ? 'x' : ' '}]`
})
}
const UnsupportedPreview = memo(function UnsupportedPreview({
file,
}: {
file: WorkspaceFileRecord
}) {
const ext = getFileExtension(file.name)
return (
@@ -714,4 +739,4 @@ function UnsupportedPreview({ file }: { file: WorkspaceFileRecord }) {
</p>
</div>
)
}
})

View File

@@ -1,9 +1,10 @@
'use client'
import { memo, useMemo } from 'react'
import { memo, useMemo, useRef } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkBreaks from 'remark-breaks'
import remarkGfm from 'remark-gfm'
import { Checkbox } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
import { useAutoScroll } from '@/hooks/use-auto-scroll'
@@ -40,23 +41,36 @@ interface PreviewPanelProps {
mimeType: string | null
filename: string
isStreaming?: boolean
onCheckboxToggle?: (checkboxIndex: number, checked: boolean) => void
}
export function PreviewPanel({ content, mimeType, filename, isStreaming }: PreviewPanelProps) {
export const PreviewPanel = memo(function PreviewPanel({
content,
mimeType,
filename,
isStreaming,
onCheckboxToggle,
}: PreviewPanelProps) {
const previewType = resolvePreviewType(mimeType, filename)
if (previewType === 'markdown')
return <MarkdownPreview content={content} isStreaming={isStreaming} />
return (
<MarkdownPreview
content={content}
isStreaming={isStreaming}
onCheckboxToggle={onCheckboxToggle}
/>
)
if (previewType === 'html') return <HtmlPreview content={content} />
if (previewType === 'csv') return <CsvPreview content={content} />
if (previewType === 'svg') return <SvgPreview content={content} />
return null
}
})
const REMARK_PLUGINS = [remarkGfm, remarkBreaks]
const PREVIEW_MARKDOWN_COMPONENTS = {
const STATIC_MARKDOWN_COMPONENTS = {
p: ({ children }: any) => (
<p className='mb-3 break-words text-[14px] text-[var(--text-primary)] leading-[1.6] last:mb-0'>
{children}
@@ -82,17 +96,6 @@ const PREVIEW_MARKDOWN_COMPONENTS = {
{children}
</h4>
),
ul: ({ children }: any) => (
<ul className='mt-1 mb-3 list-disc space-y-1 break-words pl-6 text-[14px] text-[var(--text-primary)]'>
{children}
</ul>
),
ol: ({ children }: any) => (
<ol className='mt-1 mb-3 list-decimal space-y-1 break-words pl-6 text-[14px] text-[var(--text-primary)]'>
{children}
</ol>
),
li: ({ children }: any) => <li className='break-words leading-[1.6]'>{children}</li>,
code: ({ inline, className, children, ...props }: any) => {
const isInline = inline || !className?.includes('language-')
@@ -160,26 +163,110 @@ const PREVIEW_MARKDOWN_COMPONENTS = {
td: ({ children }: any) => <td className='px-3 py-2 text-[var(--text-secondary)]'>{children}</td>,
}
function buildMarkdownComponents(
checkboxCounterRef: React.MutableRefObject<number>,
onCheckboxToggle?: (checkboxIndex: number, checked: boolean) => void
) {
const isInteractive = Boolean(onCheckboxToggle)
return {
...STATIC_MARKDOWN_COMPONENTS,
ul: ({ className, children }: any) => {
const isTaskList = typeof className === 'string' && className.includes('contains-task-list')
return (
<ul
className={cn(
'mt-1 mb-3 space-y-1 break-words text-[14px] text-[var(--text-primary)]',
isTaskList ? 'list-none pl-0' : 'list-disc pl-6'
)}
>
{children}
</ul>
)
},
ol: ({ className, children }: any) => {
const isTaskList = typeof className === 'string' && className.includes('contains-task-list')
return (
<ol
className={cn(
'mt-1 mb-3 space-y-1 break-words text-[14px] text-[var(--text-primary)]',
isTaskList ? 'list-none pl-0' : 'list-decimal pl-6'
)}
>
{children}
</ol>
)
},
li: ({ className, children }: any) => {
const isTaskItem = typeof className === 'string' && className.includes('task-list-item')
if (isTaskItem) {
return <li className='flex items-start gap-2 break-words leading-[1.6]'>{children}</li>
}
return <li className='break-words leading-[1.6]'>{children}</li>
},
input: ({ type, checked, ...props }: any) => {
if (type !== 'checkbox') return <input type={type} checked={checked} {...props} />
const index = checkboxCounterRef.current++
return (
<Checkbox
checked={checked ?? false}
onCheckedChange={
isInteractive
? (newChecked) => onCheckboxToggle!(index, Boolean(newChecked))
: undefined
}
disabled={!isInteractive}
size='sm'
className='mt-1 shrink-0'
/>
)
},
}
}
const MarkdownPreview = memo(function MarkdownPreview({
content,
isStreaming = false,
onCheckboxToggle,
}: {
content: string
isStreaming?: boolean
onCheckboxToggle?: (checkboxIndex: number, checked: boolean) => void
}) {
const { ref: scrollRef } = useAutoScroll(isStreaming)
const { committed, incoming, generation } = useStreamingReveal(content, isStreaming)
const checkboxCounterRef = useRef(0)
const components = useMemo(
() => buildMarkdownComponents(checkboxCounterRef, onCheckboxToggle),
[onCheckboxToggle]
)
checkboxCounterRef.current = 0
const committedMarkdown = useMemo(
() =>
committed ? (
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={PREVIEW_MARKDOWN_COMPONENTS}>
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={components}>
{committed}
</ReactMarkdown>
) : null,
[committed]
[committed, components]
)
if (onCheckboxToggle) {
return (
<div ref={scrollRef} className='h-full overflow-auto p-6'>
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={components}>
{content}
</ReactMarkdown>
</div>
)
}
return (
<div ref={scrollRef} className='h-full overflow-auto p-6'>
{committedMarkdown}
@@ -188,7 +275,7 @@ const MarkdownPreview = memo(function MarkdownPreview({
key={generation}
className={cn(isStreaming && 'animate-stream-fade-in', '[&>:first-child]:mt-0')}
>
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={PREVIEW_MARKDOWN_COMPONENTS}>
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={components}>
{incoming}
</ReactMarkdown>
</div>
@@ -197,7 +284,7 @@ const MarkdownPreview = memo(function MarkdownPreview({
)
})
function HtmlPreview({ content }: { content: string }) {
const HtmlPreview = memo(function HtmlPreview({ content }: { content: string }) {
return (
<div className='h-full overflow-hidden'>
<iframe
@@ -208,9 +295,9 @@ function HtmlPreview({ content }: { content: string }) {
/>
</div>
)
}
})
function SvgPreview({ content }: { content: string }) {
const SvgPreview = memo(function SvgPreview({ content }: { content: string }) {
const wrappedContent = useMemo(
() =>
`<!DOCTYPE html><html><head><style>body{margin:0;display:flex;align-items:center;justify-content:center;min-height:100vh;background:transparent;}svg{max-width:100%;max-height:100vh;}</style></head><body>${content}</body></html>`,
@@ -227,9 +314,9 @@ function SvgPreview({ content }: { content: string }) {
/>
</div>
)
}
})
function CsvPreview({ content }: { content: string }) {
const CsvPreview = memo(function CsvPreview({ content }: { content: string }) {
const { headers, rows } = useMemo(() => parseCsv(content), [content])
if (headers.length === 0) {
@@ -271,7 +358,7 @@ function CsvPreview({ content }: { content: string }) {
</div>
</div>
)
}
})
function parseCsv(text: string): { headers: string[]; rows: string[][] } {
const lines = text.split('\n').filter((line) => line.trim().length > 0)

View File

@@ -1,5 +1,6 @@
'use client'
import { memo } from 'react'
import {
DropdownMenu,
DropdownMenuContent,
@@ -18,7 +19,7 @@ interface FilesListContextMenuProps {
disableUpload?: boolean
}
export function FilesListContextMenu({
export const FilesListContextMenu = memo(function FilesListContextMenu({
isOpen,
position,
onClose,
@@ -64,4 +65,4 @@ export function FilesListContextMenu({
</DropdownMenuContent>
</DropdownMenu>
)
}
})

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams, useRouter } from 'next/navigation'
import {
@@ -41,6 +41,7 @@ import type {
HeaderAction,
ResourceColumn,
ResourceRow,
SearchConfig,
} from '@/app/workspace/[workspaceId]/components'
import {
InlineRenameInput,
@@ -159,11 +160,29 @@ export function Files() {
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState({ completed: 0, total: 0 })
const [searchTerm, setSearchTerm] = useState('')
const [inputValue, setInputValue] = useState('')
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('')
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(null)
const handleSearchChange = useCallback((value: string) => {
setInputValue(value)
if (searchTimerRef.current) clearTimeout(searchTimerRef.current)
searchTimerRef.current = setTimeout(() => {
setDebouncedSearchTerm(value)
}, 200)
}, [])
const [creatingFile, setCreatingFile] = useState(false)
const [isDirty, setIsDirty] = useState(false)
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
const [previewMode, setPreviewMode] = useState<PreviewMode>('preview')
const [previewMode, setPreviewMode] = useState<PreviewMode>(() => {
if (fileIdFromRoute) {
const file = files.find((f) => f.id === fileIdFromRoute)
if (file && isPreviewable(file)) return 'preview'
return 'editor'
}
return 'preview'
})
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [contextMenuFile, setContextMenuFile] = useState<WorkspaceFileRecord | null>(null)
@@ -183,59 +202,105 @@ export function Files() {
() => (fileIdFromRoute ? files.find((f) => f.id === fileIdFromRoute) : null),
[fileIdFromRoute, files]
)
const selectedFileRef = useRef(selectedFile)
selectedFileRef.current = selectedFile
const filteredFiles = useMemo(() => {
if (!searchTerm) return files
const q = searchTerm.toLowerCase()
if (!debouncedSearchTerm) return files
const q = debouncedSearchTerm.toLowerCase()
return files.filter((f) => f.name.toLowerCase().includes(q))
}, [files, searchTerm])
}, [files, debouncedSearchTerm])
const rows: ResourceRow[] = useMemo(
() =>
filteredFiles.map((file) => {
const Icon = getDocumentIcon(file.type || '', file.name)
return {
id: file.id,
cells: {
name: {
icon: <Icon className='h-[14px] w-[14px]' />,
label: file.name,
content:
listRename.editingId === file.id ? (
<span className='flex min-w-0 items-center gap-3 font-medium text-[var(--text-body)] text-sm'>
<span className='flex-shrink-0 text-[var(--text-icon)]'>
<Icon className='h-[14px] w-[14px]' />
</span>
<InlineRenameInput
value={listRename.editValue}
onChange={listRename.setEditValue}
onSubmit={listRename.submitRename}
onCancel={listRename.cancelRename}
/>
</span>
) : undefined,
},
size: {
label: formatFileSize(file.size, { includeBytes: true }),
},
type: {
icon: <Icon className='h-[14px] w-[14px]' />,
label: formatFileType(file.type, file.name),
},
created: timeCell(file.uploadedAt),
owner: ownerCell(file.uploadedBy, members),
updated: timeCell(file.uploadedAt),
},
sortValues: {
size: file.size,
created: -new Date(file.uploadedAt).getTime(),
updated: -new Date(file.uploadedAt).getTime(),
},
}
}),
[filteredFiles, members, listRename.editingId, listRename.editValue]
const rowCacheRef = useRef(
new Map<string, { row: ResourceRow; file: WorkspaceFileRecord; members: typeof members }>()
)
const baseRows: ResourceRow[] = useMemo(() => {
const prevCache = rowCacheRef.current
const nextCache = new Map<
string,
{ row: ResourceRow; file: WorkspaceFileRecord; members: typeof members }
>()
const result = filteredFiles.map((file) => {
const cached = prevCache.get(file.id)
if (cached && cached.file === file && cached.members === members) {
nextCache.set(file.id, cached)
return cached.row
}
const Icon = getDocumentIcon(file.type || '', file.name)
const row: ResourceRow = {
id: file.id,
cells: {
name: {
icon: <Icon className='h-[14px] w-[14px]' />,
label: file.name,
},
size: {
label: formatFileSize(file.size, { includeBytes: true }),
},
type: {
icon: <Icon className='h-[14px] w-[14px]' />,
label: formatFileType(file.type, file.name),
},
created: timeCell(file.uploadedAt),
owner: ownerCell(file.uploadedBy, members),
updated: timeCell(file.uploadedAt),
},
sortValues: {
size: file.size,
created: -new Date(file.uploadedAt).getTime(),
updated: -new Date(file.uploadedAt).getTime(),
},
}
nextCache.set(file.id, { row, file, members })
return row
})
rowCacheRef.current = nextCache
return result
}, [filteredFiles, members])
const rows: ResourceRow[] = useMemo(() => {
if (!listRename.editingId) return baseRows
return baseRows.map((row) => {
if (row.id !== listRename.editingId) return row
const file = filteredFiles.find((f) => f.id === row.id)
if (!file) return row
const Icon = getDocumentIcon(file.type || '', file.name)
return {
...row,
cells: {
...row.cells,
name: {
...row.cells.name,
content: (
<span className='flex min-w-0 items-center gap-3 font-medium text-[var(--text-body)] text-sm'>
<span className='flex-shrink-0 text-[var(--text-icon)]'>
<Icon className='h-[14px] w-[14px]' />
</span>
<InlineRenameInput
value={listRename.editValue}
onChange={listRename.setEditValue}
onSubmit={listRename.submitRename}
onCancel={listRename.cancelRename}
/>
</span>
),
},
},
}
})
}, [
baseRows,
listRename.editingId,
listRename.editValue,
listRename.setEditValue,
listRename.submitRename,
listRename.cancelRename,
filteredFiles,
])
const handleFileChange = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const list = e.target.files
@@ -288,8 +353,13 @@ export function Files() {
}
}, [])
const deleteTargetFileRef = useRef(deleteTargetFile)
deleteTargetFileRef.current = deleteTargetFile
const fileIdFromRouteRef = useRef(fileIdFromRoute)
fileIdFromRouteRef.current = fileIdFromRoute
const handleDelete = useCallback(async () => {
const target = deleteTargetFile
const target = deleteTargetFileRef.current
if (!target) return
try {
@@ -299,7 +369,7 @@ export function Files() {
})
setShowDeleteConfirm(false)
setDeleteTargetFile(null)
if (fileIdFromRoute === target.id) {
if (fileIdFromRouteRef.current === target.id) {
setIsDirty(false)
setSaveStatus('idle')
router.push(`/workspace/${workspaceId}/files`)
@@ -307,36 +377,44 @@ export function Files() {
} catch (err) {
logger.error('Failed to delete file:', err)
}
}, [deleteTargetFile, workspaceId, fileIdFromRoute, router])
}, [workspaceId, router])
const isDirtyRef = useRef(isDirty)
isDirtyRef.current = isDirty
const saveStatusRef = useRef(saveStatus)
saveStatusRef.current = saveStatus
const handleSave = useCallback(async () => {
if (!saveRef.current || !isDirty || saveStatus === 'saving') return
if (!saveRef.current || !isDirtyRef.current || saveStatusRef.current === 'saving') return
await saveRef.current()
}, [isDirty, saveStatus])
}, [])
const handleBackAttempt = useCallback(() => {
if (isDirty) {
if (isDirtyRef.current) {
setShowUnsavedChangesAlert(true)
} else {
setPreviewMode('editor')
router.push(`/workspace/${workspaceId}/files`)
}
}, [isDirty, router, workspaceId])
}, [router, workspaceId])
const handleStartHeaderRename = useCallback(() => {
if (selectedFile) headerRename.startRename(selectedFile.id, selectedFile.name)
}, [selectedFile, headerRename.startRename])
const file = selectedFileRef.current
if (file) headerRename.startRename(file.id, file.name)
}, [headerRename.startRename])
const handleDownloadSelected = useCallback(() => {
if (selectedFile) handleDownload(selectedFile)
}, [selectedFile, handleDownload])
const file = selectedFileRef.current
if (file) handleDownload(file)
}, [handleDownload])
const handleDeleteSelected = useCallback(() => {
if (selectedFile) {
setDeleteTargetFile(selectedFile)
const file = selectedFileRef.current
if (file) {
setDeleteTargetFile(file)
setShowDeleteConfirm(true)
}
}, [selectedFile])
}, [])
const fileDetailBreadcrumbs = useMemo(
() =>
@@ -379,9 +457,6 @@ export function Files() {
handleBackAttempt,
headerRename.editingId,
headerRename.editValue,
headerRename.setEditValue,
headerRename.submitRename,
headerRename.cancelRename,
handleStartHeaderRename,
handleDownloadSelected,
handleDeleteSelected,
@@ -396,12 +471,15 @@ export function Files() {
router.push(`/workspace/${workspaceId}/files`)
}, [router, workspaceId])
const creatingFileRef = useRef(creatingFile)
creatingFileRef.current = creatingFile
const handleCreateFile = useCallback(async () => {
if (creatingFile) return
if (creatingFileRef.current) return
setCreatingFile(true)
try {
const existingNames = new Set(files.map((f) => f.name))
const existingNames = new Set(filesRef.current.map((f) => f.name))
let name = 'untitled.md'
let counter = 1
while (existingNames.has(name)) {
@@ -423,42 +501,49 @@ export function Files() {
} finally {
setCreatingFile(false)
}
}, [creatingFile, files, workspaceId, router])
}, [workspaceId, router])
const handleRowContextMenu = useCallback(
(e: React.MouseEvent, rowId: string) => {
const file = files.find((f) => f.id === rowId)
const file = filesRef.current.find((f) => f.id === rowId)
if (file) {
setContextMenuFile(file)
openContextMenu(e)
}
},
[files, openContextMenu]
[openContextMenu]
)
const contextMenuFileRef = useRef(contextMenuFile)
contextMenuFileRef.current = contextMenuFile
const handleContextMenuOpen = useCallback(() => {
if (!contextMenuFile) return
router.push(`/workspace/${workspaceId}/files/${contextMenuFile.id}`)
const file = contextMenuFileRef.current
if (!file) return
router.push(`/workspace/${workspaceId}/files/${file.id}`)
closeContextMenu()
}, [contextMenuFile, closeContextMenu, router, workspaceId])
}, [closeContextMenu, router, workspaceId])
const handleContextMenuDownload = useCallback(() => {
if (!contextMenuFile) return
handleDownload(contextMenuFile)
const file = contextMenuFileRef.current
if (!file) return
handleDownload(file)
closeContextMenu()
}, [contextMenuFile, handleDownload, closeContextMenu])
}, [handleDownload, closeContextMenu])
const handleContextMenuRename = useCallback(() => {
if (contextMenuFile) listRename.startRename(contextMenuFile.id, contextMenuFile.name)
const file = contextMenuFileRef.current
if (file) listRename.startRename(file.id, file.name)
closeContextMenu()
}, [contextMenuFile, listRename.startRename, closeContextMenu])
}, [listRename.startRename, closeContextMenu])
const handleContextMenuDelete = useCallback(() => {
if (!contextMenuFile) return
setDeleteTargetFile(contextMenuFile)
const file = contextMenuFileRef.current
if (!file) return
setDeleteTargetFile(file)
setShowDeleteConfirm(true)
closeContextMenu()
}, [contextMenuFile, closeContextMenu])
}, [closeContextMenu])
const handleContentContextMenu = useCallback(
(e: React.MouseEvent) => {
@@ -479,41 +564,46 @@ export function Files() {
closeListContextMenu()
}, [closeListContextMenu])
useEffect(() => {
const prevFileIdRef = useRef(fileIdFromRoute)
if (fileIdFromRoute !== prevFileIdRef.current) {
prevFileIdRef.current = fileIdFromRoute
const isJustCreated =
fileIdFromRoute != null && justCreatedFileIdRef.current === fileIdFromRoute
if (justCreatedFileIdRef.current && !isJustCreated) {
justCreatedFileIdRef.current = null
}
if (isJustCreated) {
setPreviewMode('editor')
} else {
const file = fileIdFromRoute ? filesRef.current.find((f) => f.id === fileIdFromRoute) : null
const canPreview = file ? isPreviewable(file) : false
setPreviewMode(canPreview ? 'preview' : 'editor')
const nextMode: PreviewMode = isJustCreated
? 'editor'
: (() => {
const file = fileIdFromRoute
? filesRef.current.find((f) => f.id === fileIdFromRoute)
: null
return file && isPreviewable(file) ? 'preview' : 'editor'
})()
if (nextMode !== previewMode) {
setPreviewMode(nextMode)
}
}, [fileIdFromRoute])
}
useEffect(() => {
if (!selectedFile) return
const handleKeyDown = (e: KeyboardEvent) => {
if (!fileIdFromRouteRef.current) return
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault()
handleSave()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [selectedFile, handleSave])
useEffect(() => {
if (!isDirty) return
const handler = (e: BeforeUnloadEvent) => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (!isDirtyRef.current) return
e.preventDefault()
}
window.addEventListener('beforeunload', handler)
return () => window.removeEventListener('beforeunload', handler)
}, [isDirty])
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('beforeunload', handleBeforeUnload)
return () => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('beforeunload', handleBeforeUnload)
}
}, [handleSave])
const handleCyclePreviewMode = useCallback(() => {
setPreviewMode((prev) => {
@@ -592,27 +682,92 @@ export function Files() {
selectedFile,
saveStatus,
previewMode,
isDirty,
handleCyclePreviewMode,
handleTogglePreview,
handleSave,
isDirty,
handleDownloadSelected,
handleDeleteSelected,
])
/** Stable refs for values used in callbacks to avoid dependency churn */
const listRenameRef = useRef(listRename)
listRenameRef.current = listRename
const headerRenameRef = useRef(headerRename)
headerRenameRef.current = headerRename
const handleRowClick = useCallback(
(id: string) => {
if (listRenameRef.current.editingId !== id && !headerRenameRef.current.editingId) {
router.push(`/workspace/${workspaceId}/files/${id}`)
}
},
[router, workspaceId]
)
const handleUploadClick = useCallback(() => {
fileInputRef.current?.click()
}, [])
const canEdit = userPermissions.canEdit === true
const handleSearchClearAll = useCallback(() => {
handleSearchChange('')
}, [handleSearchChange])
const searchConfig: SearchConfig = useMemo(
() => ({
value: inputValue,
onChange: handleSearchChange,
onClearAll: handleSearchClearAll,
placeholder: 'Search files...',
}),
[inputValue, handleSearchChange, handleSearchClearAll]
)
const createConfig = useMemo(
() => ({
label: 'New file',
onClick: handleCreateFile,
disabled: uploading || creatingFile || !canEdit,
}),
[handleCreateFile, uploading, creatingFile, canEdit]
)
const uploadButtonLabel = useMemo(
() =>
uploading && uploadProgress.total > 0
? `${uploadProgress.completed}/${uploadProgress.total}`
: uploading
? 'Uploading...'
: 'Upload',
[uploading, uploadProgress.completed, uploadProgress.total]
)
const headerActionsConfig = useMemo(
() => [
{
label: uploadButtonLabel,
icon: Upload,
onClick: handleUploadClick,
},
],
[uploadButtonLabel, handleUploadClick]
)
const handleNavigateToFiles = useCallback(() => {
router.push(`/workspace/${workspaceId}/files`)
}, [router, workspaceId])
const loadingBreadcrumbs = useMemo(
() => [{ label: 'Files', onClick: handleNavigateToFiles }, { label: '...' }],
[handleNavigateToFiles]
)
if (fileIdFromRoute && !selectedFile) {
return (
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
<ResourceHeader
icon={FilesIcon}
breadcrumbs={[
{
label: 'Files',
onClick: () => router.push(`/workspace/${workspaceId}/files`),
},
{ label: '...' },
]}
/>
<ResourceHeader icon={FilesIcon} breadcrumbs={loadingBreadcrumbs} />
<div className='flex flex-1 items-center justify-center'>
<Skeleton className='h-[16px] w-[200px]' />
</div>
@@ -633,7 +788,7 @@ export function Files() {
key={selectedFile.id}
file={selectedFile}
workspaceId={workspaceId}
canEdit={userPermissions.canEdit === true}
canEdit={canEdit}
previewMode={previewMode}
autoFocus={justCreatedFileIdRef.current === selectedFile.id}
onDirtyChange={setIsDirty}
@@ -672,43 +827,18 @@ export function Files() {
)
}
const uploadButtonLabel =
uploading && uploadProgress.total > 0
? `${uploadProgress.completed}/${uploadProgress.total}`
: uploading
? 'Uploading...'
: 'Upload'
return (
<>
<Resource
icon={FilesIcon}
title='Files'
create={{
label: 'New file',
onClick: handleCreateFile,
disabled: uploading || creatingFile || userPermissions.canEdit !== true,
}}
search={{
value: searchTerm,
onChange: setSearchTerm,
placeholder: 'Search files...',
}}
create={createConfig}
search={searchConfig}
defaultSort='created'
headerActions={[
{
label: uploadButtonLabel,
icon: Upload,
onClick: () => fileInputRef.current?.click(),
},
]}
headerActions={headerActionsConfig}
columns={COLUMNS}
rows={rows}
onRowClick={(id) => {
if (listRename.editingId !== id && !headerRename.editingId) {
router.push(`/workspace/${workspaceId}/files/${id}`)
}
}}
onRowClick={handleRowClick}
onRowContextMenu={handleRowContextMenu}
isLoading={isLoading}
onContextMenu={handleContentContextMenu}
@@ -720,58 +850,20 @@ export function Files() {
onClose={closeListContextMenu}
onCreateFile={handleCreateFile}
onUploadFile={handleListUploadFile}
disableCreate={uploading || creatingFile || userPermissions.canEdit !== true}
disableUpload={uploading || userPermissions.canEdit !== true}
disableCreate={uploading || creatingFile || !canEdit}
disableUpload={uploading || !canEdit}
/>
<DropdownMenu
open={isContextMenuOpen}
onOpenChange={(open) => !open && closeContextMenu()}
modal={false}
>
<DropdownMenuTrigger asChild>
<div
style={{
position: 'fixed',
left: `${contextMenuPosition.x}px`,
top: `${contextMenuPosition.y}px`,
width: '1px',
height: '1px',
pointerEvents: 'none',
}}
tabIndex={-1}
aria-hidden
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
side='bottom'
sideOffset={4}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DropdownMenuItem onSelect={handleContextMenuOpen}>
<Eye />
Open
</DropdownMenuItem>
<DropdownMenuItem onSelect={handleContextMenuDownload}>
<Download />
Download
</DropdownMenuItem>
{userPermissions.canEdit === true && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={handleContextMenuRename}>
<Pencil />
Rename
</DropdownMenuItem>
<DropdownMenuItem onSelect={handleContextMenuDelete}>
<Trash />
Delete
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<FileRowContextMenu
isOpen={isContextMenuOpen}
position={contextMenuPosition}
onClose={closeContextMenu}
onOpen={handleContextMenuOpen}
onDownload={handleContextMenuDownload}
onRename={handleContextMenuRename}
onDelete={handleContextMenuDelete}
canEdit={canEdit}
/>
<DeleteConfirmModal
open={showDeleteConfirm}
@@ -794,6 +886,75 @@ export function Files() {
)
}
interface FileRowContextMenuProps {
isOpen: boolean
position: { x: number; y: number }
onClose: () => void
onOpen: () => void
onDownload: () => void
onRename: () => void
onDelete: () => void
canEdit: boolean
}
const FileRowContextMenu = memo(function FileRowContextMenu({
isOpen,
position,
onClose,
onOpen,
onDownload,
onRename,
onDelete,
canEdit,
}: FileRowContextMenuProps) {
return (
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
<DropdownMenuTrigger asChild>
<div
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
pointerEvents: 'none',
}}
tabIndex={-1}
aria-hidden
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
side='bottom'
sideOffset={4}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DropdownMenuItem onSelect={onOpen}>
<Eye />
Open
</DropdownMenuItem>
<DropdownMenuItem onSelect={onDownload}>
<Download />
Download
</DropdownMenuItem>
{canEdit && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onRename}>
<Pencil />
Rename
</DropdownMenuItem>
<DropdownMenuItem onSelect={onDelete}>
<Trash />
Delete
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)
})
interface DeleteConfirmModalProps {
open: boolean
onOpenChange: (open: boolean) => void
@@ -802,7 +963,7 @@ interface DeleteConfirmModalProps {
isPending: boolean
}
function DeleteConfirmModal({
const DeleteConfirmModal = memo(function DeleteConfirmModal({
open,
onOpenChange,
fileName,
@@ -833,4 +994,4 @@ function DeleteConfirmModal({
</ModalContent>
</Modal>
)
}
})

View File

@@ -82,6 +82,7 @@ const TOOL_ICONS: Record<MothershipToolName | SubagentName | 'mothership', IconC
create_job: Calendar,
manage_job: Calendar,
update_job_history: Calendar,
job_respond: Calendar,
// Management
manage_mcp_tool: Settings,
manage_skill: Asterisk,

View File

@@ -52,7 +52,7 @@ function WorkflowDropdownItem({ item }: DropdownItemRenderProps) {
return (
<>
<div
className='mr-[0px] h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
style={{
backgroundColor: color,
borderColor: `${color}60`,
@@ -72,7 +72,16 @@ function FileDropdownItem({ item }: DropdownItemRenderProps) {
const DocIcon = getDocumentIcon('', item.name)
return (
<>
<DocIcon className='mr-2 h-[14px] w-[14px] text-[var(--text-icon)]' />
<DocIcon className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate'>{item.name}</span>
</>
)
}
function IconDropdownItem({ item, icon: Icon }: DropdownItemRenderProps & { icon: ElementType }) {
return (
<>
<Icon className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate'>{item.name}</span>
</>
)
@@ -104,7 +113,7 @@ export const RESOURCE_REGISTRY: Record<MothershipResourceType, ResourceTypeConfi
renderTabIcon: (_resource, className) => (
<TableIcon className={cn(className, 'text-[var(--text-icon)]')} />
),
renderDropdownItem: (props) => <DefaultDropdownItem {...props} />,
renderDropdownItem: (props) => <IconDropdownItem {...props} icon={TableIcon} />,
},
file: {
type: 'file',
@@ -123,7 +132,7 @@ export const RESOURCE_REGISTRY: Record<MothershipResourceType, ResourceTypeConfi
renderTabIcon: (_resource, className) => (
<Database className={cn(className, 'text-[var(--text-icon)]')} />
),
renderDropdownItem: (props) => <DefaultDropdownItem {...props} />,
renderDropdownItem: (props) => <IconDropdownItem {...props} icon={Database} />,
},
} as const

View File

@@ -34,7 +34,7 @@ export type WindowWithSpeech = Window & {
}
export interface PlusMenuHandle {
open: () => void
open: (anchor?: { left: number; top: number }) => void
}
export const TEXTAREA_BASE_CLASSES = cn(

View File

@@ -1,6 +1,6 @@
'use client'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import React, { useCallback, useMemo, useRef, useState } from 'react'
import { Paperclip } from 'lucide-react'
import {
DropdownMenu,
@@ -13,7 +13,6 @@ import {
DropdownMenuTrigger,
} from '@/components/emcn'
import { Plus, Sim } from '@/components/emcn/icons'
import { cn } from '@/lib/core/utils/cn'
import type { useAvailableResources } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown'
import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
import type { PlusMenuHandle } from '@/app/workspace/[workspaceId]/home/components/user-input/components/constants'
@@ -37,24 +36,24 @@ export const PlusMenuDropdown = React.memo(
) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const [activeIndex, setActiveIndex] = useState(0)
const activeIndexRef = useRef(activeIndex)
const [anchorPos, setAnchorPos] = useState<{ left: number; top: number } | null>(null)
const buttonRef = useRef<HTMLButtonElement>(null)
const searchRef = useRef<HTMLInputElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
useEffect(() => {
activeIndexRef.current = activeIndex
}, [activeIndex])
const doOpen = useCallback((anchor?: { left: number; top: number }) => {
if (anchor) {
setAnchorPos(anchor)
} else {
const rect = buttonRef.current?.getBoundingClientRect()
if (!rect) return
setAnchorPos({ left: rect.left, top: rect.top })
}
setOpen(true)
setSearch('')
}, [])
React.useImperativeHandle(
ref,
() => ({
open: () => {
setOpen(true)
setSearch('')
setActiveIndex(0)
},
}),
[]
)
React.useImperativeHandle(ref, () => ({ open: doOpen }), [doOpen])
const filteredItems = useMemo(() => {
const q = search.toLowerCase().trim()
@@ -69,7 +68,6 @@ export const PlusMenuDropdown = React.memo(
onResourceSelect(resource)
setOpen(false)
setSearch('')
setActiveIndex(0)
},
[onResourceSelect]
)
@@ -79,32 +77,37 @@ export const PlusMenuDropdown = React.memo(
const handleSearchKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
const items = filteredItemsRef.current
if (!items) return
if (e.key === 'ArrowDown') {
e.preventDefault()
setActiveIndex((prev) => Math.min(prev + 1, items.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setActiveIndex((prev) => Math.max(prev - 1, 0))
const firstItem = contentRef.current?.querySelector<HTMLElement>('[role="menuitem"]')
firstItem?.focus()
} else if (e.key === 'Enter') {
e.preventDefault()
const idx = activeIndexRef.current
if (items.length > 0 && items[idx]) {
const { type, item } = items[idx]
handleSelect({ type, id: item.id, title: item.name })
}
const first = filteredItemsRef.current?.[0]
if (first) handleSelect({ type: first.type, id: first.item.id, title: first.item.name })
}
},
[handleSelect]
)
const handleContentKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'ArrowUp') {
const items = Array.from(
contentRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? []
)
if (items[0] && items[0] === document.activeElement) {
e.preventDefault()
searchRef.current?.focus()
}
}
}, [])
const handleOpenChange = useCallback(
(isOpen: boolean) => {
setOpen(isOpen)
if (!isOpen) {
setSearch('')
setActiveIndex(0)
setAnchorPos(null)
onClose()
}
},
@@ -126,126 +129,138 @@ export const PlusMenuDropdown = React.memo(
)
return (
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>
<button
type='button'
className='flex h-[28px] w-[28px] cursor-pointer items-center justify-center rounded-full border border-[#F0F0F0] transition-colors hover:bg-[#F7F7F7] dark:border-[#3d3d3d] dark:hover:bg-[#303030]'
title='Add attachments or resources'
<>
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>
<div
style={{
position: 'fixed',
left: anchorPos?.left ?? 0,
top: anchorPos?.top ?? 0,
width: 0,
height: 0,
pointerEvents: 'none',
}}
/>
</DropdownMenuTrigger>
<DropdownMenuContent
ref={contentRef}
align='start'
side='top'
sideOffset={8}
className='flex w-[240px] flex-col overflow-hidden'
onCloseAutoFocus={handleCloseAutoFocus}
onKeyDown={handleContentKeyDown}
>
<Plus className='h-[16px] w-[16px] text-[var(--text-icon)]' />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
side='top'
sideOffset={8}
className='flex w-[240px] flex-col overflow-hidden'
onCloseAutoFocus={handleCloseAutoFocus}
>
<DropdownMenuSearchInput
placeholder='Search resources...'
value={search}
onChange={(e) => {
setSearch(e.target.value)
setActiveIndex(0)
}}
onKeyDown={handleSearchKeyDown}
/>
<div className='min-h-0 flex-1 overflow-y-auto'>
{filteredItems ? (
filteredItems.length > 0 ? (
filteredItems.map(({ type, item }, index) => {
const config = getResourceConfig(type)
return (
<DropdownMenuItem
key={`${type}:${item.id}`}
className={cn(index === activeIndex && 'bg-[var(--surface-active)]')}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => {
handleSelect({
type,
id: item.id,
title: item.name,
})
}}
>
{config.renderDropdownItem({ item })}
<span className='ml-auto pl-2 text-[11px] text-[var(--text-tertiary)]'>
{config.label}
</span>
</DropdownMenuItem>
)
})
<DropdownMenuSearchInput
ref={searchRef}
placeholder='Search resources...'
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={handleSearchKeyDown}
/>
<div className='min-h-0 flex-1 overflow-y-auto'>
{filteredItems ? (
filteredItems.length > 0 ? (
filteredItems.map(({ type, item }, index) => {
const config = getResourceConfig(type)
return (
<DropdownMenuItem
key={`${type}:${item.id}`}
onClick={() => {
handleSelect({
type,
id: item.id,
title: item.name,
})
}}
>
{config.renderDropdownItem({ item })}
<span className='ml-auto pl-2 text-[11px] text-[var(--text-tertiary)]'>
{config.label}
</span>
</DropdownMenuItem>
)
})
) : (
<div className='px-2 py-[5px] text-center font-medium text-[12px] text-[var(--text-tertiary)]'>
No results
</div>
)
) : (
<div className='px-2 py-[5px] text-center font-medium text-[12px] text-[var(--text-tertiary)]'>
No results
</div>
)
) : (
<>
<DropdownMenuItem
onClick={() => {
setOpen(false)
onFileSelect()
}}
>
<Paperclip className='h-[14px] w-[14px]' strokeWidth={2} />
<span>Attachments</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Sim className='h-[14px] w-[14px]' fill='currentColor' />
<span>Workspace</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{availableResources.map(({ type, items }) => {
if (items.length === 0) return null
const config = getResourceConfig(type)
const Icon = config.icon
return (
<DropdownMenuSub key={type}>
<DropdownMenuSubTrigger>
{type === 'workflow' ? (
<div
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
style={{
backgroundColor: '#808080',
borderColor: '#80808060',
backgroundClip: 'padding-box',
}}
/>
) : (
<Icon className='h-[14px] w-[14px]' />
)}
<span>{config.label}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{items.map((item) => (
<DropdownMenuItem
key={item.id}
onClick={() => {
handleSelect({
type,
id: item.id,
title: item.name,
})
}}
>
{config.renderDropdownItem({ item })}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
</>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
<>
<DropdownMenuItem
onClick={() => {
setOpen(false)
onFileSelect()
}}
>
<Paperclip className='h-[14px] w-[14px]' strokeWidth={2} />
<span>Attachments</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Sim className='h-[14px] w-[14px]' fill='currentColor' />
<span>Workspace</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{availableResources.map(({ type, items }) => {
if (items.length === 0) return null
const config = getResourceConfig(type)
const Icon = config.icon
return (
<DropdownMenuSub key={type}>
<DropdownMenuSubTrigger>
{type === 'workflow' ? (
<div
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
style={{
backgroundColor: '#808080',
borderColor: '#80808060',
backgroundClip: 'padding-box',
}}
/>
) : (
<Icon className='h-[14px] w-[14px]' />
)}
<span>{config.label}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{items.map((item) => (
<DropdownMenuItem
key={item.id}
onClick={() => {
handleSelect({
type,
id: item.id,
title: item.name,
})
}}
>
{config.renderDropdownItem({ item })}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
</>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
<button
ref={buttonRef}
type='button'
onClick={() => doOpen()}
className='flex h-[28px] w-[28px] cursor-pointer items-center justify-center rounded-full border border-[#F0F0F0] transition-colors hover:bg-[#F7F7F7] dark:border-[#3d3d3d] dark:hover:bg-[#303030]'
title='Add attachments or resources'
>
<Plus className='h-[16px] w-[16px] text-[var(--text-icon)]' />
</button>
</>
)
})
)

View File

@@ -50,6 +50,50 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
export type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/types'
function getCaretAnchor(
textarea: HTMLTextAreaElement,
caretPos: number
): { left: number; top: number } {
const textareaRect = textarea.getBoundingClientRect()
const style = window.getComputedStyle(textarea)
const mirror = document.createElement('div')
mirror.style.position = 'absolute'
mirror.style.top = '0'
mirror.style.left = '0'
mirror.style.visibility = 'hidden'
mirror.style.whiteSpace = 'pre-wrap'
mirror.style.overflowWrap = 'break-word'
mirror.style.font = style.font
mirror.style.padding = style.padding
mirror.style.border = style.border
mirror.style.width = style.width
mirror.style.lineHeight = style.lineHeight
mirror.style.boxSizing = style.boxSizing
mirror.style.letterSpacing = style.letterSpacing
mirror.style.textTransform = style.textTransform
mirror.style.textIndent = style.textIndent
mirror.style.textAlign = style.textAlign
mirror.textContent = textarea.value.substring(0, caretPos)
const marker = document.createElement('span')
marker.style.display = 'inline-block'
marker.style.width = '0px'
marker.style.padding = '0'
marker.style.border = '0'
mirror.appendChild(marker)
document.body.appendChild(mirror)
const markerRect = marker.getBoundingClientRect()
const mirrorRect = mirror.getBoundingClientRect()
document.body.removeChild(mirror)
return {
left: textareaRect.left + (markerRect.left - mirrorRect.left) - textarea.scrollLeft,
top: textareaRect.top + (markerRect.top - mirrorRect.top) - textarea.scrollTop,
}
}
interface UserInputProps {
defaultValue?: string
editValue?: string
@@ -486,7 +530,8 @@ export function UserInput({
const adjusted = `${before}${after}`
setValue(adjusted)
atInsertPosRef.current = caret - 1
plusMenuRef.current?.open()
const anchor = getCaretAnchor(e.target, caret - 1)
plusMenuRef.current?.open(anchor)
restartRecognition(adjusted)
return
}
@@ -522,6 +567,28 @@ export function UserInput({
[isInitialView]
)
const handlePaste = useCallback((e: React.ClipboardEvent<HTMLTextAreaElement>) => {
const items = e.clipboardData?.items
if (!items) return
const imageFiles: File[] = []
for (const item of Array.from(items)) {
if (item.kind === 'file' && item.type.startsWith('image/')) {
const file = item.getAsFile()
if (file) imageFiles.push(file)
}
}
if (imageFiles.length === 0) return
e.preventDefault()
const dt = new DataTransfer()
for (const file of imageFiles) {
dt.items.add(file)
}
filesRef.current.processFiles(dt.files)
}, [])
const handleScroll = useCallback((e: React.UIEvent<HTMLTextAreaElement>) => {
if (overlayRef.current) {
overlayRef.current.scrollTop = e.currentTarget.scrollTop
@@ -661,6 +728,7 @@ export function UserInput({
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onInput={handleInput}
onPaste={handlePaste}
onCut={mentionTokensWithContext.handleCut}
onSelect={handleSelectAdjust}
onMouseUp={handleSelectAdjust}

View File

@@ -54,6 +54,7 @@ export function Home({ chatId }: HomeProps = {}) {
description,
color,
workspaceId,
deduplicate: true,
}),
})

View File

@@ -98,6 +98,7 @@ export type MothershipToolName =
| 'create_job'
| 'complete_job'
| 'update_job_history'
| 'job_respond'
| 'download_to_workspace_file'
| 'materialize_file'
| 'context_write'
@@ -393,6 +394,7 @@ export const TOOL_UI_METADATA: Record<MothershipToolName, ToolUIMetadata> = {
create_job: { title: 'Creating job', phaseLabel: 'Resource', phase: 'resource' },
manage_job: { title: 'Updating job', phaseLabel: 'Management', phase: 'management' },
update_job_history: { title: 'Updating job', phaseLabel: 'Management', phase: 'management' },
job_respond: { title: 'Explaining job scheduled', phaseLabel: 'Execution', phase: 'execution' },
// Management
manage_mcp_tool: { title: 'Updating integration', phaseLabel: 'Management', phase: 'management' },
manage_skill: { title: 'Updating skill', phaseLabel: 'Management', phase: 'management' },

View File

@@ -1138,9 +1138,12 @@ export function Document({
<span className='font-medium text-[var(--text-primary)]'>
{effectiveDocumentName}
</span>
? This will permanently delete the document and all {documentData?.chunkCount ?? 0}{' '}
chunk
{documentData?.chunkCount === 1 ? '' : 's'} within it.{' '}
?{' '}
<span className='text-[var(--text-error)]'>
This will permanently delete the document and all {documentData?.chunkCount ?? 0}{' '}
chunk
{documentData?.chunkCount === 1 ? '' : 's'} within it.
</span>{' '}
{documentData?.connectorId ? (
<span className='text-[var(--text-error)]'>
This document is synced from a connector. Deleting it will permanently exclude it

View File

@@ -1106,8 +1106,10 @@ export function KnowledgeBase({
<p className='text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>{knowledgeBaseName}</span>?
The knowledge base and all {pagination.total} document
{pagination.total === 1 ? '' : 's'} within it will be removed.{' '}
<span className='text-[var(--text-error)]'>
The knowledge base and all {pagination.total} document
{pagination.total === 1 ? '' : 's'} within it will be removed.
</span>{' '}
<span className='text-[var(--text-tertiary)]'>
You can restore it from Recently Deleted in Settings.
</span>
@@ -1147,7 +1149,9 @@ export function KnowledgeBase({
it from future syncs. To temporarily hide it from search, disable it instead.
</span>
) : (
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
<span className='text-[var(--text-error)]'>
This will permanently delete the document.
</span>
)}
</p>
)
@@ -1177,7 +1181,10 @@ export function KnowledgeBase({
<p className='text-[var(--text-secondary)]'>
Are you sure you want to delete {selectedDocuments.size} document
{selectedDocuments.size === 1 ? '' : 's'}?{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
<span className='text-[var(--text-error)]'>
This will permanently delete the selected document
{selectedDocuments.size === 1 ? '' : 's'}.
</span>
</p>
</ModalBody>
<ModalFooter>

View File

@@ -373,7 +373,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
<div className='flex flex-col gap-3'>
{/* Auth: API key input or OAuth credential selection */}
{isApiKeyMode ? (
<div className='flex flex-col gap-1'>
<div className='flex flex-col gap-2'>
<Label>
{connectorConfig.auth.mode === 'apiKey' && connectorConfig.auth.label
? connectorConfig.auth.label
@@ -394,7 +394,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
/>
</div>
) : (
<div className='flex flex-col gap-1'>
<div className='flex flex-col gap-2'>
<Label>Account</Label>
{credentialsLoading ? (
<div className='flex items-center gap-2 text-[var(--text-muted)] text-small'>
@@ -442,7 +442,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
canonicalId && (canonicalGroups.get(canonicalId)?.length ?? 0) === 2
return (
<div key={field.id} className='flex flex-col gap-1'>
<div key={field.id} className='flex flex-col gap-2'>
<div className='flex items-center justify-between'>
<Label>
{field.title}
@@ -507,7 +507,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
{/* Tag definitions (opt-out) */}
{connectorConfig.tagDefinitions && connectorConfig.tagDefinitions.length > 0 && (
<div className='flex flex-col gap-1'>
<div className='flex flex-col gap-2'>
<Label>Metadata Tags</Label>
{connectorConfig.tagDefinitions.map((tagDef) => (
<div
@@ -550,7 +550,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
)}
{/* Sync interval */}
<div className='flex flex-col gap-1'>
<div className='flex flex-col gap-2'>
<Label>Sync Frequency</Label>
<ButtonGroup
value={String(syncInterval)}

View File

@@ -416,9 +416,11 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
<ModalBody>
<div className='space-y-2'>
<p className='text-[var(--text-secondary)]'>
Are you sure you want to delete the "{selectedTag?.displayName}" tag? This will
remove this tag from {selectedTagUsage?.documentCount || 0} document
{selectedTagUsage?.documentCount !== 1 ? 's' : ''}.{' '}
Are you sure you want to delete the "{selectedTag?.displayName}" tag?{' '}
<span className='text-[var(--text-error)]'>
This will remove this tag from {selectedTagUsage?.documentCount || 0} document
{selectedTagUsage?.documentCount !== 1 ? 's' : ''}.
</span>{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>

View File

@@ -18,6 +18,7 @@ import {
import {
Badge,
Button,
Checkbox,
Modal,
ModalBody,
ModalContent,
@@ -73,12 +74,32 @@ export function ConnectorsSection({
isLoading,
canEdit,
}: ConnectorsSectionProps) {
const { mutate: triggerSync, isPending: isSyncing } = useTriggerSync()
const { mutate: updateConnector, isPending: isUpdating } = useUpdateConnector()
const { mutate: triggerSync } = useTriggerSync()
const { mutate: updateConnector } = useUpdateConnector()
const { mutate: deleteConnector, isPending: isDeleting } = useDeleteConnector()
const [deleteTarget, setDeleteTarget] = useState<string | null>(null)
const [deleteDocuments, setDeleteDocuments] = useState(false)
const closeDeleteModal = useCallback(() => {
setDeleteTarget(null)
setDeleteDocuments(false)
}, [])
const [editingConnector, setEditingConnector] = useState<ConnectorData | null>(null)
const [error, setError] = useState<string | null>(null)
const [syncingIds, setSyncingIds] = useState<Set<string>>(() => new Set())
const [updatingIds, setUpdatingIds] = useState<Set<string>>(() => new Set())
const addToSet = useCallback((setter: typeof setSyncingIds, id: string) => {
setter((prev) => new Set(prev).add(id))
}, [])
const removeFromSet = useCallback((setter: typeof setSyncingIds, id: string) => {
setter((prev) => {
const next = new Set(prev)
next.delete(id)
return next
})
}, [])
const syncTriggeredAt = useRef<Record<string, number>>({})
const cooldownTimers = useRef<Set<ReturnType<typeof setTimeout>>>(new Set())
@@ -103,6 +124,7 @@ export function ConnectorsSection({
if (isSyncOnCooldown(connectorId)) return
syncTriggeredAt.current[connectorId] = Date.now()
addToSet(setSyncingIds, connectorId)
triggerSync(
{ knowledgeBaseId, connectorId },
@@ -121,10 +143,35 @@ export function ConnectorsSection({
delete syncTriggeredAt.current[connectorId]
forceUpdate((n) => n + 1)
},
onSettled: () => removeFromSet(setSyncingIds, connectorId),
}
)
},
[knowledgeBaseId, triggerSync, isSyncOnCooldown]
[knowledgeBaseId, triggerSync, isSyncOnCooldown, addToSet, removeFromSet]
)
const handleTogglePause = useCallback(
(connector: ConnectorData) => {
addToSet(setUpdatingIds, connector.id)
updateConnector(
{
knowledgeBaseId,
connectorId: connector.id,
updates: {
status: connector.status === 'paused' ? 'active' : 'paused',
},
},
{
onSettled: () => removeFromSet(setUpdatingIds, connector.id),
onSuccess: () => setError(null),
onError: (err) => {
logger.error('Toggle pause failed', { error: err.message })
setError(err.message)
},
}
)
},
[knowledgeBaseId, updateConnector, addToSet, removeFromSet]
)
if (connectors.length === 0 && !canEdit && !isLoading) return null
@@ -163,28 +210,11 @@ export function ConnectorsSection({
workspaceId={workspaceId}
knowledgeBaseId={knowledgeBaseId}
canEdit={canEdit}
isSyncing={isSyncing}
isUpdating={isUpdating}
isSyncPending={syncingIds.has(connector.id)}
isUpdating={updatingIds.has(connector.id)}
syncCooldown={isSyncOnCooldown(connector.id)}
onSync={() => handleSync(connector.id)}
onTogglePause={() =>
updateConnector(
{
knowledgeBaseId,
connectorId: connector.id,
updates: {
status: connector.status === 'paused' ? 'active' : 'paused',
},
},
{
onSuccess: () => setError(null),
onError: (err) => {
logger.error('Toggle pause failed', { error: err.message })
setError(err.message)
},
}
)
}
onTogglePause={() => handleTogglePause(connector)}
onEdit={() => setEditingConnector(connector)}
onDelete={() => setDeleteTarget(connector.id)}
/>
@@ -201,17 +231,30 @@ export function ConnectorsSection({
/>
)}
<Modal open={deleteTarget !== null} onOpenChange={() => setDeleteTarget(null)}>
<Modal open={deleteTarget !== null} onOpenChange={closeDeleteModal}>
<ModalContent size='sm'>
<ModalHeader>Delete Connector</ModalHeader>
<ModalHeader>Remove Connector</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)] text-sm'>
Are you sure you want to remove this connected source? Documents already synced will
remain in the knowledge base.
This will disconnect the source and stop future syncs. Documents already synced will
remain in the knowledge base unless you choose to delete them.
</p>
<div className='mt-3 flex items-center gap-2'>
<Checkbox
id='delete-docs'
checked={deleteDocuments}
onCheckedChange={(checked) => setDeleteDocuments(checked === true)}
/>
<label
htmlFor='delete-docs'
className='cursor-pointer text-[var(--text-secondary)] text-sm'
>
Also delete all synced documents
</label>
</div>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => setDeleteTarget(null)} disabled={isDeleting}>
<Button variant='default' onClick={closeDeleteModal} disabled={isDeleting}>
Cancel
</Button>
<Button
@@ -220,23 +263,23 @@ export function ConnectorsSection({
onClick={() => {
if (deleteTarget) {
deleteConnector(
{ knowledgeBaseId, connectorId: deleteTarget },
{ knowledgeBaseId, connectorId: deleteTarget, deleteDocuments },
{
onSuccess: () => {
setError(null)
setDeleteTarget(null)
closeDeleteModal()
},
onError: (err) => {
logger.error('Delete connector failed', { error: err.message })
setError(err.message)
setDeleteTarget(null)
closeDeleteModal()
},
}
)
}
}}
>
{isDeleting ? 'Deleting...' : 'Delete'}
{isDeleting ? 'Removing...' : 'Remove'}
</Button>
</ModalFooter>
</ModalContent>
@@ -250,7 +293,7 @@ interface ConnectorCardProps {
workspaceId: string
knowledgeBaseId: string
canEdit: boolean
isSyncing: boolean
isSyncPending: boolean
isUpdating: boolean
syncCooldown: boolean
onSync: () => void
@@ -264,7 +307,7 @@ function ConnectorCard({
workspaceId,
knowledgeBaseId,
canEdit,
isSyncing,
isSyncPending,
isUpdating,
syncCooldown,
onSync,
@@ -306,13 +349,13 @@ function ConnectorCard({
{Icon && <Icon className='h-5 w-5 flex-shrink-0' />}
<div className='flex flex-col gap-0.5'>
<div className='flex items-center gap-2'>
<span className='font-medium text-[var(--text-primary)] text-small'>
<span className='flex items-center gap-1.5 font-medium text-[var(--text-primary)] text-small'>
{connectorDef?.name || connector.connectorType}
{(isSyncPending || connector.status === 'syncing') && (
<Loader2 className='h-3 w-3 animate-spin text-[var(--text-muted)]' />
)}
</span>
<Badge variant={statusConfig.variant} className='text-micro'>
{connector.status === 'syncing' && (
<Loader2 className='mr-1 h-3 w-3 animate-spin' />
)}
{statusConfig.label}
</Badge>
</div>
@@ -356,7 +399,7 @@ function ConnectorCard({
variant='ghost'
className='h-7 w-7 p-0'
onClick={onSync}
disabled={connector.status === 'syncing' || isSyncing || syncCooldown}
disabled={connector.status === 'syncing' || isSyncPending || syncCooldown}
>
<RefreshCw
className={cn(

View File

@@ -198,7 +198,7 @@ function SettingsTab({
return (
<div className='flex flex-col gap-3'>
{connectorConfig?.configFields.map((field) => (
<div key={field.id} className='flex flex-col gap-1'>
<div key={field.id} className='flex flex-col gap-2'>
<Label>
{field.title}
{field.required && <span className='ml-0.5 text-[var(--text-error)]'>*</span>}
@@ -227,7 +227,7 @@ function SettingsTab({
</div>
))}
<div className='flex flex-col gap-1'>
<div className='flex flex-col gap-2'>
<Label>Sync Frequency</Label>
<ButtonGroup
value={String(syncInterval)}

View File

@@ -1,6 +1,6 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { memo, useEffect, useRef, useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { createLogger } from '@sim/logger'
import { Loader2, RotateCcw, X } from 'lucide-react'
@@ -78,7 +78,10 @@ interface SubmitStatus {
message: string
}
export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
export const CreateBaseModal = memo(function CreateBaseModal({
open,
onOpenChange,
}: CreateBaseModalProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
@@ -543,4 +546,4 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
</ModalContent>
</Modal>
)
}
})

View File

@@ -1,5 +1,6 @@
'use client'
import { memo } from 'react'
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
interface DeleteKnowledgeBaseModalProps {
@@ -29,7 +30,7 @@ interface DeleteKnowledgeBaseModalProps {
* Delete confirmation modal for knowledge base items.
* Displays a warning message and confirmation buttons.
*/
export function DeleteKnowledgeBaseModal({
export const DeleteKnowledgeBaseModal = memo(function DeleteKnowledgeBaseModal({
isOpen,
onClose,
onConfirm,
@@ -46,10 +47,17 @@ export function DeleteKnowledgeBaseModal({
<>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>{knowledgeBaseName}</span>?
All associated documents, chunks, and embeddings will be removed.
<span className='text-[var(--text-error)]'>
All associated documents, chunks, and embeddings will be removed.
</span>
</>
) : (
'Are you sure you want to delete this knowledge base? All associated documents, chunks, and embeddings will be removed.'
<>
Are you sure you want to delete this knowledge base?{' '}
<span className='text-[var(--text-error)]'>
All associated documents, chunks, and embeddings will be removed.
</span>
</>
)}{' '}
<span className='text-[var(--text-tertiary)]'>
You can restore it from Recently Deleted in Settings.
@@ -67,4 +75,4 @@ export function DeleteKnowledgeBaseModal({
</ModalContent>
</Modal>
)
}
})

View File

@@ -1,6 +1,6 @@
'use client'
import { useEffect, useState } from 'react'
import { memo, useEffect, useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { createLogger } from '@sim/logger'
import { useForm } from 'react-hook-form'
@@ -43,7 +43,7 @@ type FormValues = z.infer<typeof FormSchema>
/**
* Modal for editing knowledge base name and description
*/
export function EditKnowledgeBaseModal({
export const EditKnowledgeBaseModal = memo(function EditKnowledgeBaseModal({
open,
onOpenChange,
knowledgeBaseId,
@@ -172,4 +172,4 @@ export function EditKnowledgeBaseModal({
</ModalContent>
</Modal>
)
}
})

View File

@@ -1,5 +1,6 @@
'use client'
import { memo } from 'react'
import {
DropdownMenu,
DropdownMenuContent,
@@ -30,7 +31,7 @@ interface KnowledgeBaseContextMenuProps {
* Context menu component for knowledge base cards.
* Displays open in new tab, view tags, edit, and delete options.
*/
export function KnowledgeBaseContextMenu({
export const KnowledgeBaseContextMenu = memo(function KnowledgeBaseContextMenu({
isOpen,
position,
onClose,
@@ -114,4 +115,4 @@ export function KnowledgeBaseContextMenu({
</DropdownMenuContent>
</DropdownMenu>
)
}
})

View File

@@ -1,5 +1,6 @@
'use client'
import { memo } from 'react'
import {
DropdownMenu,
DropdownMenuContent,
@@ -20,7 +21,7 @@ interface KnowledgeListContextMenuProps {
* Context menu component for the knowledge base list page.
* Displays "Add knowledge base" option when right-clicking on empty space.
*/
export function KnowledgeListContextMenu({
export const KnowledgeListContextMenu = memo(function KnowledgeListContextMenu({
isOpen,
position,
onClose,
@@ -58,4 +59,4 @@ export function KnowledgeListContextMenu({
</DropdownMenuContent>
</DropdownMenu>
)
}
})

View File

@@ -1,11 +1,18 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams, useRouter } from 'next/navigation'
import { Tooltip } from '@/components/emcn'
import { Database } from '@/components/emcn/icons'
import type { KnowledgeBaseData } from '@/lib/knowledge/types'
import type { ResourceColumn, ResourceRow } from '@/app/workspace/[workspaceId]/components'
import type {
CreateAction,
ResourceCell,
ResourceColumn,
ResourceRow,
SearchConfig,
} from '@/app/workspace/[workspaceId]/components'
import { ownerCell, Resource, timeCell } from '@/app/workspace/[workspaceId]/components'
import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
import {
@@ -18,10 +25,10 @@ import {
import { filterKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/utils/sort'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { CONNECTOR_REGISTRY } from '@/connectors/registry'
import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge'
import { useDeleteKnowledgeBase, useUpdateKnowledgeBase } from '@/hooks/queries/kb/knowledge'
import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace'
import { useDebounce } from '@/hooks/use-debounce'
const logger = createLogger('Knowledge')
@@ -33,11 +40,48 @@ const COLUMNS: ResourceColumn[] = [
{ id: 'name', header: 'Name' },
{ id: 'documents', header: 'Documents' },
{ id: 'tokens', header: 'Tokens' },
{ id: 'connectors', header: 'Connectors' },
{ id: 'created', header: 'Created' },
{ id: 'owner', header: 'Owner' },
{ id: 'updated', header: 'Last Updated' },
]
const DATABASE_ICON = <Database className='h-[14px] w-[14px]' />
function connectorCell(connectorTypes?: string[]): ResourceCell {
if (!connectorTypes || connectorTypes.length === 0) {
return { label: '—' }
}
const entries = connectorTypes
.map((type) => ({ type, def: CONNECTOR_REGISTRY[type] }))
.filter((e): e is { type: string; def: NonNullable<(typeof CONNECTOR_REGISTRY)[string]> } =>
Boolean(e.def?.icon)
)
if (entries.length === 0) return { label: '—' }
return {
content: (
<div className='flex items-center gap-1'>
{entries.map(({ type, def }) => {
const Icon = def.icon
return (
<Tooltip.Root key={type}>
<Tooltip.Trigger asChild>
<span className='flex-shrink-0'>
<Icon className='h-3.5 w-3.5' />
</span>
</Tooltip.Trigger>
<Tooltip.Content>{def.name}</Tooltip.Content>
</Tooltip.Root>
)
})}
</div>
),
}
}
export function Knowledge() {
const params = useParams()
const router = useRouter()
@@ -54,8 +98,22 @@ export function Knowledge() {
const { mutateAsync: updateKnowledgeBaseMutation } = useUpdateKnowledgeBase(workspaceId)
const { mutateAsync: deleteKnowledgeBaseMutation } = useDeleteKnowledgeBase(workspaceId)
const [searchQuery, setSearchQuery] = useState('')
const debouncedSearchQuery = useDebounce(searchQuery, 300)
const [searchInputValue, setSearchInputValue] = useState('')
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('')
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(null)
const handleSearchChange = useCallback((value: string) => {
setSearchInputValue(value)
if (searchTimerRef.current) clearTimeout(searchTimerRef.current)
searchTimerRef.current = setTimeout(() => {
setDebouncedSearchQuery(value)
}, 300)
}, [])
const handleSearchClearAll = useCallback(() => {
handleSearchChange('')
}, [handleSearchChange])
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
const [activeKnowledgeBase, setActiveKnowledgeBase] = useState<KnowledgeBaseWithDocCount | null>(
@@ -69,7 +127,6 @@ export function Knowledge() {
const {
isOpen: isListContextMenuOpen,
position: listContextMenuPosition,
menuRef: listMenuRef,
handleContextMenu: handleListContextMenu,
closeMenu: closeListContextMenu,
} = useContextMenu()
@@ -77,11 +134,19 @@ export function Knowledge() {
const {
isOpen: isRowContextMenuOpen,
position: rowContextMenuPosition,
menuRef: rowMenuRef,
handleContextMenu: handleRowCtxMenu,
closeMenu: closeRowContextMenu,
} = useContextMenu()
const isRowContextMenuOpenRef = useRef(isRowContextMenuOpen)
isRowContextMenuOpenRef.current = isRowContextMenuOpen
const knowledgeBasesRef = useRef(knowledgeBases)
knowledgeBasesRef.current = knowledgeBases
const activeKnowledgeBaseRef = useRef(activeKnowledgeBase)
activeKnowledgeBaseRef.current = activeKnowledgeBase
const handleContentContextMenu = useCallback(
(e: React.MouseEvent) => {
const target = e.target as HTMLElement
@@ -96,7 +161,7 @@ export function Knowledge() {
[handleListContextMenu]
)
const handleAddKnowledgeBase = useCallback(() => {
const handleOpenCreateModal = useCallback(() => {
setIsCreateModalOpen(true)
}, [])
@@ -132,7 +197,7 @@ export function Knowledge() {
id: kb.id,
cells: {
name: {
icon: <Database className='h-[14px] w-[14px]' />,
icon: DATABASE_ICON,
label: kb.name,
},
documents: {
@@ -141,6 +206,7 @@ export function Knowledge() {
tokens: {
label: kb.tokenCount ? kb.tokenCount.toLocaleString() : '0',
},
connectors: connectorCell(kb.connectorTypes),
created: timeCell(kb.createdAt),
owner: ownerCell(kb.userId, members),
updated: timeCell(kb.updatedAt),
@@ -148,6 +214,7 @@ export function Knowledge() {
sortValues: {
documents: kbWithCount.docCount || 0,
tokens: kb.tokenCount || 0,
connectors: kb.connectorTypes?.length || 0,
created: -new Date(kb.createdAt).getTime(),
updated: -new Date(kb.updatedAt).getTime(),
},
@@ -158,51 +225,98 @@ export function Knowledge() {
const handleRowClick = useCallback(
(rowId: string) => {
if (isRowContextMenuOpen) return
const kb = knowledgeBases.find((k) => k.id === rowId)
if (isRowContextMenuOpenRef.current) return
const kb = knowledgeBasesRef.current.find((k) => k.id === rowId)
if (!kb) return
const urlParams = new URLSearchParams({ kbName: kb.name })
router.push(`/workspace/${workspaceId}/knowledge/${rowId}?${urlParams.toString()}`)
},
[isRowContextMenuOpen, knowledgeBases, router, workspaceId]
[router, workspaceId]
)
const handleRowContextMenu = useCallback(
(e: React.MouseEvent, rowId: string) => {
const kb = knowledgeBases.find((k) => k.id === rowId) as KnowledgeBaseWithDocCount | undefined
const kb = knowledgeBasesRef.current.find((k) => k.id === rowId) as
| KnowledgeBaseWithDocCount
| undefined
setActiveKnowledgeBase(kb ?? null)
handleRowCtxMenu(e)
},
[knowledgeBases, handleRowCtxMenu]
[handleRowCtxMenu]
)
const handleConfirmDelete = useCallback(async () => {
if (!activeKnowledgeBase) return
const kb = activeKnowledgeBaseRef.current
if (!kb) return
setIsDeleting(true)
try {
await handleDeleteKnowledgeBase(activeKnowledgeBase.id)
await handleDeleteKnowledgeBase(kb.id)
setIsDeleteModalOpen(false)
setActiveKnowledgeBase(null)
} finally {
setIsDeleting(false)
}
}, [activeKnowledgeBase, handleDeleteKnowledgeBase])
}, [handleDeleteKnowledgeBase])
const handleCloseDeleteModal = useCallback(() => {
setIsDeleteModalOpen(false)
setActiveKnowledgeBase(null)
}, [])
const handleOpenInNewTab = useCallback(() => {
const kb = activeKnowledgeBaseRef.current
if (!kb) return
const urlParams = new URLSearchParams({ kbName: kb.name })
window.open(`/workspace/${workspaceId}/knowledge/${kb.id}?${urlParams.toString()}`, '_blank')
}, [workspaceId])
const handleViewTags = useCallback(() => {
setIsTagsModalOpen(true)
}, [])
const handleCopyId = useCallback(() => {
const kb = activeKnowledgeBaseRef.current
if (kb) {
navigator.clipboard.writeText(kb.id)
}
}, [])
const handleEdit = useCallback(() => {
setIsEditModalOpen(true)
}, [])
const handleDelete = useCallback(() => {
setIsDeleteModalOpen(true)
}, [])
const canEdit = userPermissions.canEdit === true
const createAction: CreateAction = useMemo(
() => ({
label: 'New base',
onClick: handleOpenCreateModal,
disabled: !canEdit,
}),
[handleOpenCreateModal, canEdit]
)
const searchConfig: SearchConfig = useMemo(
() => ({
value: searchInputValue,
onChange: handleSearchChange,
onClearAll: handleSearchClearAll,
placeholder: 'Search knowledge bases...',
}),
[searchInputValue, handleSearchChange, handleSearchClearAll]
)
return (
<>
<Resource
icon={Database}
title='Knowledge Base'
create={{
label: 'New base',
onClick: () => setIsCreateModalOpen(true),
disabled: userPermissions.canEdit !== true,
}}
search={{
value: searchQuery,
onChange: setSearchQuery,
placeholder: 'Search knowledge bases...',
}}
create={createAction}
search={searchConfig}
defaultSort='created'
columns={COLUMNS}
rows={rows}
@@ -216,8 +330,8 @@ export function Knowledge() {
isOpen={isListContextMenuOpen}
position={listContextMenuPosition}
onClose={closeListContextMenu}
onAddKnowledgeBase={handleAddKnowledgeBase}
disableAdd={userPermissions.canEdit !== true}
onAddKnowledgeBase={handleOpenCreateModal}
disableAdd={!canEdit}
/>
{activeKnowledgeBase && (
@@ -225,23 +339,17 @@ export function Knowledge() {
isOpen={isRowContextMenuOpen}
position={rowContextMenuPosition}
onClose={closeRowContextMenu}
onOpenInNewTab={() => {
const urlParams = new URLSearchParams({ kbName: activeKnowledgeBase.name })
window.open(
`/workspace/${workspaceId}/knowledge/${activeKnowledgeBase.id}?${urlParams.toString()}`,
'_blank'
)
}}
onViewTags={() => setIsTagsModalOpen(true)}
onCopyId={() => navigator.clipboard.writeText(activeKnowledgeBase.id)}
onEdit={() => setIsEditModalOpen(true)}
onDelete={() => setIsDeleteModalOpen(true)}
onOpenInNewTab={handleOpenInNewTab}
onViewTags={handleViewTags}
onCopyId={handleCopyId}
onEdit={handleEdit}
onDelete={handleDelete}
showOpenInNewTab
showViewTags
showEdit
showDelete
disableEdit={!userPermissions.canEdit}
disableDelete={!userPermissions.canEdit}
disableEdit={!canEdit}
disableDelete={!canEdit}
/>
)}
@@ -259,10 +367,7 @@ export function Knowledge() {
{activeKnowledgeBase && (
<DeleteKnowledgeBaseModal
isOpen={isDeleteModalOpen}
onClose={() => {
setIsDeleteModalOpen(false)
setActiveKnowledgeBase(null)
}}
onClose={handleCloseDeleteModal}
onConfirm={handleConfirmDelete}
isDeleting={isDeleting}
knowledgeBaseName={activeKnowledgeBase.name}

View File

@@ -1268,7 +1268,9 @@ export const NotificationSettings = memo(function NotificationSettings({
<ModalHeader>Delete Notification</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)] text-caption'>
This will permanently remove the notification and stop all deliveries.{' '}
<span className='text-[var(--text-error)]'>
This will permanently remove the notification and stop all deliveries.
</span>{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>

View File

@@ -57,14 +57,26 @@ function parseShortcut(shortcut: string): ParsedShortcut {
}
}
/**
* Maps a KeyboardEvent.code value to the logical key name used in shortcut definitions.
* Needed for international keyboard layouts where e.key may produce unexpected characters
* (e.g. macOS Option+letter yields 'å' instead of 'a', dead keys yield 'Dead').
*/
function codeToKey(code: string): string | undefined {
if (code.startsWith('Key')) return code.slice(3).toLowerCase()
if (code.startsWith('Digit')) return code.slice(5)
return undefined
}
function matchesShortcut(e: KeyboardEvent, parsed: ParsedShortcut): boolean {
const isMac = isMacPlatform()
const expectedCtrl = parsed.ctrl || (parsed.mod ? !isMac : false)
const expectedMeta = parsed.meta || (parsed.mod ? isMac : false)
const eventKey = e.key.length === 1 ? e.key.toLowerCase() : e.key
const keyMatches = eventKey === parsed.key || codeToKey(e.code) === parsed.key
return (
eventKey === parsed.key &&
keyMatches &&
!!e.ctrlKey === !!expectedCtrl &&
!!e.metaKey === !!expectedMeta &&
!!e.shiftKey === !!parsed.shift &&

View File

@@ -203,3 +203,35 @@ export function useUserPermissionsContext(): WorkspaceUserPermissions & {
const { userPermissions } = useWorkspacePermissionsContext()
return userPermissions
}
/**
* Lightweight permissions provider for sandbox/academy contexts.
* Grants full edit access without any API calls or workspace dependencies.
*/
export function SandboxWorkspacePermissionsProvider({ children }: { children: React.ReactNode }) {
const sandboxPermissions = useMemo(
(): WorkspacePermissionsContextType => ({
workspacePermissions: null,
permissionsLoading: false,
permissionsError: null,
updatePermissions: () => {},
refetchPermissions: async () => {},
userPermissions: {
canRead: true,
canEdit: true,
canAdmin: false,
userPermissions: 'write',
isLoading: false,
error: null,
isOfflineMode: false,
},
}),
[]
)
return (
<WorkspacePermissionsContext.Provider value={sandboxPermissions}>
{children}
</WorkspacePermissionsContext.Provider>
)
}

View File

@@ -371,8 +371,10 @@ export function ApiKeys() {
<ModalBody>
<p className='text-[var(--text-secondary)]'>
Deleting{' '}
<span className='font-medium text-[var(--text-primary)]'>{deleteKey?.name}</span> will
immediately revoke access for any integrations using it.{' '}
<span className='font-medium text-[var(--text-primary)]'>{deleteKey?.name}</span>{' '}
<span className='text-[var(--text-error)]'>
will immediately revoke access for any integrations using it.
</span>{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>

View File

@@ -404,7 +404,10 @@ export function BYOK() {
<span className='font-medium text-[var(--text-primary)]'>
{PROVIDERS.find((p) => p.id === deleteConfirmProvider)?.name}
</span>{' '}
API key? This workspace will revert to using platform hosted keys.
API key?{' '}
<span className='text-[var(--text-error)]'>
This workspace will revert to using platform hosted keys.
</span>
</p>
</ModalBody>
<ModalFooter>

View File

@@ -366,7 +366,9 @@ export function Copilot() {
<span className='font-medium text-[var(--text-primary)]'>
{deleteKey?.name || 'Unnamed Key'}
</span>{' '}
will immediately revoke access for any integrations using it.{' '}
<span className='text-[var(--text-error)]'>
will immediately revoke access for any integrations using it.
</span>{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>

View File

@@ -0,0 +1,162 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import {
Button,
ButtonGroup,
ButtonGroupItem,
Combobox,
type ComboboxOption,
Input as EmcnInput,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Textarea,
} from '@/components/emcn'
import { FormField } from '@/app/workspace/[workspaceId]/settings/components/mcp/components'
import { useCreateWorkflowMcpServer } from '@/hooks/queries/workflow-mcp-servers'
const logger = createLogger('CreateWorkflowMcpServerModal')
const INITIAL_FORM_DATA: { name: string; description: string; isPublic: boolean } = {
name: '',
description: '',
isPublic: false,
}
interface CreateWorkflowMcpServerModalProps {
open: boolean
onOpenChange: (open: boolean) => void
workspaceId: string
workflowOptions?: ComboboxOption[]
isLoadingWorkflows?: boolean
}
export function CreateWorkflowMcpServerModal({
open,
onOpenChange,
workspaceId,
workflowOptions,
isLoadingWorkflows = false,
}: CreateWorkflowMcpServerModalProps) {
const createServerMutation = useCreateWorkflowMcpServer()
const [formData, setFormData] = useState({ ...INITIAL_FORM_DATA })
const [selectedWorkflowIds, setSelectedWorkflowIds] = useState<string[]>([])
const isFormValid = formData.name.trim().length > 0
useEffect(() => {
if (open) {
setFormData({ ...INITIAL_FORM_DATA })
setSelectedWorkflowIds([])
}
}, [open])
const handleCreateServer = useCallback(async () => {
if (!formData.name.trim()) return
try {
await createServerMutation.mutateAsync({
workspaceId,
name: formData.name.trim(),
description: formData.description.trim() || undefined,
isPublic: formData.isPublic,
workflowIds: selectedWorkflowIds.length > 0 ? selectedWorkflowIds : undefined,
})
onOpenChange(false)
} catch (err) {
logger.error('Failed to create server:', err)
}
}, [formData, selectedWorkflowIds, workspaceId, onOpenChange])
const showWorkflows = workflowOptions !== undefined
return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent>
<ModalHeader>Add New MCP Server</ModalHeader>
<ModalBody>
<div className='flex flex-col gap-3'>
<FormField label='Server Name'>
<EmcnInput
placeholder='e.g., My MCP Server'
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className='h-9'
/>
</FormField>
<FormField label='Description'>
<Textarea
placeholder='Describe what this MCP server does (optional)'
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className='min-h-[60px] resize-none'
/>
</FormField>
{showWorkflows && (
<FormField label='Workflows'>
<Combobox
options={workflowOptions ?? []}
multiSelect
multiSelectValues={selectedWorkflowIds}
onMultiSelectChange={setSelectedWorkflowIds}
placeholder='Select workflows...'
searchable
searchPlaceholder='Search workflows...'
isLoading={isLoadingWorkflows}
disabled={createServerMutation.isPending}
emptyMessage='No deployed workflows available'
overlayContent={
selectedWorkflowIds.length > 0 ? (
<span className='text-[var(--text-primary)]'>
{selectedWorkflowIds.length} workflow
{selectedWorkflowIds.length !== 1 ? 's' : ''} selected
</span>
) : undefined
}
/>
</FormField>
)}
<FormField label='Access'>
<div className='flex items-center gap-3'>
<ButtonGroup
value={formData.isPublic ? 'public' : 'private'}
onValueChange={(value) =>
setFormData({ ...formData, isPublic: value === 'public' })
}
>
<ButtonGroupItem value='private'>API Key</ButtonGroupItem>
<ButtonGroupItem value='public'>Public</ButtonGroupItem>
</ButtonGroup>
{formData.isPublic && (
<span className='text-[var(--text-muted)] text-xs'>
No authentication required
</span>
)}
</div>
</FormField>
</div>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={handleCreateServer}
disabled={!isFormValid || createServerMutation.isPending}
variant='primary'
>
{createServerMutation.isPending ? 'Adding...' : 'Add Server'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@@ -35,7 +35,6 @@ import { useApiKeys } from '@/hooks/queries/api-keys'
import { useCreateMcpServer } from '@/hooks/queries/mcp'
import {
useAddWorkflowMcpTool,
useCreateWorkflowMcpServer,
useDeleteWorkflowMcpServer,
useDeleteWorkflowMcpTool,
useDeployedWorkflows,
@@ -49,6 +48,7 @@ import {
import { useWorkspaceSettings } from '@/hooks/queries/workspace'
import { CreateApiKeyModal } from '../api-keys/components'
import { FormField, McpServerSkeleton } from '../mcp/components'
import { CreateWorkflowMcpServerModal } from './create-workflow-mcp-server-modal'
const logger = createLogger('WorkflowMcpServers')
@@ -955,13 +955,10 @@ export function WorkflowMcpServers() {
const { data: servers = [], isLoading, error } = useWorkflowMcpServers(workspaceId)
const { data: deployedWorkflows = [], isLoading: isLoadingWorkflows } =
useDeployedWorkflows(workspaceId)
const createServerMutation = useCreateWorkflowMcpServer()
const deleteServerMutation = useDeleteWorkflowMcpServer()
const [searchTerm, setSearchTerm] = useState('')
const [showAddModal, setShowAddModal] = useState(false)
const [formData, setFormData] = useState({ name: '', description: '', isPublic: false })
const [selectedWorkflowIds, setSelectedWorkflowIds] = useState<string[]>([])
const [selectedServerId, setSelectedServerId] = useState<string | null>(null)
const [serverToDelete, setServerToDelete] = useState<WorkflowMcpServer | null>(null)
const [deletingServers, setDeletingServers] = useState<Set<string>>(() => new Set())
@@ -979,29 +976,6 @@ export function WorkflowMcpServers() {
}))
}, [deployedWorkflows])
const resetForm = useCallback(() => {
setFormData({ name: '', description: '', isPublic: false })
setSelectedWorkflowIds([])
setShowAddModal(false)
}, [])
const handleCreateServer = async () => {
if (!formData.name.trim()) return
try {
await createServerMutation.mutateAsync({
workspaceId,
name: formData.name.trim(),
description: formData.description.trim() || undefined,
isPublic: formData.isPublic,
workflowIds: selectedWorkflowIds.length > 0 ? selectedWorkflowIds : undefined,
})
resetForm()
} catch (err) {
logger.error('Failed to create server:', err)
}
}
const handleDeleteServer = async () => {
if (!serverToDelete) return
@@ -1026,7 +1000,6 @@ export function WorkflowMcpServers() {
const hasServers = servers.length > 0
const showNoResults = searchTerm.trim() && filteredServers.length === 0 && hasServers
const isFormValid = formData.name.trim().length > 0
if (selectedServerId) {
return (
@@ -1123,86 +1096,13 @@ export function WorkflowMcpServers() {
</div>
</div>
<Modal open={showAddModal} onOpenChange={(open) => !open && resetForm()}>
<ModalContent>
<ModalHeader>Add New MCP Server</ModalHeader>
<ModalBody>
<div className='flex flex-col gap-3'>
<FormField label='Server Name'>
<EmcnInput
placeholder='e.g., My MCP Server'
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className='h-9'
/>
</FormField>
<FormField label='Description'>
<Textarea
placeholder='Describe what this MCP server does (optional)'
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className='min-h-[60px] resize-none'
/>
</FormField>
<FormField label='Workflows'>
<Combobox
options={workflowOptions}
multiSelect
multiSelectValues={selectedWorkflowIds}
onMultiSelectChange={setSelectedWorkflowIds}
placeholder='Select workflows...'
searchable
searchPlaceholder='Search workflows...'
isLoading={isLoadingWorkflows}
disabled={createServerMutation.isPending}
emptyMessage='No deployed workflows available'
overlayContent={
selectedWorkflowIds.length > 0 ? (
<span className='text-[var(--text-primary)]'>
{selectedWorkflowIds.length} workflow
{selectedWorkflowIds.length !== 1 ? 's' : ''} selected
</span>
) : undefined
}
/>
</FormField>
<FormField label='Access'>
<div className='flex items-center gap-3'>
<ButtonGroup
value={formData.isPublic ? 'public' : 'private'}
onValueChange={(value) =>
setFormData({ ...formData, isPublic: value === 'public' })
}
>
<ButtonGroupItem value='private'>API Key</ButtonGroupItem>
<ButtonGroupItem value='public'>Public</ButtonGroupItem>
</ButtonGroup>
{formData.isPublic && (
<span className='text-[var(--text-muted)] text-xs'>
No authentication required
</span>
)}
</div>
</FormField>
</div>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={resetForm}>
Cancel
</Button>
<Button
onClick={handleCreateServer}
disabled={!isFormValid || createServerMutation.isPending}
variant='primary'
>
{createServerMutation.isPending ? 'Adding...' : 'Add Server'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<CreateWorkflowMcpServerModal
open={showAddModal}
onOpenChange={setShowAddModal}
workspaceId={workspaceId}
workflowOptions={workflowOptions}
isLoadingWorkflows={isLoadingWorkflows}
/>
<Modal open={!!serverToDelete} onOpenChange={(open) => !open && setServerToDelete(null)}>
<ModalContent size='sm'>

View File

@@ -164,8 +164,10 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess
)}
<p className='text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
{isSingleRow ? 'this row' : `these ${deleteCount} rows`}? This will permanently remove
all data in {isSingleRow ? 'this row' : 'these rows'}.{' '}
{isSingleRow ? 'this row' : `these ${deleteCount} rows`}?{' '}
<span className='text-[var(--text-error)]'>
This will permanently remove all data in {isSingleRow ? 'this row' : 'these rows'}.
</span>{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>

View File

@@ -1809,6 +1809,9 @@ export function Table({
<p className='text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>{tableData?.name}</span>?{' '}
<span className='text-[var(--text-error)]'>
All {tableData?.rowCount ?? 0} rows will be removed.
</span>{' '}
<span className='text-[var(--text-tertiary)]'>
You can restore it from Recently Deleted in Settings.
</span>
@@ -1845,8 +1848,10 @@ export function Table({
<ModalBody>
<p className='text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>{deletingColumn}</span>? This
will remove all data in this column.{' '}
<span className='font-medium text-[var(--text-primary)]'>{deletingColumn}</span>?{' '}
<span className='text-[var(--text-error)]'>
This will remove all data in this column.
</span>{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>

View File

@@ -320,8 +320,10 @@ export function Tables() {
<ModalBody>
<p className='text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>{activeTable?.name}</span>?
All {activeTable?.rowCount} rows will be removed.{' '}
<span className='font-medium text-[var(--text-primary)]'>{activeTable?.name}</span>?{' '}
<span className='text-[var(--text-error)]'>
All {activeTable?.rowCount} rows will be removed.
</span>{' '}
<span className='text-[var(--text-tertiary)]'>
You can restore it from Recently Deleted in Settings.
</span>

View File

@@ -9,6 +9,8 @@ import type { GlobalCommand } from '@/app/workspace/[workspaceId]/providers/glob
export type CommandId =
| 'accept-diff-changes'
| 'add-agent'
| 'add-workflow'
| 'add-task'
// | 'goto-templates'
| 'goto-logs'
| 'open-search'
@@ -52,6 +54,16 @@ export const COMMAND_DEFINITIONS: Record<CommandId, CommandDefinition> = {
shortcut: 'Mod+Shift+A',
allowInEditable: true,
},
'add-workflow': {
id: 'add-workflow',
shortcut: 'Mod+Shift+P',
allowInEditable: false,
},
'add-task': {
id: 'add-task',
shortcut: 'Mod+Shift+K',
allowInEditable: false,
},
// 'goto-templates': {
// id: 'goto-templates',
// shortcut: 'Mod+Y',

View File

@@ -929,7 +929,7 @@ export function Chat() {
>
{shouldShowConfigureStartInputsButton && (
<div
className='flex flex-none cursor-pointer items-center whitespace-nowrap rounded-md border border-[var(--border-1)] bg-[var(--surface-5)] px-2.5 py-0.5 font-medium font-sans text-[var(--text-primary)] text-caption hover-hover:bg-[var(--surface-7)] dark:hover-hover:border-[var(--surface-7)] dark:hover-hover:bg-[var(--border-1)]'
className='flex flex-none cursor-pointer items-center whitespace-nowrap rounded-md border border-[var(--border-1)] bg-[var(--surface-5)] px-2.5 py-0.5 font-medium font-sans text-[var(--text-primary)] text-caption hover-hover:bg-[var(--surface-active)]'
title='Add chat inputs to Start block'
onMouseDown={(e) => {
e.stopPropagation()

View File

@@ -320,5 +320,6 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
handleDragOver,
handleDrop,
clearAttachedFiles,
processFiles,
}
}

View File

@@ -883,7 +883,7 @@ console.log(data);`
</p>
{missingFields.any && (
<div
className='flex flex-none cursor-pointer items-center whitespace-nowrap rounded-md border border-[var(--border-1)] bg-[var(--surface-5)] px-[9px] py-0.5 font-medium font-sans text-[var(--text-primary)] text-caption hover-hover:bg-[var(--surface-7)] dark:hover-hover:border-[var(--surface-7)] dark:hover-hover:bg-[var(--border-1)]'
className='flex flex-none cursor-pointer items-center whitespace-nowrap rounded-md border border-[var(--border-1)] bg-[var(--surface-5)] px-[9px] py-0.5 font-medium font-sans text-[var(--text-primary)] text-caption hover-hover:bg-[var(--surface-active)]'
title='Add required A2A input fields to Start block'
onClick={handleAddA2AInputs}
>

View File

@@ -17,8 +17,7 @@ import { generateToolInputSchema, sanitizeToolName } from '@/lib/mcp/workflow-to
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import type { InputFormatField } from '@/lib/workflows/types'
import { McpServerFormModal } from '@/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal'
import { useAllowedMcpDomains, useCreateMcpServer } from '@/hooks/queries/mcp'
import { CreateWorkflowMcpServerModal } from '@/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/create-workflow-mcp-server-modal'
import {
useAddWorkflowMcpTool,
useDeleteWorkflowMcpTool,
@@ -28,7 +27,6 @@ import {
type WorkflowMcpServer,
type WorkflowMcpTool,
} from '@/hooks/queries/workflow-mcp-servers'
import { useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -102,11 +100,7 @@ export function McpDeploy({
}: McpDeployProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const [showMcpModal, setShowMcpModal] = useState(false)
const createMcpServer = useCreateMcpServer()
const { data: allowedMcpDomains = null } = useAllowedMcpDomains()
const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
const [showCreateModal, setShowCreateModal] = useState(false)
const { data: servers = [], isLoading: isLoadingServers } = useWorkflowMcpServers(workspaceId)
const addToolMutation = useAddWorkflowMcpTool()
@@ -473,22 +467,16 @@ export function McpDeploy({
<>
<div className='flex h-full flex-col items-center justify-center gap-3'>
<p className='text-[13px] text-[var(--text-muted)]'>
Create an MCP Server in Settings MCP Servers first.
Create an MCP Server to expose your workflows as tools.
</p>
<Button variant='tertiary' onClick={() => setShowMcpModal(true)}>
<Button variant='tertiary' onClick={() => setShowCreateModal(true)}>
Create MCP Server
</Button>
</div>
<McpServerFormModal
open={showMcpModal}
onOpenChange={setShowMcpModal}
mode='add'
onSubmit={async (config) => {
await createMcpServer.mutateAsync({ workspaceId, config: { ...config, enabled: true } })
}}
<CreateWorkflowMcpServerModal
open={showCreateModal}
onOpenChange={setShowCreateModal}
workspaceId={workspaceId}
availableEnvVars={availableEnvVars}
allowedMcpDomains={allowedMcpDomains}
/>
</>
)

View File

@@ -280,7 +280,7 @@ export const EnvVarDropdown: React.FC<EnvVarDropdownProps> = ({
}
return (
<Popover open={visible} onOpenChange={(open) => !open && onClose?.()}>
<Popover open={visible} onOpenChange={(open) => !open && onClose?.()} colorScheme='inverted'>
<PopoverAnchor asChild>
<div
className={cn('pointer-events-none', className)}

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