diff --git a/.agents/skills/add-enrichment/SKILL.md b/.agents/skills/add-enrichment/SKILL.md
index 7b34e4c7c38..44c3e9f95da 100644
--- a/.agents/skills/add-enrichment/SKILL.md
+++ b/.agents/skills/add-enrichment/SKILL.md
@@ -63,7 +63,7 @@ Why it matters: the cascade runner only bills (and only reads `output.cost.total
Create `apps/sim/enrichments/{name}/{name}.ts` and a barrel `index.ts`. Mirror the existing entries (`work-email`, `phone-number`, `company-domain`, `company-info`).
```typescript
-import { SomeIcon } from 'lucide-react'
+import { SomeIcon } from '@sim/emcn/icons'
import { filterUndefined } from '@sim/utils/object'
import { normalizeDomain, splitName, str, toolProvider } from '@/enrichments/providers'
import type { EnrichmentConfig } from '@/enrichments/types'
@@ -109,7 +109,7 @@ export { myEnrichment } from './my-enrichment'
```
Rules:
-- Keep the file **client-safe**: import only `lucide-react`, `@sim/utils/*`, `@/enrichments/providers`, and the types. **Never import `@/tools`** here — the runner does the tool call.
+- Keep the file **client-safe**: import only `@sim/emcn/icons`, `@sim/utils/*`, `@/enrichments/providers`, and the types. **Never import `@/tools`** here — the runner does the tool call.
- `buildParams` returns `null` when inputs are insufficient (provider skipped). `mapOutput` returns `null`/empty for a miss (falls through). Use `filterUndefined` when assembling optional tool params; coerce numbers explicitly (don't pass `''` to number outputs).
- Output `id`s are the keys `mapOutput` returns; output `name`s are the default column names (the user can rename them in the config).
diff --git a/.agents/skills/emcn-design-review/SKILL.md b/.agents/skills/emcn-design-review/SKILL.md
index 89ae8d47843..78253e5e772 100644
--- a/.agents/skills/emcn-design-review/SKILL.md
+++ b/.agents/skills/emcn-design-review/SKILL.md
@@ -18,7 +18,7 @@ This codebase uses **emcn**, a custom component library built on Radix UI primit
## Steps
-1. Read the emcn public barrel at `apps/sim/components/emcn/index.ts` (re-exports components, Calendar, Table*, and icons) to know what's available; for the full icon set read `apps/sim/components/emcn/icons/index.ts`
+1. Read the emcn public barrel at `packages/emcn/src/index.ts` (re-exports components, Calendar, Table*, and icons) to know what's available; for the full icon set read `packages/emcn/src/icons/index.ts`
2. Read `apps/sim/app/_styles/globals.css` for CSS variable tokens
3. Analyze the specified scope against every rule below
4. If fix=true, apply the fixes. If fix=false, propose the fixes without applying.
@@ -28,7 +28,7 @@ This codebase uses **emcn**, a custom component library built on Radix UI primit
## Imports
- Import from `@/components/emcn` barrel, never subpaths
-- Icons from `@/components/emcn/icons` or `lucide-react`
+- Icons from `@sim/emcn/icons`
- Use `cn` from `@/lib/core/utils/cn` for conditional classes
## Design Tokens
@@ -45,7 +45,7 @@ Use CSS variable pattern (`text-[var(--text-primary)]`), never Tailwind semantic
## Buttons
-Intent-to-variant mapping (read the actual `buttonVariants` in `apps/sim/components/emcn/components/button/button.tsx` for the full variant set — it exposes more than listed here):
+Intent-to-variant mapping (read the actual `buttonVariants` in `packages/emcn/src/components/button/button.tsx` for the full variant set — it exposes more than listed here):
| Action | Variant |
|--------|---------|
diff --git a/.claude/commands/add-enrichment.md b/.claude/commands/add-enrichment.md
index b0beef265cf..c23d001f70a 100644
--- a/.claude/commands/add-enrichment.md
+++ b/.claude/commands/add-enrichment.md
@@ -62,7 +62,7 @@ Why it matters: the cascade runner only bills (and only reads `output.cost.total
Create `apps/sim/enrichments/{name}/{name}.ts` and a barrel `index.ts`. Mirror the existing entries (`work-email`, `phone-number`, `company-domain`, `company-info`).
```typescript
-import { SomeIcon } from 'lucide-react'
+import { SomeIcon } from '@sim/emcn/icons'
import { filterUndefined } from '@sim/utils/object'
import { normalizeDomain, splitName, str, toolProvider } from '@/enrichments/providers'
import type { EnrichmentConfig } from '@/enrichments/types'
@@ -108,7 +108,7 @@ export { myEnrichment } from './my-enrichment'
```
Rules:
-- Keep the file **client-safe**: import only `lucide-react`, `@sim/utils/*`, `@/enrichments/providers`, and the types. **Never import `@/tools`** here — the runner does the tool call.
+- Keep the file **client-safe**: import only `@sim/emcn/icons`, `@sim/utils/*`, `@/enrichments/providers`, and the types. **Never import `@/tools`** here — the runner does the tool call.
- `buildParams` returns `null` when inputs are insufficient (provider skipped). `mapOutput` returns `null`/empty for a miss (falls through). Use `filterUndefined` when assembling optional tool params; coerce numbers explicitly (don't pass `''` to number outputs).
- Output `id`s are the keys `mapOutput` returns; output `name`s are the default column names (the user can rename them in the config).
diff --git a/.claude/commands/emcn-design-review.md b/.claude/commands/emcn-design-review.md
index 741c02c64b9..1a5c562facd 100644
--- a/.claude/commands/emcn-design-review.md
+++ b/.claude/commands/emcn-design-review.md
@@ -17,7 +17,7 @@ This codebase uses **emcn**, a custom component library built on Radix UI primit
## Steps
-1. Read the emcn public barrel at `apps/sim/components/emcn/index.ts` (re-exports components, Calendar, Table*, and icons) to know what's available; for the full icon set read `apps/sim/components/emcn/icons/index.ts`
+1. Read the emcn public barrel at `packages/emcn/src/index.ts` (re-exports components, Calendar, Table*, and icons) to know what's available; for the full icon set read `packages/emcn/src/icons/index.ts`
2. Read `apps/sim/app/_styles/globals.css` for CSS variable tokens
3. Analyze the specified scope against every rule below
4. If fix=true, apply the fixes. If fix=false, propose the fixes without applying.
@@ -27,7 +27,7 @@ This codebase uses **emcn**, a custom component library built on Radix UI primit
## Imports
- Import from `@/components/emcn` barrel, never subpaths
-- Icons from `@/components/emcn/icons` or `lucide-react`
+- Icons from `@sim/emcn/icons`
- Use `cn` from `@/lib/core/utils/cn` for conditional classes
## Design Tokens
@@ -44,7 +44,7 @@ Use CSS variable pattern (`text-[var(--text-primary)]`), never Tailwind semantic
## Buttons
-Intent-to-variant mapping (read the actual `buttonVariants` in `apps/sim/components/emcn/components/button/button.tsx` for the full variant set — it exposes more than listed here):
+Intent-to-variant mapping (read the actual `buttonVariants` in `packages/emcn/src/components/button/button.tsx` for the full variant set — it exposes more than listed here):
| Action | Variant |
|--------|---------|
diff --git a/.claude/rules/emcn-components.md b/.claude/rules/emcn-components.md
index 23491d6aaaf..5ca3d2a8dcb 100644
--- a/.claude/rules/emcn-components.md
+++ b/.claude/rules/emcn-components.md
@@ -1,6 +1,6 @@
---
paths:
- - "apps/sim/components/emcn/**"
+ - "packages/emcn/**"
---
# EMCN Components
@@ -20,7 +20,7 @@ The menu surface intentionally diverges from the pill: `dropdown-menu.tsx` items
## Component catalogue
-- **`Chip` / `ChipLink`** — the pill button (`
)
}
@@ -75,9 +56,7 @@ function NavItem({
)}
>
-
- {label}
-
+ {label}
)
}
@@ -133,12 +112,7 @@ export function LandingPreviewSidebar({
)}
>
-
- Home
-
+ Home
@@ -182,10 +156,7 @@ export function LandingPreviewSidebar({
)}
>
-
+
{workflow.name}
diff --git a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-stage/landing-preview-stage-header.tsx b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-stage/landing-preview-stage-header.tsx
index ff5d55dbd40..d6ab860810b 100644
--- a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-stage/landing-preview-stage-header.tsx
+++ b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-stage/landing-preview-stage-header.tsx
@@ -1,5 +1,4 @@
-import { X } from '@sim/emcn'
-import { PanelRight, Workflow } from 'lucide-react'
+import { PanelRight, Workflow, X } from '@sim/emcn/icons'
interface LandingPreviewStageHeaderProps {
/** The staged resource's display name. */
@@ -11,7 +10,7 @@ interface LandingPreviewStageHeaderProps {
* (44px, `px-4`, `gap-1.5`) that sits above the workflow canvas in the "chat
* everywhere" layout. There is no tab strip and no Deploy/Run: a workflow's
* panel actions are `null` in the real header, so it carries only the staged
- * resource's identity - the lucide `Workflow` mark (`size-[14px]`, `--text-icon`)
+ * resource's identity - the emcn `Workflow` mark (`size-[14px]`, `--text-icon`)
* and its name in chip geometry - plus the panel's close + collapse controls on
* the right. Aligns to the chat pane's title bar so the two read as one header
* row across the split.
diff --git a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-workflow/preview-block-node.tsx b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-workflow/preview-block-node.tsx
index 7f1d33beba9..027fa0c6157 100644
--- a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-workflow/preview-block-node.tsx
+++ b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-workflow/preview-block-node.tsx
@@ -2,8 +2,8 @@
import { memo } from 'react'
import { Blimp } from '@sim/emcn'
+import { Database } from '@sim/emcn/icons'
import { domAnimation, LazyMotion, m } from 'framer-motion'
-import { Database } from 'lucide-react'
import { Handle, type NodeProps, Position } from 'reactflow'
import {
AgentIcon,
diff --git a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data.ts b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data.ts
index 0309c6dadb1..e105e5e7cba 100644
--- a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data.ts
+++ b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data.ts
@@ -401,10 +401,6 @@ export const RESOURCE_CHATS: Record = {
user: 'What’s in our knowledge base?',
assistant: 'Here are your knowledge bases. Your agents read from these to ground every answer.',
},
- 'scheduled-tasks': {
- user: 'What’s scheduled to run?',
- assistant: 'These are your scheduled tasks. I can pause, edit, or add a new one for you.',
- },
}
/** The chat shown for a staged resource: the workflow's own exchange, else the view's. */
diff --git a/apps/sim/app/(landing)/components/landing-preview/landing-preview.tsx b/apps/sim/app/(landing)/components/landing-preview/landing-preview.tsx
index 1fe20a74aca..ef0b25d078c 100644
--- a/apps/sim/app/(landing)/components/landing-preview/landing-preview.tsx
+++ b/apps/sim/app/(landing)/components/landing-preview/landing-preview.tsx
@@ -7,7 +7,6 @@ import { LandingPreviewFiles } from '@/app/(landing)/components/landing-preview/
import { LandingPreviewHome } from '@/app/(landing)/components/landing-preview/components/landing-preview-home/landing-preview-home'
import { LandingPreviewKnowledge } from '@/app/(landing)/components/landing-preview/components/landing-preview-knowledge/landing-preview-knowledge'
import { LandingPreviewLogs } from '@/app/(landing)/components/landing-preview/components/landing-preview-logs/landing-preview-logs'
-import { LandingPreviewScheduledTasks } from '@/app/(landing)/components/landing-preview/components/landing-preview-scheduled-tasks/landing-preview-scheduled-tasks'
import type { SidebarView } from '@/app/(landing)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar'
import { LandingPreviewSidebar } from '@/app/(landing)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar'
import { LandingPreviewStageHeader } from '@/app/(landing)/components/landing-preview/components/landing-preview-stage/landing-preview-stage-header'
@@ -26,7 +25,6 @@ const CHAT_TITLES: Partial> = {
tables: 'Workspace data',
files: 'Files',
knowledge: 'Knowledge base',
- 'scheduled-tasks': 'Scheduled tasks',
}
const containerVariants: Variants = {
@@ -108,7 +106,7 @@ interface LandingPreviewProps {
/**
* Initial staged view for the static snapshot (`autoplay={false}`). Defaults
* to `'workflow'`. Lets each feature stage show the platform surface that
- * matches its callout (e.g. `'logs'`, `'scheduled-tasks'`).
+ * matches its callout (e.g. `'logs'`, `'tables'`).
*/
initialView?: SidebarView
/** Initial workflow for the static snapshot. Defaults to the first preview workflow. */
@@ -326,15 +324,6 @@ export function LandingPreview({
)}
- {activeView === 'scheduled-tasks' && (
-
-
-
- )}
) : activeView === 'tables' ? (
@@ -344,8 +333,6 @@ export function LandingPreview({
) : activeView === 'logs' ? (
- ) : activeView === 'scheduled-tasks' ? (
-
) : (
)}
diff --git a/apps/sim/app/(landing)/components/navbar/components/mobile-nav/mobile-nav.tsx b/apps/sim/app/(landing)/components/navbar/components/mobile-nav/mobile-nav.tsx
index 4790edb28d1..c91b65f72fe 100644
--- a/apps/sim/app/(landing)/components/navbar/components/mobile-nav/mobile-nav.tsx
+++ b/apps/sim/app/(landing)/components/navbar/components/mobile-nav/mobile-nav.tsx
@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react'
import { ChipLink, cn } from '@sim/emcn'
-import { Menu, X } from 'lucide-react'
+import { Menu, X } from '@sim/emcn/icons'
import Link from 'next/link'
import { GithubOutlineIcon } from '@/components/icons'
import { NAV_MENUS } from '@/app/(landing)/components/navbar/components/nav-menu-chip'
@@ -161,7 +161,6 @@ export function MobileNav({ stars }: MobileNavProps) {
variant='border'
href='/login'
fullWidth
- flush
prefetch={false}
className='h-[40px] justify-center [&>span]:flex-none'
onClick={() => setOpen(false)}
@@ -172,7 +171,6 @@ export function MobileNav({ stars }: MobileNavProps) {
variant='primary'
href={DEMO_HREF}
fullWidth
- flush
className='h-[40px] justify-center [&>span]:flex-none'
onClick={() => setOpen(false)}
>
diff --git a/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/constants.ts b/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/constants.ts
index b598d046e72..31675b86ec0 100644
--- a/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/constants.ts
+++ b/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/constants.ts
@@ -27,11 +27,6 @@ export const PLATFORM_MENU: NavMenu = {
description: 'One file store for team and agents',
href: '/files',
},
- {
- title: 'Scheduled Tasks',
- description: 'Run agents on a cadence',
- href: '/scheduled-tasks',
- },
{
title: 'Logs',
description: 'Trace every agent decision',
diff --git a/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/nav-menu-chip.tsx b/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/nav-menu-chip.tsx
index 3036158a3a4..f0d71450684 100644
--- a/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/nav-menu-chip.tsx
+++ b/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/nav-menu-chip.tsx
@@ -1,7 +1,7 @@
'use client'
import { useState } from 'react'
-import { ChipChevronDown, chipContentLabelClass, chipGeometryClass, cn } from '@sim/emcn'
+import { ChipChevronDown, chipContentLabelClass, chipVariants, cn } from '@sim/emcn'
import { NavMenuItem } from '@/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-item'
import type { NavMenu } from '@/app/(landing)/components/navbar/components/nav-menu-chip/types'
@@ -68,8 +68,9 @@ export function NavMenuChip({ menu }: NavMenuChipProps) {
aria-label={`${label} menu`}
onFocus={reArm}
className={cn(
- chipGeometryClass,
- 'mx-0.5 inline-flex cursor-pointer transition-colors hover-hover:bg-[var(--surface-active)]',
+ chipVariants(),
+ /* Held open by the wrapper's state, not the button's own hover, so the
+ panel and its trigger light together. */
'group-focus-within/navmenu:bg-[var(--surface-active)] group-hover/navmenu:bg-[var(--surface-active)]'
)}
>
diff --git a/apps/sim/app/(landing)/components/navbar/navbar.tsx b/apps/sim/app/(landing)/components/navbar/navbar.tsx
index 2cf7458cac6..19a67772f3a 100644
--- a/apps/sim/app/(landing)/components/navbar/navbar.tsx
+++ b/apps/sim/app/(landing)/components/navbar/navbar.tsx
@@ -19,10 +19,10 @@ import { DEMO_HREF, SIGNUP_HREF } from '@/app/(landing)/constants'
* {@link NavbarShell} (which frosts the bar to glass on scroll) are isolated
* client leaves, so the wordmark and links stay zero-hydration, crawlable HTML.
*
- * Every item is a bare emcn chip. Both clusters use `gap-1`, which with
- * the chips' own `mx-0.5` margins yields 8px between pills; the nav's
- * `gap-3.5` (14px) plus the first chip's 2px margin puts exactly 16px -
- * twice the inter-chip gap - between the wordmark and the first menu chip.
+ * Every item is a bare emcn chip. Chips carry no margin of their own, so both
+ * clusters' `gap-1` is the full 4px between pills, and the nav's own `gap-4`
+ * is the full 16px between the wordmark and the first menu chip - twice the
+ * inter-chip gap. Only that first gap is live: the trailing cluster is `ml-auto`.
* Horizontal padding (`px-20`, 48px) matches every section's edge gutter,
* and the bar content is capped and centered at the shared
* `max-w-[1460px]` (1300px content + the two 80px gutters) so the wordmark
@@ -59,7 +59,7 @@ export function Navbar({ stars, logoOnly = false }: NavbarProps) {
aria-label='Primary navigation'
itemScope
itemType='https://schema.org/SiteNavigationElement'
- className='relative mx-auto flex w-full max-w-[1460px] items-center gap-3.5 px-20 py-4 max-sm:px-5 max-lg:px-8'
+ className='relative mx-auto flex w-full max-w-[1460px] items-center gap-4 px-20 py-4 max-sm:px-5 max-lg:px-8'
>
diff --git a/apps/sim/app/(landing)/components/share-button/share-button.tsx b/apps/sim/app/(landing)/components/share-button/share-button.tsx
index 8d0bb6dc095..b95f1ee81ba 100644
--- a/apps/sim/app/(landing)/components/share-button/share-button.tsx
+++ b/apps/sim/app/(landing)/components/share-button/share-button.tsx
@@ -9,8 +9,7 @@ import {
TRIGGER_BORDER_CLASS,
useCopyToClipboard,
} from '@sim/emcn'
-import { Duplicate } from '@sim/emcn/icons'
-import { Share2 } from 'lucide-react'
+import { Duplicate, Share } from '@sim/emcn/icons'
import { LinkedInIcon, xIcon as XIcon } from '@/components/icons'
interface ShareButtonProps {
@@ -35,7 +34,7 @@ export function ShareButton({ url, title }: ShareButtonProps) {
return (
-
+
Share
diff --git a/apps/sim/app/(landing)/components/shared/editor-loop/editor-loop.tsx b/apps/sim/app/(landing)/components/shared/editor-loop/editor-loop.tsx
index ecbe334e528..47cbcd1e50c 100644
--- a/apps/sim/app/(landing)/components/shared/editor-loop/editor-loop.tsx
+++ b/apps/sim/app/(landing)/components/shared/editor-loop/editor-loop.tsx
@@ -43,7 +43,7 @@ interface EditorLoopProps {
/**
* The chat-free sibling of the enterprise platform loop, shared by the
- * workflows and scheduled-tasks heroes. Same architecture (the
+ * workflows hero. Same architecture (the
* {@link HeroLoopShell}'s fixed 1280x735 design-space layer scaled to the
* window, a parent-owned clock driving a presentational stage, reduced-motion
* showing the finished frame) and the same live sidebar, but the workspace
diff --git a/apps/sim/app/(landing)/components/solutions-page/components/solutions-card-row/components/solutions-pill-cta/solutions-pill-cta.tsx b/apps/sim/app/(landing)/components/solutions-page/components/solutions-card-row/components/solutions-pill-cta/solutions-pill-cta.tsx
index 05e80ee7122..48935402b55 100644
--- a/apps/sim/app/(landing)/components/solutions-page/components/solutions-card-row/components/solutions-pill-cta/solutions-pill-cta.tsx
+++ b/apps/sim/app/(landing)/components/solutions-page/components/solutions-card-row/components/solutions-pill-cta/solutions-pill-cta.tsx
@@ -1,7 +1,7 @@
'use client'
import { ChipLink } from '@sim/emcn'
-import { ArrowRight } from 'lucide-react'
+import { ArrowRight } from '@sim/emcn/icons'
import type { SolutionsPillCta as SolutionsPillCtaConfig } from '@/app/(landing)/components/solutions-page/types'
/**
diff --git a/apps/sim/app/(landing)/contact/components/contact-form/contact-form.tsx b/apps/sim/app/(landing)/contact/components/contact-form/contact-form.tsx
index 28f8d516ee5..b21f950b3bb 100644
--- a/apps/sim/app/(landing)/contact/components/contact-form/contact-form.tsx
+++ b/apps/sim/app/(landing)/contact/components/contact-form/contact-form.tsx
@@ -284,7 +284,6 @@ export function ContactForm() {
updateField('topic', value as ContactRequestPayload['topic'])}
@@ -342,7 +341,6 @@ export function ContactForm() {
}
>
) : (
- {placeholder}
+ {placeholder}
)}
@@ -275,7 +275,7 @@ export function EnterpriseHomeStage({
- Send message to Sim
+ Send message to Sim
diff --git a/apps/sim/app/(landing)/enterprise/components/enterprise-platform-loop/enterprise-sidebar.tsx b/apps/sim/app/(landing)/enterprise/components/enterprise-platform-loop/enterprise-sidebar.tsx
index c77deff231c..24eddcfc205 100644
--- a/apps/sim/app/(landing)/enterprise/components/enterprise-platform-loop/enterprise-sidebar.tsx
+++ b/apps/sim/app/(landing)/enterprise/components/enterprise-platform-loop/enterprise-sidebar.tsx
@@ -43,7 +43,7 @@ function IconRow({ icon: Icon, label, active = false }: IconRowProps) {
)}
>
- {label}
+ {label}
)
}
@@ -52,7 +52,7 @@ function IconRow({ icon: Icon, label, active = false }: IconRowProps) {
function TextRow({ label }: { label: string }) {
return (
{priceSubtext ?? ' '}
-
+
{cta.label}
diff --git a/apps/sim/app/(landing)/scheduled-tasks/components/scheduled-tasks-calendar-loop/index.ts b/apps/sim/app/(landing)/scheduled-tasks/components/scheduled-tasks-calendar-loop/index.ts
deleted file mode 100644
index 35e43aa2af3..00000000000
--- a/apps/sim/app/(landing)/scheduled-tasks/components/scheduled-tasks-calendar-loop/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { ScheduledTasksCalendarLoop } from './scheduled-tasks-calendar-loop'
diff --git a/apps/sim/app/(landing)/scheduled-tasks/components/scheduled-tasks-calendar-loop/scheduled-tasks-calendar-loop.module.css b/apps/sim/app/(landing)/scheduled-tasks/components/scheduled-tasks-calendar-loop/scheduled-tasks-calendar-loop.module.css
deleted file mode 100644
index ea6ba04bb0a..00000000000
--- a/apps/sim/app/(landing)/scheduled-tasks/components/scheduled-tasks-calendar-loop/scheduled-tasks-calendar-loop.module.css
+++ /dev/null
@@ -1,28 +0,0 @@
-/**
- * The ScheduledTasksCalendarLoop's motion: a newly scheduled task pill stamps
- * in once when the parent clock mounts it on its calendar day. A one-shot
- * mount animation - the loop's restart unmounts and remounts the pills, so a
- * new cycle replays them naturally. Under prefers-reduced-motion the parent
- * renders the finished frame and this never plays.
- */
-
-.pillIn {
- animation: scheduled-tasks-pill-in 0.35s cubic-bezier(0.22, 1, 0.36, 1) backwards;
-}
-
-@keyframes scheduled-tasks-pill-in {
- from {
- opacity: 0;
- transform: translateY(-4px);
- }
- to {
- opacity: 1;
- transform: translateY(0);
- }
-}
-
-@media (prefers-reduced-motion: reduce) {
- .pillIn {
- animation: none;
- }
-}
diff --git a/apps/sim/app/(landing)/scheduled-tasks/components/scheduled-tasks-calendar-loop/scheduled-tasks-calendar-loop.tsx b/apps/sim/app/(landing)/scheduled-tasks/components/scheduled-tasks-calendar-loop/scheduled-tasks-calendar-loop.tsx
deleted file mode 100644
index 73824540eae..00000000000
--- a/apps/sim/app/(landing)/scheduled-tasks/components/scheduled-tasks-calendar-loop/scheduled-tasks-calendar-loop.tsx
+++ /dev/null
@@ -1,300 +0,0 @@
-'use client'
-
-import { useState } from 'react'
-import { Chip, chipContentGap, chipPrimaryFillTokens, cn } from '@sim/emcn'
-import { Calendar, Plus } from '@sim/emcn/icons'
-import { ChevronLeft, ChevronRight } from 'lucide-react'
-import { HeroLoopShell } from '@/app/(landing)/components/shared/hero-loop-shell'
-import { RESET_FADE_MS } from '@/app/(landing)/hooks/use-design-scale'
-import { useMotionSafeCycle } from '@/app/(landing)/hooks/use-motion-safe-cycle'
-import styles from '@/app/(landing)/scheduled-tasks/components/scheduled-tasks-calendar-loop/scheduled-tasks-calendar-loop.module.css'
-
-/** Sidebar content for the scheduled-tasks hero - a recurring-ops workspace. */
-const SIDEBAR_CHATS = [
- 'Morning digest setup',
- 'Move sync to nightly',
- 'Weekly KPI report',
- 'Retry failed runs',
-] as const
-
-/** Deployed workflows in the sidebar - five fill the design height. */
-const SIDEBAR_WORKFLOWS = [
- 'Morning digest',
- 'Nightly data sync',
- 'Weekly KPI report',
- 'Invoice sweep',
- 'Churn-risk alerts',
-] as const
-
-/** Weekday header labels, Sunday-start, matching the real month grid. */
-const WEEKDAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const
-
-/** Month the fixed calendar shows - June 2026 opens on a Monday and fills five Sunday-start weeks. */
-const MONTH_LABEL = 'June 2026'
-/** Day-of-month carrying the today ring, a mid-month Wednesday. */
-const TODAY = 10
-/** Days in June - the grid's in-month cells span indexes 1..30. */
-const DAYS_IN_MONTH = 30
-/** Total cells in the five-week Sunday-start grid. */
-const CELL_COUNT = 35
-
-interface CalendarPill {
- /** Occurrence start time, preformatted like the real event chip's `h:mm a`. */
- time: string
- /** Scheduled task title. */
- title: string
- /** Paused schedules render dimmed - the one status the real pill signals. */
- paused?: boolean
- /**
- * Position of this pill in the animated stamp-in series (the Weekly KPI
- * report being scheduled across the month's Mondays); unset pills are the
- * settled base calendar.
- */
- stampIndex?: number
-}
-
-interface CalendarCell {
- /** Day-of-month number the cell shows. */
- day: number
- /** In-month days get body-colored numbers; leading/trailing days go muted. */
- inMonth: boolean
- /** Today's number gets the real grid's 26px primary-filled square. */
- isToday: boolean
- /** Task occurrences on this day, in start-time order. */
- pills: CalendarPill[]
-}
-
-/** Mondays in the grid (cell indexes), in stamp order for the animated series. */
-const MONDAY_CELLS: readonly number[] = [1, 8, 15, 22, 29]
-
-/**
- * Derives one day's task pills from the workspace's recurring schedules:
- * the Morning digest on weekdays at 9:00 AM, the paused Churn-risk alerts on
- * Thursdays, the Nightly data sync every night, a monthly Invoice sweep on
- * the 30th, and - as the animated series - the Weekly KPI report landing on
- * each Monday as the schedule is created.
- */
-function pillsForCell(index: number): CalendarPill[] {
- const weekday = index % 7
- const pills: CalendarPill[] = []
- const mondayOrder = MONDAY_CELLS.indexOf(index)
- if (mondayOrder !== -1) {
- pills.push({ time: '8:00 AM', title: 'Weekly KPI report', stampIndex: mondayOrder })
- }
- if (weekday >= 1 && weekday <= 5) {
- pills.push({ time: '9:00 AM', title: 'Morning digest' })
- }
- if (index === DAYS_IN_MONTH) {
- pills.push({ time: '3:00 PM', title: 'Invoice sweep' })
- }
- if (weekday === 4) {
- pills.push({ time: '4:00 PM', title: 'Churn-risk alerts', paused: true })
- }
- pills.push({ time: '11:00 PM', title: 'Nightly data sync' })
- return pills
-}
-
-/**
- * The fixed June 2026 grid: May 31 leads the first week, June fills the
- * middle, and July 1-4 close the fifth week - every cell's pills derived
- * from the recurring schedules above.
- */
-const CALENDAR_CELLS: readonly CalendarCell[] = Array.from({ length: CELL_COUNT }, (_, index) => {
- const inMonth = index >= 1 && index <= DAYS_IN_MONTH
- const day = index === 0 ? 31 : inMonth ? index : index - DAYS_IN_MONTH
- return {
- day,
- inMonth,
- isToday: index === TODAY,
- pills: pillsForCell(index),
- }
-})
-
-/** Total pills the animated Weekly KPI series stamps onto the month's Mondays. */
-const TOTAL_STAMPED_PILLS = MONDAY_CELLS.length
-
-/** The settled calendar holds this long before the first KPI pill lands. */
-const IDLE_HOLD_MS = 900
-/** Stamped pill N lands at IDLE_HOLD_MS + N * PILL_STEP_MS. */
-const PILL_STEP_MS = 620
-/** The fully scheduled month holds this long before the fade. */
-const SCHEDULED_HOLD_MS = 5200
-
-interface CalendarPanePillProps {
- pill: CalendarPill
- /** Stamped pills replay the mount animation each cycle. */
- animate: boolean
-}
-
-/**
- * One task pill in a day cell - the real calendar event chip's exact
- * chrome (primary fill, start time + title, paused schedules dimmed) rendered
- * as a plain `
` since the whole frame is `aria-hidden` decoration.
- */
-function CalendarPanePill({ pill, animate }: CalendarPanePillProps) {
- return (
-
- {pill.time}
- {pill.title}
-
- )
-}
-
-interface ScheduledTasksCalendarPaneProps {
- /** How many Weekly KPI pills the stamp-in series has landed (0..5). */
- stampedCount: number
-}
-
-/**
- * The static Scheduled Tasks page in the real workspace's exact vocabulary -
- * the resource header (Calendar icon + title + primary "New scheduled task"
- * chip), the calendar toolbar (Today jump, period label, prev/next chevrons,
- * scope chip), the sticky weekday header, and the five-week month grid of day
- * cells with the today square and stacked task pills - rendered from the
- * parent clock's `stampedCount` beat.
- */
-function ScheduledTasksCalendarPane({ stampedCount }: ScheduledTasksCalendarPaneProps) {
- return (
-
@@ -49,10 +49,10 @@ export function InviteStatusCard({
return (
<>
-
+
{title}
-
{description}
+
{description}
@@ -78,7 +78,6 @@ export function InviteStatusCard({
)}
- {/*
+ {/*
Workspace layout dimensions: set CSS vars before hydration to avoid layout jump.
IMPORTANT: These hardcoded values must stay in sync with stores/constants.ts
@@ -93,9 +93,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
}
// Sidebar width. Mirror clampSidebarWidth() in stores/sidebar/store.ts:
- // the upper bound can never fall below the 248px minimum, so a narrow
+ // the upper bound can never fall below the 238px minimum, so a narrow
// window yields a width >= MIN instead of a sub-minimum sliver.
- var defaultSidebarWidth = 248;
+ var defaultSidebarWidth = 238;
try {
// Collapse comes from the cookie (independent of localStorage
// parsing); the persisted width is read defensively below. Match the
@@ -121,10 +121,10 @@ export default function RootLayout({ children }: { children: React.ReactNode })
// collapsed, because the desktop hover-peek renders the sidebar at
// its restore width while --sidebar-width still reads collapsed.
var width = state && state.sidebarWidth;
- var maxSidebarWidth = Math.max(248, window.innerWidth * 0.3);
+ var maxSidebarWidth = Math.max(238, window.innerWidth * 0.3);
var expandedWidth =
typeof width === 'number' && isFinite(width)
- ? Math.min(Math.max(width, 248), maxSidebarWidth)
+ ? Math.min(Math.max(width, 238), maxSidebarWidth)
: defaultSidebarWidth;
document.documentElement.style.setProperty(
'--sidebar-expanded-width',
diff --git a/apps/sim/app/playground/page.tsx b/apps/sim/app/playground/page.tsx
index 4923394efa3..10560fa90da 100644
--- a/apps/sim/app/playground/page.tsx
+++ b/apps/sim/app/playground/page.tsx
@@ -77,14 +77,13 @@ import {
ToastProvider,
Tooltip,
Trash,
- Trash2,
toast,
Undo,
Wrap,
ZoomIn,
ZoomOut,
} from '@sim/emcn'
-import { ArrowLeft, Folder, Moon, Sun } from 'lucide-react'
+import { ArrowLeft, Folder, Moon, Sun } from '@sim/emcn/icons'
import { notFound, useRouter } from 'next/navigation'
import { env, isTruthy } from '@/lib/core/config/env'
@@ -1050,7 +1049,6 @@ export default function PlaygroundPage() {
{ Icon: Redo, name: 'Redo' },
{ Icon: Rocket, name: 'Rocket' },
{ Icon: Trash, name: 'Trash' },
- { Icon: Trash2, name: 'Trash2' },
{ Icon: Undo, name: 'Undo' },
{ Icon: Wrap, name: 'Wrap' },
{ Icon: ZoomIn, name: 'ZoomIn' },
diff --git a/apps/sim/app/sitemap.ts b/apps/sim/app/sitemap.ts
index b468d883742..e0b7a0d6a8d 100644
--- a/apps/sim/app/sitemap.ts
+++ b/apps/sim/app/sitemap.ts
@@ -71,9 +71,6 @@ export default async function sitemap(): Promise {
{
url: `${baseUrl}/logs`,
},
- {
- url: `${baseUrl}/scheduled-tasks`,
- },
{
url: `${baseUrl}/pricing`,
},
diff --git a/apps/sim/app/unsubscribe/unsubscribe.tsx b/apps/sim/app/unsubscribe/unsubscribe.tsx
index 605f45a1943..5f3483accb1 100644
--- a/apps/sim/app/unsubscribe/unsubscribe.tsx
+++ b/apps/sim/app/unsubscribe/unsubscribe.tsx
@@ -43,9 +43,7 @@ function UnsubscribeContent() {
Loading
-
- Validating your unsubscribe link…
-
+
Validating your unsubscribe link…
@@ -61,7 +59,7 @@ function UnsubscribeContent() {
Invalid Unsubscribe Link
-
{error}
+
{error}
@@ -80,7 +78,7 @@ function UnsubscribeContent() {
Important Account Emails
-
+
Transactional emails like password resets, account confirmations, and security alerts
cannot be unsubscribed from as they contain essential information for your account.
@@ -102,7 +100,7 @@ function UnsubscribeContent() {
Successfully Unsubscribed
-
+
You have been unsubscribed from our emails. You will stop receiving emails within 48
hours.
@@ -125,10 +123,10 @@ function UnsubscribeContent() {
Email Preferences
-
+
Choose which emails you'd like to stop receiving.
-
{data?.email}
+
{data?.email}
@@ -145,14 +143,11 @@ function UnsubscribeContent() {
-
- or choose specific types
-
+ or choose specific types
You'll continue receiving important account emails like password resets and security
alerts.
@@ -218,9 +211,7 @@ export default function Unsubscribe() {
Loading
-
- Validating your unsubscribe link…
-
+
Validating your unsubscribe link…
diff --git a/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/generate-prompt-control.tsx b/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/generate-prompt-control.tsx
index a0d07c1e511..a0f290f045d 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/generate-prompt-control.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/generate-prompt-control.tsx
@@ -2,7 +2,7 @@
import { useRef, useState } from 'react'
import { Chip, ChipInput } from '@sim/emcn'
-import { ArrowUp } from 'lucide-react'
+import { ArrowUp } from '@sim/emcn/icons'
interface GeneratePromptControlProps {
isLoading: boolean
@@ -42,7 +42,7 @@ export function GeneratePromptControl({
if (!isActive) {
return (
-
+
Generate
)
@@ -72,7 +72,6 @@ export function GeneratePromptControl({
placeholder='Describe what to generate...'
/>
- {icon ?? }
+ {icon ?? }
-
+
{title}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/components/index.ts
index 02855214e23..16570ad0070 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/components/index.ts
@@ -2,6 +2,7 @@ export { ConversationListItem } from './conversation-list-item'
export type { ErrorBoundaryProps, ErrorStateProps } from './error'
export { ErrorShell, ErrorState } from './error'
export { InlineRenameInput } from './inline-rename-input'
+export { IntegrationTabsHeader } from './integration-tabs-header'
export { MessageActions } from './message-actions'
export { FloatingOverflowText } from './resource/components/floating-overflow-text'
export { ownerCell } from './resource/components/owner-cell'
diff --git a/apps/sim/app/workspace/[workspaceId]/components/integration-tabs-header/index.ts b/apps/sim/app/workspace/[workspaceId]/components/integration-tabs-header/index.ts
new file mode 100644
index 00000000000..1706152ecf3
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/integration-tabs-header/index.ts
@@ -0,0 +1 @@
+export { IntegrationTabsHeader } from './integration-tabs-header'
diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/integration-tabs-header/integration-tabs-header.tsx b/apps/sim/app/workspace/[workspaceId]/components/integration-tabs-header/integration-tabs-header.tsx
similarity index 56%
rename from apps/sim/app/workspace/[workspaceId]/integrations/components/integration-tabs-header/integration-tabs-header.tsx
rename to apps/sim/app/workspace/[workspaceId]/components/integration-tabs-header/integration-tabs-header.tsx
index 3706f107890..53efc5a0175 100644
--- a/apps/sim/app/workspace/[workspaceId]/integrations/components/integration-tabs-header/integration-tabs-header.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/integration-tabs-header/integration-tabs-header.tsx
@@ -5,13 +5,20 @@ import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header
interface IntegrationTabsHeaderProps {
active: 'integrations' | 'skills'
workspaceId: string
+ /** Trailing actions for the owning page (e.g. skills' "Add skill"). */
rightSlot?: ReactNode
}
/**
- * Top-of-page chip header shared by the Integrations and Skills pages.
- * Highlights the active tab and links to the sibling tab; `rightSlot` lets
- * each page render its own trailing actions (e.g. an "Add skill" button).
+ * Top-of-page tab header shared by the Integrations and Skills pages — two halves
+ * of one surface, so each highlights itself and links to its sibling.
+ *
+ * Lives in the shared workspace components rather than under `integrations/`
+ * because both pages own it equally; its former home made Skills reach across into
+ * a sibling feature for its own chrome.
+ *
+ * The `gap-1` is explicit because chips carry no outer margin — the parent owns the
+ * space between them.
*/
export function IntegrationTabsHeader({
active,
@@ -19,7 +26,7 @@ export function IntegrationTabsHeader({
rightSlot,
}: IntegrationTabsHeaderProps) {
return (
-
+
Integrations
diff --git a/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx
index c110e1b064e..9b21c25ed31 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx
@@ -327,7 +327,6 @@ export function InviteModal({
searchable={isOrganizationInvite}
searchPlaceholder='Search workspaces...'
fullWidth
- flush
disabled={isSubmitting || !canInvite || workspaceOptions.length <= 1}
/>
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx
index 9f295fe229a..d70a12049b8 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx
@@ -1,3 +1,5 @@
+'use client'
+
import {
type ComponentType,
Fragment,
@@ -29,7 +31,7 @@ import {
useFloatingTooltip,
useIsOverflowing,
} from '@sim/emcn'
-import { ArrowUpLeft } from 'lucide-react'
+import { ArrowUpLeft } from '@sim/emcn/icons'
import { createPortal } from 'react-dom'
import { HEADER_ACTION_CLUSTER, TITLE_BAR_LANE_PT } from '@/components/page-header-bar'
import { orderHeaderActions } from '@/components/settings/settings-header'
@@ -309,10 +311,7 @@ const BreadcrumbSegment = memo(function BreadcrumbSegment({
* rounded-[5px] / justify-center and break chip parity with the static/title
* crumbs.
*/
- const triggerClassName = cn(
- chipVariants({ flush: true }),
- 'group min-w-0 max-w-full justify-start'
- )
+ const triggerClassName = cn(chipVariants(), 'group min-w-0 max-w-full justify-start')
if (dropdownItems && dropdownItems.length > 0) {
return (
@@ -459,7 +458,7 @@ function BreadcrumbLocationPopover({
onMouseEnter={openPopover}
onMouseLeave={scheduleClose}
className={cn(
- chipVariants({ flush: true }),
+ chipVariants(),
'max-w-none gap-1.5 px-2 transition-colors',
open && 'relative z-[var(--z-popover)]',
className
@@ -467,10 +466,7 @@ function BreadcrumbLocationPopover({
>
-
+
{rootBreadcrumb?.label && (
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options/resource-options.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options/resource-options.tsx
index de67d5ef176..101acc67ee8 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options/resource-options.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options/resource-options.tsx
@@ -1,3 +1,5 @@
+'use client'
+
import { memo, type ReactNode, useState } from 'react'
import * as PopoverPrimitive from '@radix-ui/react-popover'
import {
@@ -143,7 +145,7 @@ export const ResourceOptions = memo(function ResourceOptions({
and only the trailing action is pushed to the far edge. */}
- {/* Chips carry their own `mx-0.5` cluster spacing, so this row sets no gap. */}
-
-
+
+ {/* 16px icon + 2px + the row's 4px gap = the 22px text inset the sibling
+ rows below are tuned to (`pl-[22px]`, and `gap-1.5` on an unmargined
+ icon). Margin and gap add, so this cannot be `mr-1.5`. */}
+
{preview ? (
@@ -190,7 +192,7 @@ export function ToolPermissionCard({
-
-
- )
-}
-
interface EmbeddedLogProps {
workspaceId: string
logId: string
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx
index e1bbf9f9f36..5a4793713ad 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx
@@ -3,11 +3,11 @@
import type { ElementType, ReactNode } from 'react'
import { cn } from '@sim/emcn'
import {
- Calendar,
Connections,
Database,
File as FileIcon,
Folder as FolderIcon,
+ Globe,
Library,
Table as TableIcon,
Task,
@@ -15,7 +15,6 @@ import {
Workflow,
} from '@sim/emcn/icons'
import type { QueryClient } from '@tanstack/react-query'
-import { Globe } from 'lucide-react'
import { getDocumentIcon } from '@/components/icons/document-icons'
import type {
MothershipResource,
@@ -24,7 +23,6 @@ import type {
import { getBareIconStyle, type StyleableIcon } from '@/blocks/brand-icon-style'
import { logKeys } from '@/hooks/queries/logs'
import { mothershipChatKeys } from '@/hooks/queries/mothership-chats'
-import { scheduleKeys } from '@/hooks/queries/schedules'
import { folderKeys } from '@/hooks/queries/utils/folder-keys'
import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists'
import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys'
@@ -186,15 +184,6 @@ export const RESOURCE_REGISTRY: Record,
},
- scheduledtask: {
- type: 'scheduledtask',
- label: 'Scheduled Tasks',
- icon: Calendar,
- renderTabIcon: (_resource, className) => (
-
- ),
- renderDropdownItem: (props) => ,
- },
log: {
type: 'log',
label: 'Logs',
@@ -273,9 +262,6 @@ const RESOURCE_INVALIDATORS: Record<
task: (qc, wId) => {
qc.invalidateQueries({ queryKey: mothershipChatKeys.list(wId) })
},
- scheduledtask: (qc, wId) => {
- qc.invalidateQueries({ queryKey: scheduleKeys.list(wId) })
- },
log: (qc, wId, id) => {
qc.invalidateQueries({ queryKey: logKeys.details() })
qc.invalidateQueries({ queryKey: logKeys.detail(wId, id) })
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/queued-messages/queued-messages.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/queued-messages/queued-messages.tsx
index ee45c1a1aa9..76979893694 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/queued-messages/queued-messages.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/queued-messages/queued-messages.tsx
@@ -2,7 +2,7 @@
import { useCallback, useRef, useState } from 'react'
import { cn, Tooltip } from '@sim/emcn'
-import { ArrowUp, ChevronDown, ChevronRight, Paperclip, Pencil, Trash2, X } from 'lucide-react'
+import { ArrowUp, ChevronDown, ChevronRight, Paperclip, Pencil, Trash, X } from '@sim/emcn/icons'
import { UserMessageContent } from '@/app/workspace/[workspaceId]/home/components/user-message-content'
import type { QueuedMessage } from '@/app/workspace/[workspaceId]/home/types'
@@ -182,7 +182,7 @@ export function QueuedMessages({
}}
className='rounded-md p-[5px] text-[var(--text-icon)] transition-colors hover-hover:bg-[var(--surface-active)] hover-hover:text-[var(--text-primary)]'
>
-
+
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx
index c90e0795ee8..8a78cf03537 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx
@@ -1,8 +1,8 @@
'use client'
import { type ComponentType, type CSSProperties, useMemo, useState } from 'react'
-import { ArrowRight, ChevronDown, chipVariants, cn, Expandable, ExpandableContent } from '@sim/emcn'
-import { Shuffle, Table } from '@sim/emcn/icons'
+import { ArrowRight, ChevronDown, cn, Expandable, ExpandableContent } from '@sim/emcn'
+import { Table } from '@sim/emcn/icons'
import { randomFloat } from '@sim/utils/random'
import { stripVersionSuffix } from '@sim/utils/string'
import { useParams } from 'next/navigation'
@@ -149,7 +149,7 @@ function scoreCandidate(c: Candidate, signals: Signals): number {
/**
* Weighted sampling without replacement. Each pick's probability is
- * proportional to its weight, so shuffles stay fresh while staying relevant.
+ * proportional to its weight, so the set stays varied while staying relevant.
*/
function weightedSample(pool: readonly T[], n: number, weightOf: (item: T) => number): T[] {
const remaining = pool.map((item) => ({ item, weight: Math.max(weightOf(item), 0) }))
@@ -277,8 +277,6 @@ export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) {
* above it.
*/
const [animationsEnabled, setAnimationsEnabled] = useState(false)
- /** Incremented by the shuffle control to re-roll the weighted sample. */
- const [shuffleNonce, setShuffleNonce] = useState(0)
/**
* OAuth connect modal target. Setting this opens the modal; setting it back
* to `null` (via `onOpenChange(false)`) closes it. Mirrors the local-state
@@ -307,16 +305,16 @@ export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) {
)
/**
- * Personalized suggestions, re-sampled whenever signals resolve or the user
- * shuffles. Falls back to {@link INITIAL_ACTIONS} until the credential and
- * service queries have loaded (and stays there for users with no
- * connections, unless they shuffle), so first paint never flashes.
+ * Personalized suggestions, re-sampled whenever signals resolve. Falls back to
+ * {@link INITIAL_ACTIONS} until the credential and service queries have loaded
+ * — and stays there for users with no connections — so first paint never
+ * flashes.
*/
const actions = useMemo(() => {
const personalized = services.length > 0 && connectedProviders.size > 0
- if (!personalized && shuffleNonce === 0) return INITIAL_ACTIONS
+ if (!personalized) return INITIAL_ACTIONS
return computeActions(services, signals)
- }, [connectedProviders, services, signals, shuffleNonce])
+ }, [connectedProviders, services, signals])
const handleSelect = (action: Action, position: number) => {
captureEvent(posthog, 'suggested_action_clicked', {
@@ -335,14 +333,6 @@ export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) {
if (match) setOAuthTarget(match)
}
- const handleShuffle = () => {
- captureEvent(posthog, 'suggested_actions_shuffled', {
- workspace_id: workspaceId,
- connected_provider_count: connectedProviders.size,
- })
- setShuffleNonce((n) => n + 1)
- }
-
const handleToggleExpanded = () => {
captureEvent(posthog, 'suggested_actions_toggled', {
workspace_id: workspaceId,
@@ -353,41 +343,40 @@ export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) {
}
return (
-
-
-
- Suggested actions
-
-
-
+ {/* Full width so the whole line toggles, not just the label and chevron. */}
+
+ Suggested actions
+ {/*
+ * Revealed by hovering anywhere in the section — the group sits on the
+ * section wrapper rather than this row, so the action rows below arm it just
+ * as the header does. Focus is keyed off the toggle instead, the only element
+ * here that can hold it, and matters because globals clear focus outlines.
+ * One transition covers the fade and the rotation so the two cannot drift
+ * apart. Mirrors the sidebar's section headers.
+ */}
+
- Shuffle
-
-
-
+ />
+
-
-
+
+ {/* 6px, matching a sidebar section header to its first item — both headers
+ are an 18px box around 12px text, so equal padding reads as equal
+ distance. Padding an inner wrapper rather than the animated element:
+ `collapsible-up`/`-down` interpolate height alone, so a margin here
+ would hold its full value through the close and then vanish on unmount,
+ snapping the content below up. */}
+
{actions.map((action, i) => {
const Icon = action.icon
return (
@@ -396,7 +385,7 @@ export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) {
type='button'
onClick={() => handleSelect(action, i)}
className={cn(
- 'flex items-center gap-2 border-[var(--divider)] px-2 py-2 text-left transition-colors hover-hover:bg-[var(--surface-5)]',
+ 'flex items-center gap-2 border-[var(--border)] px-2 py-2 text-left transition-colors hover-hover:bg-[var(--surface-5)]',
i > 0 && 'border-t'
)}
>
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/attached-files-list.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/attached-files-list.tsx
index 1d917779162..f4e01792827 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/attached-files-list.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/attached-files-list.tsx
@@ -2,7 +2,7 @@
import React from 'react'
import { Loader, Tooltip } from '@sim/emcn'
-import { X } from 'lucide-react'
+import { X } from '@sim/emcn/icons'
import { getDocumentIcon } from '@/components/icons/document-icons'
import type { AttachedFile } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts
index e2166acd6cc..73eb6eff658 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts
@@ -27,7 +27,6 @@ const PORTABLE_KIND_TO_ID_FIELD = {
file: 'fileId',
folder: 'folderId',
filefolder: 'fileFolderId',
- scheduledtask: 'scheduleId',
knowledge: 'knowledgeId',
past_chat: 'chatId',
workflow: 'workflowId',
@@ -234,8 +233,6 @@ export function chipLinkToContext(link: ParsedChipLink): ChatContext {
return { kind: 'folder', folderId: link.id, label: link.label }
case 'filefolder':
return { kind: 'filefolder', fileFolderId: link.id, label: link.label }
- case 'scheduledtask':
- return { kind: 'scheduledtask', scheduleId: link.id, label: link.label }
case 'knowledge':
return { kind: 'knowledge', knowledgeId: link.id, label: link.label }
case 'past_chat':
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts
index 29e4baa4ea7..75c0b4da123 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts
@@ -58,12 +58,12 @@ export interface PlusMenuHandle {
* Box and typography shared by the textarea and its mirror overlay — both must
* produce identical line wrapping so the overlay text sits exactly over the
* (transparent) textarea text. The scale is the chat input's native prompt
- * scale (`text-[15px]`, `-0.015em` tracking); the task modal's body inherits it
+ * scale (`text-[14px]`, `-0.015em` tracking); the task modal's body inherits it
* so the editor reads the same whether it's the chat input or inside the modal.
*/
const FIELD_MIRROR_CLASSES = cn(
'm-0 box-border min-h-[24px] w-full break-words [overflow-wrap:anywhere] border-0 bg-transparent',
- 'px-1 py-1 font-body text-[15px] leading-[24px] tracking-[-0.015em]'
+ 'px-1 py-1 font-body text-[14px] leading-[24px] tracking-[-0.015em]'
)
/**
@@ -75,7 +75,7 @@ export const TEXTAREA_BASE_CLASSES = cn(
FIELD_MIRROR_CLASSES,
'block h-auto resize-none overflow-hidden',
'text-transparent caret-[var(--text-primary)] outline-none',
- 'placeholder:font-[380] placeholder:text-[var(--text-subtle)]',
+ 'placeholder:text-[var(--text-muted)]',
'focus-visible:ring-0 focus-visible:ring-offset-0'
)
@@ -128,7 +128,6 @@ const RESOURCE_TO_CONTEXT: Record<
task: (r) => ({ kind: 'past_chat', chatId: r.id, label: r.title }),
log: (r) => ({ kind: 'logs', executionId: r.id, label: r.title }),
integration: (r) => ({ kind: 'integration', blockType: r.id, label: r.title }),
- scheduledtask: (r) => ({ kind: 'scheduledtask', scheduleId: r.id, label: r.title }),
generic: (r) => ({ kind: 'docs', label: r.title }),
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx
index 1e1932332aa..810484c9d84 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx
@@ -530,8 +530,8 @@ const UserInputImpl = forwardRef(function UserI
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx
index 6315cc4e304..01d725a9719 100644
--- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx
@@ -19,7 +19,6 @@ import {
Tooltip,
useCopyToClipboard,
} from '@sim/emcn'
-import { formatDuration } from '@sim/utils/formatting'
import {
ArrowDown,
ArrowUp,
@@ -29,7 +28,8 @@ import {
Clipboard,
Search,
X,
-} from 'lucide-react'
+} from '@sim/emcn/icons'
+import { formatDuration } from '@sim/utils/formatting'
import { createPortal } from 'react-dom'
import type { TraceSpan } from '@/lib/logs/types'
import {
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx
index 5a353e7c0f4..bde89f51df8 100644
--- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx
@@ -22,9 +22,18 @@ import {
Tooltip,
useCopyToClipboard,
} from '@sim/emcn'
-import { Workflow, Wrench } from '@sim/emcn/icons'
+import {
+ ArrowDown,
+ ArrowUp,
+ Check,
+ ChevronUp,
+ Clipboard,
+ Search,
+ Workflow,
+ Wrench,
+ X,
+} from '@sim/emcn/icons'
import { formatDuration } from '@sim/utils/formatting'
-import { ArrowDown, ArrowUp, Check, ChevronUp, Clipboard, Search, X } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { useQueryState } from 'nuqs'
import { createPortal } from 'react-dom'
@@ -543,7 +552,7 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP
Snapshot
- setIsExecutionSnapshotOpen(true)}>
+ setIsExecutionSnapshotOpen(true)}>
View Snapshot
@@ -555,7 +564,7 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP
Troubleshoot
-
+
Troubleshoot in Chat
diff --git a/apps/sim/app/workspace/[workspaceId]/not-found.tsx b/apps/sim/app/workspace/[workspaceId]/not-found.tsx
index db69e38864d..6a29b6c5a26 100644
--- a/apps/sim/app/workspace/[workspaceId]/not-found.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/not-found.tsx
@@ -1,8 +1,7 @@
'use client'
import { Button, buttonVariants } from '@sim/emcn'
-import { ArrowLeft, Home } from '@sim/emcn/icons'
-import { Compass } from 'lucide-react'
+import { ArrowLeft, Compass, Home } from '@sim/emcn/icons'
import Link from 'next/link'
import { useParams, useRouter } from 'next/navigation'
import { ErrorShell } from '@/app/workspace/[workspaceId]/components'
@@ -16,7 +15,7 @@ export default function WorkspaceNotFound() {
}
+ icon={}
>
router.back()}>
diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/prefetch.ts
index e4c372eb603..fe69e488fae 100644
--- a/apps/sim/app/workspace/[workspaceId]/prefetch.ts
+++ b/apps/sim/app/workspace/[workspaceId]/prefetch.ts
@@ -3,6 +3,7 @@ import { listWorkspacesContract, type WorkspaceHostContext } from '@/lib/api/con
import { listMothershipChats } from '@/lib/copilot/chat/list-mothership-chats'
import { isChatEnabled } from '@/lib/core/config/env-flags'
import { listFoldersForWorkspace } from '@/lib/folders/queries'
+import { getUserProfile } from '@/lib/users/queries'
import { listWorkflowsForUser } from '@/lib/workflows/queries'
import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context'
import { listWorkspacesForViewer } from '@/lib/workspaces/list'
@@ -12,6 +13,11 @@ import {
mapChat,
mothershipChatKeys,
} from '@/hooks/queries/mothership-chats'
+import {
+ mapUserProfileResponse,
+ USER_PROFILE_STALE_TIME,
+ userProfileKeys,
+} from '@/hooks/queries/user-profile'
import { FOLDER_LIST_STALE_TIME, folderKeys, mapFolder } from '@/hooks/queries/utils/folder-keys'
import { workflowKeys } from '@/hooks/queries/utils/workflow-keys'
import { mapWorkflow, WORKFLOW_LIST_STALE_TIME } from '@/hooks/queries/utils/workflow-list-query'
@@ -42,10 +48,11 @@ export function prefetchWorkspaceHostContext(
}
/**
- * Prefetches the sidebar's workflow, chat, folder, workspace-permissions, and
- * workspace lists for a workspace and stores them under the same query keys +
- * mappers the client hooks use, so the persistent sidebar (including the
- * workspace switcher header) paints populated on the first server render
+ * Prefetches the sidebar's workflow, chat, folder, workspace-permissions,
+ * workspace, and viewer-profile reads for a workspace and stores them under the
+ * same query keys + mappers the client hooks use, so the persistent sidebar
+ * (including the workspace switcher header and the footer's profile row) paints
+ * populated on the first server render
* instead of flashing skeletons on a cold load (e.g. after the browser
* discards an idle tab). Calls the data layer directly — the same functions
* the API routes use — with no internal HTTP hop.
@@ -125,5 +132,21 @@ export async function prefetchWorkspaceSidebar(
),
staleTime: WORKSPACE_PERMISSIONS_STALE_TIME,
}),
+ /**
+ * The sidebar footer renders the viewer's name and avatar, so the profile is
+ * sidebar data and joins this batch rather than trailing it as a client
+ * waterfall. Keyed identically to `useUserProfile`, so the footer paints
+ * hydrated. Unlike the settings prefetch this needs no session lookup — the
+ * caller already resolved the viewer.
+ */
+ queryClient.prefetchQuery({
+ queryKey: userProfileKeys.profile(),
+ queryFn: async () => {
+ const user = await getUserProfile(userId)
+ if (!user) throw new Error('User not found')
+ return mapUserProfileResponse(user)
+ },
+ staleTime: USER_PROFILE_STALE_TIME,
+ }),
])
}
diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/calendar-toolbar/calendar-toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/calendar-toolbar/calendar-toolbar.tsx
index f45d7490186..b32d2d930ca 100644
--- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/calendar-toolbar/calendar-toolbar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/calendar-toolbar/calendar-toolbar.tsx
@@ -9,8 +9,8 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@sim/emcn'
+import { ChevronLeft, ChevronRight } from '@sim/emcn/icons'
import { format, parseISO } from 'date-fns'
-import { ChevronLeft, ChevronRight } from 'lucide-react'
import type { CalendarScope } from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/calendar-grid'
const SCOPE_OPTIONS: { value: CalendarScope; label: string }[] = [
@@ -51,7 +51,7 @@ export function CalendarToolbar({
return (
-
+
Today onSelectDate(parseISO(value))}
/>
-
+
diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/schedule-calendar.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/schedule-calendar.tsx
index 6e005317a7f..04d26254d1b 100644
--- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/schedule-calendar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/schedule-calendar.tsx
@@ -41,10 +41,15 @@ interface ScheduleCalendarProps {
}
/**
- * Calendar body for the scheduled-tasks page. Owns the scroll region and view
- * dispatch: it renders the toolbar, derives the grid from the page's
- * `useCalendar` state, and switches between the month grid and the shared time
- * grid on the grid discriminant.
+ * Calendar body, retained unmounted for reuse. The scheduled-tasks page that
+ * hosted it — along with its `useCalendar`/`useScheduledTasks` hooks, modals,
+ * and sidebar entry — was removed; this component tree and `../../utils` are
+ * kept deliberately so the calendar can be repurposed on a future surface. It
+ * has no importer today: that is intentional, NOT dead code to delete.
+ *
+ * Owns the scroll region and view dispatch: it renders the toolbar, derives the
+ * grid from caller-supplied scope/anchor state, and switches between the month
+ * grid and the shared time grid on the grid discriminant.
*
* Scroll behavior: entering week/day scope, and "Today" presses (signaled via an
* internal `scrollSignal`), center the current time in the viewport; month scope
diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-list-context-menu/index.ts b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-list-context-menu/index.ts
deleted file mode 100644
index df0d2d3b3fb..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-list-context-menu/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { ScheduleListContextMenu } from './schedule-list-context-menu'
diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-list-context-menu/schedule-list-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-list-context-menu/schedule-list-context-menu.tsx
deleted file mode 100644
index dccceda7ac2..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-list-context-menu/schedule-list-context-menu.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-'use client'
-
-import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@sim/emcn'
-import { Plus } from '@sim/emcn/icons'
-
-interface ScheduleListContextMenuProps {
- isOpen: boolean
- position: { x: number; y: number }
- onClose: () => void
- onCreateSchedule?: () => void
- disableCreate?: boolean
-}
-
-export function ScheduleListContextMenu({
- isOpen,
- position,
- onClose,
- onCreateSchedule,
- disableCreate = false,
-}: ScheduleListContextMenuProps) {
- return (
- !open && onClose()} modal={false}>
-
-
-
- e.preventDefault()}
- onContextMenu={(e) => e.preventDefault()}
- >
- {onCreateSchedule && (
-
-
- New scheduled task
-
- )}
-
-
- )
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-context-menu/index.ts b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-context-menu/index.ts
deleted file mode 100644
index 088038b1c53..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-context-menu/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { TaskContextMenu } from './task-context-menu'
diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-context-menu/task-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-context-menu/task-context-menu.tsx
deleted file mode 100644
index b9afc65ea72..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-context-menu/task-context-menu.tsx
+++ /dev/null
@@ -1,114 +0,0 @@
-'use client'
-
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from '@sim/emcn'
-import { Duplicate as DuplicateIcon, Pause, Pencil, Play, Trash } from '@sim/emcn/icons'
-import type { ScheduledTask } from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events'
-
-interface TaskContextMenuProps {
- isOpen: boolean
- position: { x: number; y: number }
- onClose: () => void
- /** The right-clicked task; its status decides which actions render. */
- task: ScheduledTask | null
- canEdit: boolean
- onEdit: () => void
- /** Opens a new-task modal pre-filled from this task. */
- onDuplicate: () => void
- /** Pauses an active recurring task — suspends its future runs. */
- onPause: () => void
- /** Resumes a paused recurring task. */
- onResume: () => void
- onDelete: () => void
-}
-
-/**
- * Right-click menu for a calendar task pill. Upcoming (`pending`) tasks can be
- * edited or deleted, and recurring ones paused or resumed; any task can be
- * duplicated into a new one. Finished tasks open their read-only record on
- * click, so the menu only offers Duplicate.
- */
-export function TaskContextMenu({
- isOpen,
- position,
- onClose,
- task,
- canEdit,
- onEdit,
- onDuplicate,
- onPause,
- onResume,
- onDelete,
-}: TaskContextMenuProps) {
- const isUpcoming = task?.status === 'pending'
- /** Pause/Resume applies to recurring tasks only — one-time tasks carry no cadence. */
- const canPauseResume = isUpcoming && task?.recurring === true
-
- return (
- !open && onClose()} modal={false}>
-
-
-
- e.preventDefault()}
- onContextMenu={(e) => e.preventDefault()}
- >
- {isUpcoming ? (
- <>
- {canEdit && (
-
-
- Edit
-
- )}
- {canPauseResume &&
- (task?.disabled ? (
-
-
- Resume
-
- ) : (
-
-
- Pause
-
- ))}
-
-
- Duplicate
-
-
-
-
- Delete
-
- >
- ) : (
-
-
- Duplicate
-
- )}
-
-
- )
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-delete-dialog/index.ts b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-delete-dialog/index.ts
deleted file mode 100644
index 17754fa1187..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-delete-dialog/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { TaskDeleteDialog } from './task-delete-dialog'
diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-delete-dialog/task-delete-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-delete-dialog/task-delete-dialog.tsx
deleted file mode 100644
index 4927774a4f9..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-delete-dialog/task-delete-dialog.tsx
+++ /dev/null
@@ -1,98 +0,0 @@
-'use client'
-
-import {
- ChipConfirmModal,
- ChipModal,
- ChipModalBody,
- ChipModalFooter,
- ChipModalHeader,
-} from '@sim/emcn'
-import { Calendar } from '@sim/emcn/icons'
-import type { ScheduledTask } from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events'
-
-interface TaskDeleteDialogProps {
- /** The task targeted for deletion, or `null` to keep the dialog closed. */
- task: ScheduledTask | null
- onClose: () => void
- /** Delete just the targeted occurrence of a recurring task. */
- onDeleteOccurrence: (task: ScheduledTask) => void
- /** Delete a one-time task, or the entire recurring series. */
- onDeleteSeries: (task: ScheduledTask) => void
-}
-
-/**
- * Deletion confirmation for a scheduled task. A one-time task takes a single
- * confirm; a recurring task offers the calendar-app choice between deleting
- * this occurrence and deleting the whole series.
- */
-export function TaskDeleteDialog({
- task,
- onClose,
- onDeleteOccurrence,
- onDeleteSeries,
-}: TaskDeleteDialogProps) {
- if (task && !task.recurring) {
- return (
- {
- if (!open) onClose()
- }}
- title='Delete scheduled task'
- text='This task will be removed from the calendar and will not run.'
- confirm={{
- label: 'Delete',
- onClick: () => {
- onDeleteSeries(task)
- onClose()
- },
- }}
- />
- )
- }
-
- return (
- {
- if (!open) onClose()
- }}
- size='sm'
- srTitle='Delete recurring task'
- >
- {task && (
- <>
-
- Delete recurring task
-
-
-
- This is a recurring task. Delete only this occurrence, or the entire series?
-
-
- {
- onDeleteOccurrence(task)
- onClose()
- },
- },
- ]}
- primaryAction={{
- label: 'All tasks',
- variant: 'destructive',
- onClick: () => {
- onDeleteSeries(task)
- onClose()
- },
- }}
- />
- >
- )}
-
- )
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-details-modal/index.ts b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-details-modal/index.ts
deleted file mode 100644
index 195e198b20c..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-details-modal/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { TaskDetailsModal } from './task-details-modal'
diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-details-modal/task-details-modal.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-details-modal/task-details-modal.tsx
deleted file mode 100644
index b52cb043e54..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-details-modal/task-details-modal.tsx
+++ /dev/null
@@ -1,106 +0,0 @@
-'use client'
-import {
- ChipModal,
- ChipModalBody,
- ChipModalField,
- ChipModalFooter,
- ChipModalHeader,
- chipFieldSurfaceClass,
- cn,
-} from '@sim/emcn'
-import { Calendar } from '@sim/emcn/icons'
-import { format } from 'date-fns'
-import { useParams } from 'next/navigation'
-import {
- PromptEditor,
- usePromptEditor,
-} from '@/app/workspace/[workspaceId]/home/components/user-input/components'
-import type {
- ScheduledTask,
- ScheduledTaskStatus,
-} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events'
-
-/**
- * Plaintext copy per task state: the status label and the verb that titles the
- * run-time field — the tense carries the state ("Ran" is done, "Failed" errored).
- * No icons, no status colors, by design. Total over the status union for type
- * safety, though `pending` tasks open the edit `TaskModal` instead.
- */
-const STATUS_COPY: Record = {
- pending: { label: 'Pending', timeTitle: 'Runs' },
- error: { label: 'Error', timeTitle: 'Failed' },
- completed: { label: 'Completed', timeTitle: 'Ran' },
-}
-
-interface TaskDetailsModalProps {
- /** The running or finished task to show. `null` keeps the modal closed. */
- task: ScheduledTask | null
- onClose: () => void
-}
-
-/**
- * Read-only record modal for tasks that are running, finished, or owned by
- * another execution actor. Three plaintext fields:
- * Status and the run time as copy fields, the prompt as a view-only chip editor.
- */
-export function TaskDetailsModal({ task, onClose }: TaskDetailsModalProps) {
- return (
- {
- if (!open) onClose()
- }}
- size='md'
- srTitle='Scheduled task'
- >
- {/* Key by the occurrence id so switching tasks while the modal stays open
- remounts the content — the editor seeds prompt + contexts on mount, so
- without a fresh mount it would keep showing the first task's prompt. */}
- {task && }
-
- )
-}
-
-/**
- * Inner content, mounted only while a task is shown (the Radix portal unmounts
- * closed content). Holding the read-only editor here keeps its mention-data
- * queries from firing on page load and re-seeds from the task on each open.
- */
-function TaskDetailsContent({ task, onClose }: { task: ScheduledTask; onClose: () => void }) {
- const { workspaceId } = useParams<{ workspaceId: string }>()
- /**
- * Seed the stored resource mentions (files, tables, knowledge) as the editor's
- * initial contexts — these can't be recovered from the prompt text alone. The
- * mount chipify pass then merges integration `@`-mentions and `/`-skills on top
- * (they DO chipify from text), so the overlay renders the full set. Seeding is
- * deliberate over a post-mount `setContexts`, which would clobber the
- * auto-registered integration/skill contexts.
- */
- const editor = usePromptEditor({
- workspaceId,
- initialValue: task.prompt,
- initialContexts: task.contexts,
- })
-
- return (
- <>
-
- Scheduled task
-
-
-
-
-
-
-
-
-
-
-
- >
- )
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/index.ts b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/index.ts
deleted file mode 100644
index 71f627bedc7..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { type TaskDraft, type TaskEditSeed, TaskModal, type TaskPrefill } from './task-modal'
diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-section.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-section.tsx
deleted file mode 100644
index e936999b586..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-section.tsx
+++ /dev/null
@@ -1,301 +0,0 @@
-'use client'
-
-import { useRef } from 'react'
-import {
- CalendarDayCell,
- ChipDatePicker,
- ChipModalField,
- ChipModalSeparator,
- Switch,
-} from '@sim/emcn'
-import { format } from 'date-fns'
-import type {
- MonthlyMode,
- Recurrence,
- RecurrenceFrequency,
-} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/recurrence'
-
-const WEEKDAY_PRESET = [1, 2, 3, 4, 5]
-/** Seed count when the user first chooses "ends after N runs". */
-const DEFAULT_END_AFTER_COUNT = 10
-/** Cadence a task falls back to when the user first flips on recurrence. */
-const DEFAULT_RECURRING_FREQUENCY = 'daily'
-
-/** Sunday-first weekday order with single-letter labels and full names for a11y. */
-const WEEKDAYS = [
- { value: 0, short: 'S', name: 'Sunday' },
- { value: 1, short: 'M', name: 'Monday' },
- { value: 2, short: 'T', name: 'Tuesday' },
- { value: 3, short: 'W', name: 'Wednesday' },
- { value: 4, short: 'T', name: 'Thursday' },
- { value: 5, short: 'F', name: 'Friday' },
- { value: 6, short: 'S', name: 'Saturday' },
-] as const
-
-/** Ordinal words for the 1st–5th weekday-of-month, matching a calendar app's labels. */
-const ORDINALS = ['first', 'second', 'third', 'fourth', 'fifth'] as const
-
-/** The frequency presets the dropdown authors, keyed by a synthetic option value. */
-type FrequencyOption = 'daily' | 'weekly' | 'weekdays' | 'monthly' | 'yearly' | 'custom'
-
-function isWeekdayPreset(weekdays: number[]): boolean {
- return (
- weekdays.length === WEEKDAY_PRESET.length && WEEKDAY_PRESET.every((d) => weekdays.includes(d))
- )
-}
-
-/**
- * Collapses a recurring recurrence into the single dropdown value that
- * represents it. `once` maps to the default cadence as an exhaustiveness
- * fallback: callers gate on `isRecurring`, so it never reaches here at runtime,
- * but the dropdown can't represent it — mapping it keeps the return type
- * `FrequencyOption` without a cast.
- */
-function frequencyOptionFor(recurrence: Recurrence): FrequencyOption {
- if (recurrence.frequency === 'weekly')
- return isWeekdayPreset(recurrence.weekdays) ? 'weekdays' : 'weekly'
- if (recurrence.frequency === 'monthly') return 'monthly'
- if (recurrence.frequency === 'yearly') return 'yearly'
- if (recurrence.frequency === 'custom') return 'custom'
- if (recurrence.frequency === 'once') return DEFAULT_RECURRING_FREQUENCY
- return recurrence.frequency
-}
-
-/**
- * The monthly sub-options, derived from the launch date the same way a calendar
- * app offers them: repeat on the day number, on the ordinal weekday of the
- * month (e.g. the third Tuesday), or on the last weekday of the month.
- *
- * The ordinal anchor is offered only for the 1st–4th occurrence: a 5th
- * occurrence is always the month's last weekday, so — like a calendar app — it
- * is folded into the "last weekday" option rather than offering a "fifth" that
- * would silently skip months without a 5th occurrence.
- */
-function monthlyModeOptions(launch: Date): Array<{ value: MonthlyMode; label: string }> {
- const weekdayName = format(launch, 'EEEE')
- const ordinal = Math.ceil(launch.getDate() / 7)
- const options: Array<{ value: MonthlyMode; label: string }> = [
- { value: 'day-of-month', label: `On day ${format(launch, 'd')}` },
- ]
- if (ordinal <= 4)
- options.push({ value: 'nth-weekday', label: `On the ${ORDINALS[ordinal - 1]} ${weekdayName}` })
- options.push({ value: 'last-weekday', label: `On the last ${weekdayName}` })
- return options
-}
-
-interface RecurrenceSectionProps {
- recurrence: Recurrence
- onChange: (recurrence: Recurrence) => void
- /** The launch day, so weekly/monthly labels name the weekday and day-of-month. */
- launchDate: string
-}
-
-/**
- * The repeat + end controls for a scheduled task, rendered as a body section
- * below the prompt: a "Recurring" {@link Switch} that toggles a one-time launch
- * into a repeat, and — once on — the frequency preset, its cadence detail (the
- * weekly day toggles or the monthly anchor), and how it ends (never, on a date,
- * or after N runs).
- *
- * Composed as a sibling between the prompt body and footer; it owns its own
- * leading separator and mirrors {@link ChipModalBody}'s spacing
- * (`gap-4 px-2 pt-4 pb-4.5`) so every {@link ChipModalField} lands at the same
- * effective `px-4` as the modal header/footer — no changes to the `ChipModal`
- * primitives.
- */
-export function RecurrenceSection({ recurrence, onChange, launchDate }: RecurrenceSectionProps) {
- /**
- * The cadence to reinstate when recurrence is toggled back on. Toggling off
- * collapses `frequency` to `once`, dropping which preset was active, so the
- * last recurring cadence is cached here and restored — a paused "Weekly on
- * Mon" returns as weekly, not silently reset to daily. Written during render
- * (an idempotent cache), so it is current before the toggle handler reads it.
- */
- const lastRecurringFrequency = useRef(DEFAULT_RECURRING_FREQUENCY)
- if (recurrence.frequency !== 'once') lastRecurringFrequency.current = recurrence.frequency
-
- const launch = new Date(`${launchDate}T00:00`)
- const isRecurring = recurrence.frequency !== 'once'
- const selectedWeekdays = recurrence.weekdays.length > 0 ? recurrence.weekdays : [launch.getDay()]
-
- const monthlyOptions = monthlyModeOptions(launch)
- const monthlyMode = recurrence.monthlyMode ?? 'day-of-month'
- // If the launch date drifted to a 5th occurrence, the nth anchor is no longer
- // offered; fall back to "last weekday", which is exactly what it compiles to.
- const monthlyValue = monthlyOptions.some((option) => option.value === monthlyMode)
- ? monthlyMode
- : 'last-weekday'
-
- const frequencyOptions = [
- { value: 'daily', label: 'Daily' },
- { value: 'weekly', label: 'Weekly' },
- { value: 'weekdays', label: 'Weekdays' },
- { value: 'monthly', label: 'Monthly' },
- { value: 'yearly', label: `Yearly on ${format(launch, 'MMM d')}` },
- ...(recurrence.frequency === 'custom' ? [{ value: 'custom', label: 'Custom' }] : []),
- ]
-
- /**
- * Flips the one-time launch into a repeat and back. Toggling off keeps the
- * recurrence shape (weekdays, end, and a passed-through `custom` cron) on the
- * object and only collapses `frequency` to `once`; toggling back on reinstates
- * the remembered cadence, so neither a weekly preset nor a conversationally
- * authored custom cron is silently rewritten to daily.
- */
- const handleRecurringToggle = (checked: boolean) => {
- onChange({ ...recurrence, frequency: checked ? lastRecurringFrequency.current : 'once' })
- }
-
- const handleFrequencyChange = (value: string) => {
- const option = value as FrequencyOption
- switch (option) {
- case 'daily':
- onChange({ ...recurrence, frequency: 'daily', weekdays: [], cron: undefined })
- return
- case 'weekly':
- onChange({
- ...recurrence,
- frequency: 'weekly',
- weekdays: [launch.getDay()],
- cron: undefined,
- })
- return
- case 'weekdays':
- onChange({
- ...recurrence,
- frequency: 'weekly',
- weekdays: [...WEEKDAY_PRESET],
- cron: undefined,
- })
- return
- case 'monthly':
- onChange({
- ...recurrence,
- frequency: 'monthly',
- weekdays: [],
- monthlyMode: recurrence.monthlyMode ?? 'day-of-month',
- cron: undefined,
- })
- return
- case 'yearly':
- onChange({ ...recurrence, frequency: 'yearly', weekdays: [], cron: undefined })
- return
- case 'custom':
- onChange({ ...recurrence, frequency: 'custom' })
- }
- }
-
- /** Toggles a weekday on or off, never letting the last selected day be cleared. */
- const handleWeekdayToggle = (day: number) => {
- const isSelected = selectedWeekdays.includes(day)
- if (isSelected && selectedWeekdays.length === 1) return
- const weekdays = isSelected
- ? selectedWeekdays.filter((d) => d !== day)
- : [...selectedWeekdays, day].sort((a, b) => a - b)
- onChange({ ...recurrence, weekdays })
- }
-
- const handleEndChange = (value: string) => {
- if (value === 'never') onChange({ ...recurrence, end: { type: 'never' } })
- else if (value === 'on')
- onChange({ ...recurrence, end: { type: 'on', date: format(launch, 'yyyy-MM-dd') } })
- else {
- const count = recurrence.end.type === 'after' ? recurrence.end.count : DEFAULT_END_AFTER_COUNT
- onChange({ ...recurrence, end: { type: 'after', count } })
- }
- }
-
- return (
-
-
-
-
-
-
-
- {isRecurring && (
- <>
-
-
- {recurrence.frequency === 'weekly' && (
-
- {/* A one-row extract of the calendar: seven equal day cells built
- from the same {@link CalendarDayCell} the date picker uses, so
- the weekday toggles read as a sibling of the calendar rather than
- a separate segmented bar. */}
-
{
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/file-list/file-list.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/file-list/file-list.tsx
index ef8aab99bdb..85158ee7b9d 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/file-list/file-list.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/file-list/file-list.tsx
@@ -2,7 +2,7 @@
import { memo, useMemo, useState } from 'react'
import { cn } from '@sim/emcn'
-import { ChevronRight } from 'lucide-react'
+import { ChevronRight } from '@sim/emcn/icons'
import Link from 'next/link'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import type { WorkspaceFileFolderApi } from '@/hooks/queries/workspace-file-folders'
@@ -92,9 +92,7 @@ const FileTreeNodeItem = memo(function FileTreeNodeItem({
style={{ paddingLeft: `${8 + level * INDENT_PER_LEVEL + CHEVRON_WIDTH}px` }}
>
{FILE_ICON}
-
- {node.name}
-
+ {node.name}
)
}
@@ -132,7 +130,7 @@ const FileTreeNodeItem = memo(function FileTreeNodeItem({
)}
-
+
{node.name}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/help-modal/help-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/help-modal/help-modal.tsx
index 41fe122cd38..6e734ec5c31 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/help-modal/help-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/help-modal/help-modal.tsx
@@ -9,10 +9,10 @@ import {
ChipModalFooter,
ChipModalHeader,
} from '@sim/emcn'
+import { X } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { useMutation } from '@tanstack/react-query'
import imageCompression from 'browser-image-compression'
-import { X } from 'lucide-react'
import Image from 'next/image'
import { Controller, useForm } from 'react-hook-form'
import { z } from 'zod'
@@ -250,8 +250,8 @@ export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpM
}
return (
-
- onOpenChange(false)}>Help & support
+
+ onOpenChange(false)}>Contact support
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/index.ts
new file mode 100644
index 00000000000..c181faf748e
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/index.ts
@@ -0,0 +1 @@
+export { SidebarFooter } from './sidebar-footer'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.tsx
new file mode 100644
index 00000000000..ff0adf795e6
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.tsx
@@ -0,0 +1,259 @@
+'use client'
+
+import type { ComponentType } from 'react'
+import {
+ Chip,
+ chipContentLabelClass,
+ chipVariants,
+ cn,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+ Skeleton,
+} from '@sim/emcn'
+import { BookOpen, Credit, HelpCircle, Settings, Trash, Users } from '@sim/emcn/icons'
+import { SlackIcon } from '@/components/icons'
+import { useSession } from '@/lib/auth/auth-client'
+import { canViewWorkspaceBillingSettings } from '@/lib/billing/workspace-permissions'
+import { isBillingEnabled } from '@/lib/core/config/env-flags'
+import { getUserColor } from '@/lib/workspaces/colors'
+import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
+import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'
+import { SIDEBAR_ITEM_GAP_CLASS } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
+import { SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
+import { useUserProfile } from '@/hooks/queries/user-profile'
+import { useWorkspaceInvitePolicy } from '@/hooks/use-workspace-invite-policy'
+
+/**
+ * Settings destinations reachable from the profile menu, in display order. Labels
+ * and icons mirror the settings navigation entries they open, so the menu and the
+ * settings sidebar never disagree about what a section is called.
+ *
+ * Which of them a given viewer actually gets is decided in {@link SidebarFooter} —
+ * the same gates the settings sidebar and the section route apply, so the menu
+ * never lists a page the server would refuse.
+ */
+const PROFILE_MENU_ITEMS: readonly {
+ section: SettingsSection
+ label: string
+ icon: ComponentType<{ className?: string }>
+}[] = [
+ { section: 'general', label: 'Settings', icon: Settings },
+ { section: 'billing', label: 'Subscription', icon: Credit },
+ { section: 'teammates', label: 'Teammates', icon: Users },
+ { section: 'recently-deleted', label: 'Recently deleted', icon: Trash },
+]
+
+interface SidebarFooterProps {
+ workspaceId: string
+ isCollapsed: boolean
+ showCollapsedTooltips: boolean
+ onOpenSettings: (section: SettingsSection) => void
+ onOpenDocs: () => void
+ onJoinSlack: () => void
+ onContactSupport: () => void
+}
+
+/**
+ * Pinned bottom bar of the workspace sidebar: the viewer's avatar and name, which
+ * open a menu of their settings destinations, plus a help menu.
+ *
+ * Expanded, the two share one row — the profile claims the free width so the help
+ * button lands hard right, mirroring the collapse control in the workspace header.
+ * Collapsed, the rail is too narrow for a row, so they stack as icon chips with
+ * help on top and the profile resting at the foot of the rail.
+ *
+ * Both layouts are the same two elements — only the container's direction and the
+ * children's classes change — because `isCollapsed` flips in one frame while the
+ * rail takes 200ms to widen, so the row spends that window laid out at a width it
+ * does not fit in. Neither element may give ground there: the profile stops at its
+ * avatar (no `min-w-0`) and the help button never shrinks, so the row overflows the
+ * narrow rail and the aside's `overflow-hidden` clips it. The avatar keeps the exact
+ * position it holds collapsed, and the `?` rides in on the opening edge — paced by
+ * the rail itself rather than by a duration of its own.
+ *
+ * Collapsed reverses the flex direction instead of reordering the DOM, which is what
+ * keeps both elements (and the help menu's trigger) alive across a toggle. The cost
+ * is bottom-up focus order there, a smaller price for one pair of adjacent controls
+ * than remounting a trigger mid-animation.
+ */
+export function SidebarFooter({
+ workspaceId,
+ isCollapsed,
+ showCollapsedTooltips,
+ onOpenSettings,
+ onOpenDocs,
+ onJoinSlack,
+ onContactSupport,
+}: SidebarFooterProps) {
+ const { data: profile } = useUserProfile()
+ const { data: session } = useSession()
+ const hostContext = useWorkspaceHostContext()
+ const { isInvitationsDisabled } = useWorkspaceInvitePolicy(workspaceId)
+
+ const name = profile ? profile.name?.trim() || profile.email : ''
+
+ /**
+ * Subscription is dropped for viewers the Billing page would turn away — a
+ * deployment with billing off, or anyone who is not the payer (on an
+ * organization-hosted workspace, every member who is not an org admin). The
+ * settings sidebar hides its own Billing entry on exactly this test.
+ */
+ const menuItems = PROFILE_MENU_ITEMS.filter(
+ (item) =>
+ item.section !== 'billing' || canViewWorkspaceBillingSettings(hostContext, session?.user?.id)
+ )
+
+ /**
+ * Teammates is a dead end on a plan that cannot invite, so a blocked viewer is
+ * sent to the plan itself instead — which resolves to the upgrade page for
+ * anyone who cannot manage the payer. With billing off there is nowhere to send
+ * them and no upgrade to make, so the row simply does nothing. This is the gate
+ * the workspace switcher's "Manage workspace" entry carried before this menu
+ * took the section over.
+ */
+ const handleSelectSection = (section: SettingsSection) => {
+ if (section === 'teammates' && isInvitationsDisabled) {
+ if (isBillingEnabled) onOpenSettings('billing')
+ return
+ }
+ onOpenSettings(section)
+ }
+
+ /**
+ * Built from plain `img`/`div` rather than the emcn `Avatar`, whose Radix root
+ * renders a `` — and globals fade every `span` in the collapsed rail to
+ * `opacity: 0`, which blanked the avatar exactly where it is the only thing
+ * left to see. The workspace header's logo sidesteps the same rule the same way.
+ */
+ const avatar = !profile ? (
+
+ ) : profile.image ? (
+
+ ) : (
+
+ {name.charAt(0).toUpperCase()}
+
+ )
+
+ /**
+ * Expanded, the chip hugs its content (`max-w-full` so a long name truncates
+ * rather than overflowing) and the free width belongs to the wrapper it sits in,
+ * so hover highlights only the avatar and name. Collapsed, `fullWidth` fills the
+ * narrow rail instead. Both mirror the workspace header's chip exactly.
+ *
+ * No `min-w-0`: the label already truncates on its own, and letting the chip
+ * shrink past its avatar is what let the help button ride onto the photo while
+ * the rail was still narrow (see {@link SidebarFooter}). Its automatic minimum
+ * is exactly the icon-only chip, so the avatar holds the same spot at any width.
+ *
+ * The name is the button's accessible name — no `aria-label`, which would
+ * override the visible text. Radix contributes the menu role and expanded state.
+ */
+ const profileMenu = (
+
+
+
+
+ {avatar}
+ {profile ? (
+ {name}
+ ) : (
+ /* Fixed width — the chip hugs its content, so a flexible bar would collapse to nothing. */
+
+ )}
+
+
+
+
+ {menuItems.map(({ section, label, icon: Icon }) => (
+ handleSelectSection(section)}>
+
+ {label}
+
+ ))}
+
+
+ )
+
+ /**
+ * The same `Chip` the workspace header uses for Search and Collapse, so the two
+ * ends of the rail carry identical chrome — box, radius, hover fill, and glyph
+ * size all come from the component rather than a local class string.
+ *
+ * One node across both states; only `fullWidth` changes (collapsed it fills the
+ * rail, expanded it hugs its icon). Toggling a prop rather than rendering two
+ * buttons keeps the same DOM node — and the same Radix menu — alive through the
+ * transition instead of tearing one trigger down and mounting another mid-animation.
+ *
+ * Icon-only in both: the collapsed rail hides labels anyway. The tooltip names it
+ * while the rail is collapsed.
+ */
+ const helpMenu = (
+
+
+
+
+
+
+ {/* Anchored to whichever edge the trigger sits on, so the menu never overhangs the rail. */}
+
+
+
+ Docs
+
+
+
+ Join Slack
+
+
+
+ Contact support
+
+
+
+ )
+
+ return (
+
+ {/* Expanded, claims the row's free width so the help button lands hard right —
+ the same wrapper the workspace header puts around its chip. `flex` makes the
+ inline-flex chip a flex item rather than an inline one, so the wrapper is
+ exactly the chip's 30px instead of a line box padded by the strut's
+ half-leading, which would deepen the bar below the chip. Collapsed, it
+ stretches to the rail on its own and the chip fills it. */}
+
{profileMenu}
+ {helpMenu}
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-section/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-section/index.ts
new file mode 100644
index 00000000000..225ee6fb60e
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-section/index.ts
@@ -0,0 +1 @@
+export { SidebarSection } from './sidebar-section'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-section/sidebar-section.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-section/sidebar-section.tsx
new file mode 100644
index 00000000000..341f8f8091c
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-section/sidebar-section.tsx
@@ -0,0 +1,119 @@
+'use client'
+
+import { type ReactNode, useState } from 'react'
+import { ChevronDown, cn, Expandable, ExpandableContent } from '@sim/emcn'
+
+/**
+ * Title-row layout, shared by the toggle and its rail-collapsed counterpart so the
+ * label lands identically either way. The row's gutter lives here rather than on the
+ * row itself: the toggle stretches edge to edge and insets only its own content, so
+ * the full width of the rail is clickable rather than just the text and chevron.
+ */
+const TITLE_ROW_CLASS = 'flex h-full min-w-0 flex-1 items-center gap-2 px-4'
+
+/**
+ * One transition covering both of the chevron's states — the hover fade and the
+ * collapse rotation — so the two can never drift apart. 150ms on Tailwind's default
+ * `cubic-bezier(0.4, 0, 0.2, 1)` is the same curve the `collapsible-up`/`-down`
+ * keyframes use for the body, so the whole section animates as one gesture.
+ */
+const CHEVRON_TRANSITION_CLASS = 'transition-[opacity,transform] duration-150'
+
+interface SidebarSectionProps {
+ title: string
+ /** Controls pinned to the right of the title (e.g. the Workflows create/more buttons). */
+ action?: ReactNode
+ /**
+ * True while the rail is collapsed. Only icons are visible there, so the title
+ * renders as a static label — no chevron, nothing focusable — and the content
+ * stays open regardless of what the user last toggled.
+ */
+ railCollapsed?: boolean
+ /** Layout classes for the section wrapper (section gap, positioning). */
+ className?: string
+ children: ReactNode
+}
+
+/**
+ * A titled, collapsible group of sidebar items ("Chats", "Workspace", "Workflows",
+ * and each settings group).
+ *
+ * Owns the section's vertical rhythm so every section matches: an 18px header row
+ * — exactly the title's line box at `text-caption` — then a 6px gap to the content,
+ * sitting between the 2px item gap and the 16px section gap. The gap rides inside
+ * the collapsing content, so it closes along with it. Consumers supply only the
+ * item container.
+ *
+ * The title carries `sidebar-collapse-hide` so it fades out with the rail while the
+ * row keeps its height, holding the collapsed rail on the expanded rail's grid.
+ */
+export function SidebarSection({
+ title,
+ action,
+ railCollapsed = false,
+ className,
+ children,
+}: SidebarSectionProps) {
+ const [expanded, setExpanded] = useState(true)
+ /**
+ * Collapse animations are enabled only after the first user toggle, so sections
+ * render at full height on mount instead of replaying the open animation.
+ */
+ const [animationsEnabled, setAnimationsEnabled] = useState(false)
+
+ const handleToggle = () => {
+ setAnimationsEnabled(true)
+ setExpanded((prev) => !prev)
+ }
+
+ const label = (
+
+ {title}
+
+ )
+
+ return (
+
+
+ {railCollapsed ? (
+
{label}
+ ) : (
+
+ {label}
+ {/*
+ * Revealed by hovering anywhere in the section: the group sits on the
+ * section wrapper rather than this row, so the items below arm it just as
+ * the header does. Focus is keyed off the toggle instead — the only element
+ * here that can hold it — and matters because globals clear focus outlines.
+ */}
+
+
+ )}
+ {/* Carries the gutter the row gave up so the toggle can reach the rail's edge. */}
+ {action ?
{action}
: null}
+
+
+
+ {/* The header gap pads an inner wrapper rather than the animated element:
+ `collapsible-up`/`-down` interpolate height alone, so a margin here would
+ hold its full 6px for the whole close and then vanish on unmount, snapping
+ the next section up. Padding on the content itself can't work either —
+ border-box keeps it rendered at `height: 0`, so the section never shuts. */}
+
- )
-}
-
-/**
- * Read-only recursive tree of workflow references. Each row navigates to its
- * workflow on click; cyclic leaves are marked and render no children.
- */
-export function ReferenceTree({ nodes, onNavigate }: ReferenceTreeProps) {
- return (
-
- {nodes.map((node) => (
-
- ))}
-
- )
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/references-modal/references-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/references-modal/references-modal.tsx
deleted file mode 100644
index e507111c956..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/references-modal/references-modal.tsx
+++ /dev/null
@@ -1,74 +0,0 @@
-'use client'
-
-import { useState } from 'react'
-import { ChipModal, ChipModalBody, ChipModalHeader, ChipModalTabs } from '@sim/emcn'
-import { useRouter } from 'next/navigation'
-import { ReferenceTree } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/references-modal/components/reference-tree/reference-tree'
-import { useWorkflowReferences } from '@/hooks/queries/workflow-references'
-
-type ReferencesTab = 'callers' | 'callees'
-
-const TABS = [
- { value: 'callers', label: 'Used by' },
- { value: 'callees', label: 'Uses' },
-] as const
-
-const EMPTY_MESSAGE: Record = {
- callers: 'No workflows call this workflow.',
- callees: "This workflow doesn't call any other workflows.",
-}
-
-interface ReferencesModalProps {
- onClose: () => void
- workspaceId: string
- workflowId: string
- workflowName: string
-}
-
-/**
- * IDE-style reference viewer for a workflow. "Used by" lists the workflows that
- * call it (inbound); "Uses" lists the workflows it calls (outbound). Both are
- * recursive trees whose rows navigate to the referenced workflow. Mounted on
- * demand by the owning row, so state initializes fresh per open.
- */
-export function ReferencesModal({
- onClose,
- workspaceId,
- workflowId,
- workflowName,
-}: ReferencesModalProps) {
- const router = useRouter()
- const [activeTab, setActiveTab] = useState('callers')
-
- const { data, isPending, isError } = useWorkflowReferences(workflowId)
-
- const handleNavigate = (targetId: string) => {
- router.push(`/workspace/${workspaceId}/w/${targetId}`)
- onClose()
- }
-
- const nodes = data?.[activeTab] ?? []
-
- return (
- !next && onClose()} srTitle='References'>
- References — {workflowName}
-
- setActiveTab(value as ReferencesTab)}
- aria-label='Reference direction'
- />
- {isPending ? (
-
Loading references…
- ) : isError ? (
-
Failed to load references.
- ) : nodes.length === 0 ? (
-
{EMPTY_MESSAGE[activeTab]}
- ) : (
-
- )}
-
-
- )
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx
index 372d093b014..5eb559647d7 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx
@@ -2,15 +2,13 @@
import { memo, useCallback, useMemo, useRef, useState } from 'react'
import { chipVariants, cn } from '@sim/emcn'
-import { Lock } from '@sim/emcn/icons'
+import { Lock, MoreHorizontal } from '@sim/emcn/icons'
import clsx from 'clsx'
-import { MoreHorizontal } from 'lucide-react'
import Link from 'next/link'
import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal'
-import { ReferencesModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/references-modal/references-modal'
import { Avatars } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/avatars/avatars'
import {
useContextMenu,
@@ -81,7 +79,6 @@ export const WorkflowItem = memo(function WorkflowItem({
const { canDeleteWorkflows, canDeleteFolder } = useCanDelete({ workspaceId })
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
- const [isReferencesOpen, setIsReferencesOpen] = useState(false)
const [deleteItemType, setDeleteItemType] = useState<'workflow' | 'mixed'>('workflow')
const [deleteModalNames, setDeleteModalNames] = useState('')
const [canDeleteSelection, setCanDeleteSelection] = useState(true)
@@ -400,10 +397,6 @@ export const WorkflowItem = memo(function WorkflowItem({
[shouldPreventClickRef, workflow.id, onWorkflowClick, isEditing]
)
- const handleFindReferences = useCallback(() => {
- setIsReferencesOpen(true)
- }, [])
-
return (
<>
-
- {isReferencesOpen && (
- setIsReferencesOpen(false)}
- workspaceId={workspaceId}
- workflowId={workflow.id}
- workflowName={workflow.name}
- />
- )}
>
)
})
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-menu-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-menu-item.tsx
index 4fe2657eff8..c0d1e12e5c2 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-menu-item.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-menu-item.tsx
@@ -22,7 +22,7 @@ export function ViewInvitationsMenuItem({ onOpen }: ViewInvitationsMenuItemProps
}
return (
-
+
View invitations
)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx
index 09839ddf978..6c76d2c69a5 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx
@@ -2,10 +2,11 @@
import { memo, type ReactElement, useEffect, useRef, useState } from 'react'
import {
- ChevronDown,
Chip,
+ ChipChevronDown,
ChipConfirmModal,
ChipInput,
+ chipContentLabelClass,
chipGeometryClass,
chipVariants,
cn,
@@ -18,10 +19,9 @@ import {
Skeleton,
Tooltip,
} from '@sim/emcn'
-import { ManageWorkspace, PanelLeft } from '@sim/emcn/icons'
+import { MoreHorizontal, PanelLeft, Search } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
-import { MoreHorizontal, Search } from 'lucide-react'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { InviteModal } from '@/app/workspace/[workspaceId]/components/invite-modal'
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
@@ -38,6 +38,7 @@ import {
} from '@/hooks/queries/workspace'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
+import { SIDEBAR_WIDTH } from '@/stores/constants'
const logger = createLogger('WorkspaceHeader')
@@ -261,6 +262,11 @@ function WorkspaceHeaderImpl({
* server refused the send.
*/
const { userPermissions } = useWorkspacePermissionsContext()
+ /**
+ * Derived from the `workspaces` prop rather than {@link useWorkspaceInvitePolicy}:
+ * this component is already handed the list it would otherwise re-read, and the
+ * same object supplies the logo, color, and organization below.
+ */
const inviteDisabledReason = activeWorkspaceFull?.inviteDisabledReason ?? null
const isInvitationsDisabled = isInvitationsDisabledByConfig || inviteDisabledReason !== null
@@ -493,10 +499,8 @@ function WorkspaceHeaderImpl({
)}
{!isCollapsed && activeWorkspace?.name && (
<>
-
- {activeWorkspace.name}
-
-
+ {activeWorkspace.name}
+
>
)}
@@ -507,7 +511,7 @@ function WorkspaceHeaderImpl({
sideOffset={isCollapsed ? 16 : 8}
className='flex max-h-none flex-col overflow-hidden'
style={{
- width: '248px',
+ width: `${SIDEBAR_WIDTH.DEFAULT}px`,
maxWidth: 'calc(100vw - 24px)',
}}
onCloseAutoFocus={(e) => e.preventDefault()}
@@ -595,9 +599,7 @@ function WorkspaceHeaderImpl({
}
>
{editingWorkspaceId === workspace.id ? (
-
>
)}
@@ -814,11 +793,10 @@ function WorkspaceHeaderImpl({
@@ -842,10 +820,8 @@ function WorkspaceHeaderImpl({
)}
{!isCollapsed && activeWorkspace?.name && (
<>
-
- {activeWorkspace.name}
-
-
+ {activeWorkspace.name}
+
>
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/constants.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/constants.ts
index 88a25c6de88..1b2df2b4460 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/constants.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/constants.ts
@@ -6,11 +6,29 @@
* rhythm changes, update these values and every consumer follows.
*/
-/** Vertical gap between sibling sidebar sections (12px). */
-export const SIDEBAR_SECTION_GAP_CLASS = 'mt-3'
+/** Vertical gap between sibling sidebar sections (16px). */
+export const SIDEBAR_SECTION_GAP_CLASS = 'mt-4'
-/** Vertical gap between items within a sidebar section (2px). */
-export const SIDEBAR_ITEM_GAP_CLASS = 'gap-0.5'
+/**
+ * Vertical gap between items within a sidebar section (1px).
+ *
+ * Written as an arbitrary value, not `gap-px`: the `px` spacing key is remapped
+ * to `--border-width`, which thins to 0.5px on hidpi displays so hairline borders
+ * stay hairlines. That is right for a rule and wrong for a gap — this one is a
+ * literal pixel at every density.
+ */
+export const SIDEBAR_ITEM_GAP_CLASS = 'gap-[1px]'
+
+/**
+ * Halves of {@link SIDEBAR_SECTION_GAP_CLASS} straddling the scroll region's
+ * divider: the pinned block above carries the top half, the scroll region below
+ * carries the bottom half. Split this way the divider sits centered in a gap that
+ * reads as one section gap, so the first section header is spaced from the block
+ * above it exactly like every other section boundary. Keep both in step with the
+ * section gap.
+ */
+export const SIDEBAR_DIVIDER_PAD_ABOVE_CLASS = 'pb-2'
+export const SIDEBAR_DIVIDER_PAD_BELOW_CLASS = 'pt-2'
/**
* Nested-selector variants for cmdk-based surfaces (e.g. the search modal).
@@ -18,7 +36,7 @@ export const SIDEBAR_ITEM_GAP_CLASS = 'gap-0.5'
*/
/** Matches {@link SIDEBAR_SECTION_GAP_CLASS} applied to adjacent cmdk groups. */
-export const CMDK_SECTION_GAP_CLASS = '[&_[cmdk-group]+[cmdk-group]]:mt-3'
+export const CMDK_SECTION_GAP_CLASS = '[&_[cmdk-group]+[cmdk-group]]:mt-4'
/** Matches {@link SIDEBAR_ITEM_GAP_CLASS} applied to cmdk item containers. */
-export const CMDK_ITEM_GAP_CLASS = '[&_[cmdk-group-items]]:gap-0.5'
+export const CMDK_ITEM_GAP_CLASS = '[&_[cmdk-group-items]]:gap-[1px]'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx
index c47212fa5e5..e3f0741b269 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx
@@ -20,26 +20,22 @@ import {
Upload,
} from '@sim/emcn'
import {
- BookOpen,
- Calendar,
Database,
Files,
- HelpCircle,
Integration,
+ MoreHorizontal,
PanelLeft,
+ Pin,
Plus,
Search,
- Settings,
Table,
Task,
Workflow,
} from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
-import { MoreHorizontal, Pin } from 'lucide-react'
import Link from 'next/link'
import { useParams, usePathname, useRouter } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
-import { SlackIcon } from '@/components/icons'
import { useSession } from '@/lib/auth/auth-client'
import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types'
import { isChatEnabled } from '@/lib/core/config/env-flags'
@@ -48,6 +44,7 @@ import { buildFolderTree, getFolderPathNames } from '@/lib/folders/tree'
import { captureEvent } from '@/lib/posthog/client'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
+import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
import {
CollapsedChatFlyoutItem,
@@ -58,6 +55,8 @@ import {
NavItemContextMenu,
SearchModal,
SettingsSidebar,
+ SidebarFooter,
+ SidebarSection,
WorkflowList,
WorkspaceHeader,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components'
@@ -68,6 +67,8 @@ import {
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal'
import {
+ SIDEBAR_DIVIDER_PAD_ABOVE_CLASS,
+ SIDEBAR_DIVIDER_PAD_BELOW_CLASS,
SIDEBAR_ITEM_GAP_CLASS,
SIDEBAR_SECTION_GAP_CLASS,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
@@ -155,9 +156,10 @@ export function SidebarTooltip({
)
}
+/** Stands in for a chip row while a list loads, so it carries no margin either. */
function SidebarItemSkeleton() {
return (
-
) : null}
{/* `selectChatOnly` populates `selectedChats` on every click, so
- a single entry just means "last clicked" — already conveyed by
- `isCurrentRoute`. Highlight from selection only for explicit
- multi-selection (size > 1), otherwise it lingers after navigating
- away from a chat. */}
+ a single entry just means "last clicked" — already conveyed by
+ `isCurrentRoute`. Highlight from selection only for explicit
+ multi-selection (size > 1), otherwise it lingers after navigating
+ away from a chat. */}
{chats.slice(0, visibleChatCount).map((chat) => {
const isCurrentRoute = pathname === chat.href
const isRenaming = chatFlyoutRename.editingId === chat.id
@@ -1544,13 +1557,14 @@ export const Sidebar = memo(function Sidebar({
)}
{filteredCoreBlocks.length === 0 && filteredToolBlocks.length === 0 && (
@@ -1766,10 +1757,7 @@ export function GroupDetail({
setBlocksAllowed(filteredCoreBlocks, !coreBlocksAllAllowed)}
- >
+ setBlocksAllowed(filteredCoreBlocks, !coreBlocksAllAllowed)}>
{coreBlocksAllAllowed ? 'Deselect All' : 'Select All'}
}
@@ -1821,10 +1809,7 @@ export function GroupDetail({
}
action={
- setBlocksAllowed(filteredToolBlocks, !toolBlocksAllAllowed)}
- >
+ setBlocksAllowed(filteredToolBlocks, !toolBlocksAllAllowed)}>
{toolBlocksAllAllowed ? 'Deselect All' : 'Select All'}
}
@@ -1871,7 +1856,6 @@ export function GroupDetail({
),
}))
}
- flush
disabled={filteredPlatformFeatures.length === 0}
>
{platformAllVisible ? 'Deselect All' : 'Select All'}
diff --git a/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx b/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx
index d4d22822858..8d7b5ccaf1c 100644
--- a/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx
+++ b/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx
@@ -18,8 +18,8 @@ import {
Switch,
toast,
} from '@sim/emcn'
+import { ArrowLeft, ChevronDown, ImageUp as ImageIcon, X } from '@sim/emcn/icons'
import { getErrorMessage } from '@sim/utils/errors'
-import { ArrowLeft, ChevronDown, Image as ImageIcon, X } from 'lucide-react'
import { saveDiscardActions } from '@/components/settings/save-discard-actions'
import {
type FlattenOutputsBlockInput,
diff --git a/apps/sim/ee/custom-blocks/components/custom-blocks.tsx b/apps/sim/ee/custom-blocks/components/custom-blocks.tsx
index 428b433da5f..d21c047e725 100644
--- a/apps/sim/ee/custom-blocks/components/custom-blocks.tsx
+++ b/apps/sim/ee/custom-blocks/components/custom-blocks.tsx
@@ -2,7 +2,7 @@
import { useMemo, useState } from 'react'
import { ChipTag } from '@sim/emcn'
-import { Plus } from 'lucide-react'
+import { Plus } from '@sim/emcn/icons'
import { useParams } from 'next/navigation'
import { useQueryState } from 'nuqs'
import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation'
diff --git a/apps/sim/ee/data-retention/components/data-retention-settings.tsx b/apps/sim/ee/data-retention/components/data-retention-settings.tsx
index 81e49ceb12e..cbc49c956a4 100644
--- a/apps/sim/ee/data-retention/components/data-retention-settings.tsx
+++ b/apps/sim/ee/data-retention/components/data-retention-settings.tsx
@@ -14,11 +14,10 @@ import {
Search,
toast,
} from '@sim/emcn'
-import { ArrowLeft } from '@sim/emcn/icons'
+import { ArrowLeft, Plus } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
-import { Plus } from 'lucide-react'
import { CustomPatternsEditor } from '@/components/pii/custom-patterns-editor'
import { saveDiscardActions } from '@/components/settings/save-discard-actions'
import type { SettingsAction } from '@/components/settings/settings-header'
diff --git a/apps/sim/ee/sso/components/sso-settings.tsx b/apps/sim/ee/sso/components/sso-settings.tsx
index b7f3df18a85..6b31562a006 100644
--- a/apps/sim/ee/sso/components/sso-settings.tsx
+++ b/apps/sim/ee/sso/components/sso-settings.tsx
@@ -14,9 +14,9 @@ import {
Switch,
toast,
} from '@sim/emcn'
+import { ChevronDown, Eye, EyeOff } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
-import { ChevronDown, Eye, EyeOff } from 'lucide-react'
import { saveDiscardActions } from '@/components/settings/save-discard-actions'
import type { SettingsAction } from '@/components/settings/settings-header'
import type { SsoRegistrationBody } from '@/lib/api/contracts/auth'
diff --git a/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx b/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx
index 64dedd8a589..fa42fed2bce 100644
--- a/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx
+++ b/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx
@@ -2,9 +2,9 @@
import { useEffect, useRef, useState } from 'react'
import { Button, ChipInput, cn, Label, Loader, toast } from '@sim/emcn'
+import { ImageUp as ImageIcon, X } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
-import { Image as ImageIcon, X } from 'lucide-react'
import Image from 'next/image'
import { saveDiscardActions } from '@/components/settings/save-discard-actions'
import { isEnterprise } from '@/lib/billing/plan-helpers'
diff --git a/apps/sim/ee/workspace-forking/components/fork-workspace-modal/fork-workspace-modal.tsx b/apps/sim/ee/workspace-forking/components/fork-workspace-modal/fork-workspace-modal.tsx
index 0623ffc0de4..0f0c10fa431 100644
--- a/apps/sim/ee/workspace-forking/components/fork-workspace-modal/fork-workspace-modal.tsx
+++ b/apps/sim/ee/workspace-forking/components/fork-workspace-modal/fork-workspace-modal.tsx
@@ -11,7 +11,7 @@ import {
ChipModalHeader,
toast,
} from '@sim/emcn'
-import { AlertTriangle } from 'lucide-react'
+import { TriangleAlert } from '@sim/emcn/icons'
import { useRouter } from 'next/navigation'
import type { GetForkResourcesResponse } from '@/lib/api/contracts/workspace-fork'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
@@ -261,7 +261,7 @@ export function ForkWorkspaceModal({
)}
{hasDeselection ? (
-
+
Some resources are not selected — references to them in your workflows will be
cleared in the fork.
diff --git a/apps/sim/ee/workspace-forking/components/forks.tsx b/apps/sim/ee/workspace-forking/components/forks.tsx
index 550be464c49..6cc9789acd1 100644
--- a/apps/sim/ee/workspace-forking/components/forks.tsx
+++ b/apps/sim/ee/workspace-forking/components/forks.tsx
@@ -2,9 +2,8 @@
import { useState } from 'react'
import { ChipConfirmModal, toast } from '@sim/emcn'
-import { ArrowLeft } from '@sim/emcn/icons'
+import { ArrowLeft, Plus, TriangleAlert } from '@sim/emcn/icons'
import { getErrorMessage } from '@sim/utils/errors'
-import { AlertTriangle, Plus } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { useQueryState } from 'nuqs'
import { saveDiscardActions } from '@/components/settings/save-discard-actions'
@@ -550,7 +549,7 @@ export function Forks() {
}}
>
-
+
This cannot be undone — the saved mappings and sync history for this pair are deleted,
and forking again creates a brand-new workspace.
@@ -576,7 +575,7 @@ export function Forks() {
}}
>
-
+
Resources copied into this workspace during syncs may remain afterward — rollback
restores workflows to their prior versions but does not remove copied resources.
diff --git a/apps/sim/enrichments/company-domain/company-domain.ts b/apps/sim/enrichments/company-domain/company-domain.ts
index 03349d07247..82ae54dca6d 100644
--- a/apps/sim/enrichments/company-domain/company-domain.ts
+++ b/apps/sim/enrichments/company-domain/company-domain.ts
@@ -1,4 +1,4 @@
-import { Globe } from 'lucide-react'
+import { Globe } from '@sim/emcn/icons'
import { normalizeDomain, str, toolProvider } from '@/enrichments/providers'
import type { EnrichmentConfig } from '@/enrichments/types'
diff --git a/apps/sim/enrichments/company-info/company-info.ts b/apps/sim/enrichments/company-info/company-info.ts
index 407b7db95bd..43fbde12a23 100644
--- a/apps/sim/enrichments/company-info/company-info.ts
+++ b/apps/sim/enrichments/company-info/company-info.ts
@@ -1,5 +1,5 @@
+import { Building } from '@sim/emcn/icons'
import { filterUndefined } from '@sim/utils/object'
-import { Building2 } from 'lucide-react'
import { normalizeDomain, str, toolProvider } from '@/enrichments/providers'
import type { EnrichmentConfig } from '@/enrichments/types'
@@ -15,7 +15,7 @@ export const companyInfoEnrichment: EnrichmentConfig = {
id: 'company-info',
name: 'Company Info',
description: "Look up a company's size and description from its domain.",
- icon: Building2,
+ icon: Building,
inputs: [{ id: 'domain', name: 'Company domain', type: 'string', required: true }],
outputs: [
{ id: 'employeeCount', name: 'employee count', type: 'string' },
diff --git a/apps/sim/enrichments/phone-number/phone-number.ts b/apps/sim/enrichments/phone-number/phone-number.ts
index 270135e20fd..c135270181a 100644
--- a/apps/sim/enrichments/phone-number/phone-number.ts
+++ b/apps/sim/enrichments/phone-number/phone-number.ts
@@ -1,5 +1,5 @@
+import { Phone } from '@sim/emcn/icons'
import { filterUndefined } from '@sim/utils/object'
-import { Phone } from 'lucide-react'
import { firstNonEmpty, normalizeDomain, str, toolProvider } from '@/enrichments/providers'
import type { EnrichmentConfig } from '@/enrichments/types'
diff --git a/apps/sim/hooks/queries/schedules.ts b/apps/sim/hooks/queries/schedules.ts
index def9e736bdd..a2db0ad31e9 100644
--- a/apps/sim/hooks/queries/schedules.ts
+++ b/apps/sim/hooks/queries/schedules.ts
@@ -1,22 +1,13 @@
-import { toast } from '@sim/emcn'
import { createLogger } from '@sim/logger'
-import { getErrorMessage } from '@sim/utils/errors'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { isApiClientError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
import { deployWorkflowContract } from '@/lib/api/contracts/deployments'
import {
- type CreateScheduleBody,
- createScheduleContract,
- deleteScheduleContract,
- disableScheduleContract,
- excludeOccurrenceContract,
getScheduleByIdContract,
getScheduleContract,
listWorkspaceSchedulesContract,
reactivateScheduleContract,
- type UpdateScheduleBody,
- updateScheduleContract,
type WorkflowScheduleRow,
type WorkspaceScheduleRow,
} from '@/lib/api/contracts/schedules'
@@ -92,18 +83,15 @@ export function useWorkspaceSchedules(workspaceId?: string, options?: { enabled?
staleTime: SCHEDULE_LIST_STALE_TIME,
placeholderData: keepPreviousData,
// Pinned off (not inheriting the QueryClient default, which is on in the
- // desktop app): a background refetch regenerates occurrence ids, which
- // would close an open scheduled-task modal and drop its draft. See the
- // taskById note in scheduled-tasks/hooks/use-scheduled-tasks.ts.
+ // desktop app): a background refetch regenerates occurrence ids, so any
+ // consumer holding one across a refetch would lose it mid-edit.
refetchOnWindowFocus: false,
})
}
/**
- * Fetch a single schedule (job) by id. Used by the mothership resource viewer so
- * opening a scheduled-task artifact does a lightweight by-id read instead of the
- * whole-workspace `useWorkspaceSchedules` fetch (which contended with the chat
- * stream connection and stalled start/resume).
+ * Fetch a single workflow schedule by id — a lightweight by-id read instead of
+ * the whole-workspace `useWorkspaceSchedules` fetch.
*/
export function useScheduleById(scheduleId?: string) {
return useQuery({
@@ -229,223 +217,6 @@ export function useReactivateSchedule() {
})
}
-/**
- * Mutation to disable an active schedule or job
- */
-export function useDisableSchedule() {
- const queryClient = useQueryClient()
-
- return useMutation({
- mutationFn: async ({
- scheduleId,
- workspaceId,
- }: {
- scheduleId: string
- workspaceId: string
- }) => {
- await requestJson(disableScheduleContract, {
- params: { id: scheduleId },
- body: { action: 'disable' },
- })
-
- return { workspaceId }
- },
- onSuccess: () => {
- toast.success('Task paused')
- },
- onError: (error) => {
- logger.error('Failed to disable schedule', { error })
- toast.error("Couldn't pause task", { description: getErrorMessage(error) })
- },
- onSettled: async (data) => {
- if (!data) return
- await Promise.all([
- queryClient.invalidateQueries({ queryKey: scheduleKeys.list(data.workspaceId) }),
- queryClient.invalidateQueries({ queryKey: scheduleKeys.details() }),
- ])
- },
- })
-}
-
-/**
- * Mutation to resume (reactivate) a paused standalone job schedule. Keyed by
- * `workspaceId` so it invalidates the workspace list; the workflow-block variant
- * {@link useReactivateSchedule} keys by `workflowId`/`blockId` instead. Resuming
- * recomputes `nextRunAt` from the schedule's cron, so it applies to recurring
- * tasks only — one-time tasks carry no cadence to resume.
- */
-export function useResumeSchedule() {
- const queryClient = useQueryClient()
-
- return useMutation({
- mutationFn: async ({
- scheduleId,
- workspaceId,
- }: {
- scheduleId: string
- workspaceId: string
- }) => {
- await requestJson(reactivateScheduleContract, {
- params: { id: scheduleId },
- body: { action: 'reactivate' },
- })
-
- return { workspaceId }
- },
- onSuccess: () => {
- toast.success('Task resumed')
- },
- onError: (error) => {
- logger.error('Failed to resume schedule', { error })
- toast.error("Couldn't resume task", { description: getErrorMessage(error) })
- },
- onSettled: async (data) => {
- if (!data) return
- await Promise.all([
- queryClient.invalidateQueries({ queryKey: scheduleKeys.list(data.workspaceId) }),
- queryClient.invalidateQueries({ queryKey: scheduleKeys.details() }),
- ])
- },
- })
-}
-
-/**
- * Mutation to delete a schedule or job
- */
-export function useDeleteSchedule() {
- const queryClient = useQueryClient()
-
- return useMutation({
- mutationFn: async ({
- scheduleId,
- workspaceId,
- }: {
- scheduleId: string
- workspaceId: string
- }) => {
- await requestJson(deleteScheduleContract, {
- params: { id: scheduleId },
- })
-
- return { workspaceId }
- },
- onSuccess: () => {
- toast.success('Task deleted')
- },
- onError: (error) => {
- logger.error('Failed to delete schedule', { error })
- toast.error("Couldn't delete task", { description: getErrorMessage(error) })
- },
- onSettled: async (data) => {
- if (!data) return
- await Promise.all([
- queryClient.invalidateQueries({ queryKey: scheduleKeys.list(data.workspaceId) }),
- queryClient.invalidateQueries({ queryKey: scheduleKeys.details() }),
- ])
- },
- })
-}
-
-/**
- * Mutation to delete a single occurrence of a recurring task (gcal "this
- * event"). The whole series is deleted via {@link useDeleteSchedule} instead.
- */
-export function useExcludeOccurrence() {
- const queryClient = useQueryClient()
-
- return useMutation({
- mutationFn: async ({
- scheduleId,
- occurrence,
- workspaceId,
- }: {
- scheduleId: string
- occurrence: string
- workspaceId: string
- }) => {
- await requestJson(excludeOccurrenceContract, {
- params: { id: scheduleId },
- body: { action: 'exclude_occurrence', occurrence },
- })
-
- return { workspaceId }
- },
- onSuccess: () => {
- toast.success('Occurrence removed')
- },
- onError: (error) => {
- logger.error('Failed to delete occurrence', { error })
- toast.error("Couldn't remove occurrence", { description: getErrorMessage(error) })
- },
- onSettled: async (data) => {
- if (!data) return
- await Promise.all([
- queryClient.invalidateQueries({ queryKey: scheduleKeys.list(data.workspaceId) }),
- queryClient.invalidateQueries({ queryKey: scheduleKeys.details() }),
- ])
- },
- })
-}
-
-/**
- * Mutation to update fields on a standalone job schedule
- */
-export function useUpdateSchedule() {
- const queryClient = useQueryClient()
-
- return useMutation({
- mutationFn: async ({
- scheduleId,
- workspaceId,
- ...updates
- }: {
- scheduleId: string
- workspaceId: string
- } & Omit) => {
- await requestJson(updateScheduleContract, {
- params: { id: scheduleId },
- body: { action: 'update', ...updates },
- })
-
- return { workspaceId }
- },
- onSuccess: () => {
- toast.success('Task updated')
- },
- onError: (error) => {
- logger.error('Failed to update schedule', { error })
- toast.error("Couldn't update task", { description: getErrorMessage(error) })
- },
- onSettled: async (data) => {
- if (!data) return
- await Promise.all([
- queryClient.invalidateQueries({ queryKey: scheduleKeys.list(data.workspaceId) }),
- queryClient.invalidateQueries({ queryKey: scheduleKeys.details() }),
- ])
- },
- })
-}
-
-/**
- * Mutation to create a standalone scheduled job
- */
-export function useCreateSchedule() {
- const queryClient = useQueryClient()
-
- return useMutation({
- mutationFn: async (body: CreateScheduleBody) => requestJson(createScheduleContract, { body }),
- onSuccess: () => {
- toast.success('Task scheduled')
- },
- onError: (error) => {
- logger.error('Failed to create schedule', { error })
- toast.error("Couldn't schedule task", { description: getErrorMessage(error) })
- },
- onSettled: (_data, _error, variables) =>
- queryClient.invalidateQueries({ queryKey: scheduleKeys.list(variables.workspaceId) }),
- })
-}
-
/**
* Mutation to redeploy a workflow (which recreates the schedule)
*/
diff --git a/apps/sim/hooks/queries/workflow-references.ts b/apps/sim/hooks/queries/workflow-references.ts
deleted file mode 100644
index 6ae68da75b4..00000000000
--- a/apps/sim/hooks/queries/workflow-references.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import { useQuery } from '@tanstack/react-query'
-import { requestJson } from '@/lib/api/client/request'
-import {
- getWorkflowReferencesContract,
- type WorkflowReferencesResponse,
-} from '@/lib/api/contracts/workflow-references'
-
-/**
- * Zero — the graph reflects live editor state, and no workflow-edit mutation
- * invalidates this key (edits arrive over the socket, not through React Query).
- * The modal mounts on demand, so every open refetches; a reopen paints the
- * cached tree instantly while the background refetch reconciles it.
- */
-export const WORKFLOW_REFERENCES_STALE_TIME = 0
-
-export const workflowReferenceKeys = {
- all: ['workflow-references'] as const,
- details: () => [...workflowReferenceKeys.all, 'detail'] as const,
- detail: (workflowId?: string) => [...workflowReferenceKeys.details(), workflowId ?? ''] as const,
-}
-
-async function fetchWorkflowReferences(
- workflowId: string,
- signal?: AbortSignal
-): Promise {
- return requestJson(getWorkflowReferencesContract, {
- params: { id: workflowId },
- signal,
- })
-}
-
-export function useWorkflowReferences(workflowId?: string) {
- return useQuery({
- queryKey: workflowReferenceKeys.detail(workflowId),
- queryFn: ({ signal }) => fetchWorkflowReferences(workflowId as string, signal),
- enabled: Boolean(workflowId),
- staleTime: WORKFLOW_REFERENCES_STALE_TIME,
- })
-}
diff --git a/apps/sim/hooks/use-workspace-invite-policy.ts b/apps/sim/hooks/use-workspace-invite-policy.ts
new file mode 100644
index 00000000000..b3ee90723b2
--- /dev/null
+++ b/apps/sim/hooks/use-workspace-invite-policy.ts
@@ -0,0 +1,48 @@
+'use client'
+
+import { useMemo } from 'react'
+import { useWorkspacesQuery } from '@/hooks/queries/workspace'
+import { usePermissionConfig } from '@/hooks/use-permission-config'
+
+export interface UseWorkspaceInvitePolicyReturn {
+ /**
+ * Why inviting is unavailable, already phrased for whoever is asking — the
+ * billed user is told to upgrade, everyone else to contact the owner. `null`
+ * when inviting is available, and also when it is blocked by a switch that has
+ * no viewer-facing explanation.
+ */
+ inviteDisabledReason: string | null
+ /** Whether inviting is blocked at all, by any of the sources below. */
+ isInvitationsDisabled: boolean
+}
+
+/**
+ * The workspace's invite gate, as every entry point offering an invite or a
+ * teammate-management action must read it.
+ *
+ * Two independent sources say no, and both have to be consulted: the deployment
+ * or permission-group switch (`NEXT_PUBLIC_DISABLE_INVITATIONS`, a permission
+ * group's `disableInvitations`), which is silent about why, and the workspace's
+ * own plan policy, which supplies {@link UseWorkspaceInvitePolicyReturn.inviteDisabledReason}.
+ * Reading only one of them is what lets an entry point offer an invite the server
+ * then refuses.
+ *
+ * For callers that are already handed the workspace — the workspace switcher gets
+ * the whole list as a prop — deriving the same two fields inline is correct; this
+ * hook is for the ones that would otherwise fetch the list just to ask.
+ */
+export function useWorkspaceInvitePolicy(workspaceId: string): UseWorkspaceInvitePolicyReturn {
+ const { isInvitationsDisabled: isInvitationsDisabledByConfig } = usePermissionConfig()
+ const { data: workspaces } = useWorkspacesQuery()
+
+ const inviteDisabledReason =
+ workspaces?.find((workspace) => workspace.id === workspaceId)?.inviteDisabledReason ?? null
+
+ return useMemo(
+ () => ({
+ inviteDisabledReason,
+ isInvitationsDisabled: isInvitationsDisabledByConfig || inviteDisabledReason !== null,
+ }),
+ [inviteDisabledReason, isInvitationsDisabledByConfig]
+ )
+}
diff --git a/apps/sim/lib/api/contracts/schedules.ts b/apps/sim/lib/api/contracts/schedules.ts
index 3a707e59232..6fd4921f673 100644
--- a/apps/sim/lib/api/contracts/schedules.ts
+++ b/apps/sim/lib/api/contracts/schedules.ts
@@ -102,38 +102,6 @@ export const workspaceScheduleRowSchema = workflowScheduleRowSchema.extend({
export type WorkspaceScheduleRow = z.output
-export const createScheduleBodySchema = z
- .object({
- workspaceId: z.string().min(1, 'Workspace ID is required'),
- title: z.string().min(1, 'Title is required'),
- prompt: z.string().min(1, 'Prompt is required'),
- /** Recurring cadence. Omit (with `time` set) for a one-time task. */
- cronExpression: z.string().min(1).optional(),
- /** One-time launch instant (ISO 8601). Omit (with `cronExpression` set) for a recurring task. */
- time: z.string().min(1).optional(),
- timezone: z.string().optional().default('UTC'),
- lifecycle: scheduleLifecycleSchema.optional().default('persistent'),
- /** Recurrence end after N runs (gcal "ends after N occurrences"). */
- maxRuns: z.number().int().positive().optional(),
- /** Recurrence end on a date (ISO 8601; gcal "ends on date"). */
- endsAt: z.string().optional(),
- startDate: z.string().optional(),
- contexts: z.array(scheduleContextSchema).optional(),
- secretScope: secretMountScopeSchema.optional(),
- mountedSecrets: mountedSecretNamesSchema.optional(),
- })
- .superRefine((body, ctx) => {
- if (!body.cronExpression && !body.time) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- path: ['time'],
- message: 'Provide a cron expression for a recurring task or a time for a one-time task',
- })
- }
- })
-
-export type CreateScheduleBody = z.input
-
export const reactivateScheduleBodySchema = z.object({
action: z.literal('reactivate'),
})
@@ -146,41 +114,9 @@ export const disableScheduleBodySchema = z.object({
export type DisableScheduleBody = z.input
-export const updateScheduleBodySchema = z.object({
- action: z.literal('update'),
- title: z.string().min(1).optional(),
- prompt: z.string().min(1).optional(),
- cronExpression: z.string().nullable().optional(),
- /** One-time launch instant (ISO 8601). Switches a task to one-time when set alongside a null `cronExpression`. */
- time: z.string().min(1).optional(),
- timezone: z.string().optional(),
- lifecycle: scheduleLifecycleSchema.optional(),
- maxRuns: z.number().int().positive().nullable().optional(),
- endsAt: z.string().nullable().optional(),
- contexts: z.array(scheduleContextSchema).optional(),
- secretScope: secretMountScopeSchema.optional(),
- mountedSecrets: mountedSecretNamesSchema.optional(),
-})
-
-export type UpdateScheduleBody = z.input
-
-/**
- * Deletes a single occurrence of a recurring task (gcal "this event"): the
- * occurrence's instant is added to the schedule's exclusion list and the next
- * run advances past it. Deleting the whole series uses {@link deleteScheduleContract}.
- */
-export const excludeOccurrenceBodySchema = z.object({
- action: z.literal('exclude_occurrence'),
- occurrence: z.string().min(1, 'Occurrence timestamp is required'),
-})
-
-export type ExcludeOccurrenceBody = z.input
-
export const scheduleUpdateSchema = z.discriminatedUnion('action', [
reactivateScheduleBodySchema,
disableScheduleBodySchema,
- updateScheduleBodySchema,
- excludeOccurrenceBodySchema,
])
export type ScheduleUpdate = z.input
@@ -227,9 +163,8 @@ export const listWorkspaceSchedulesContract = defineRouteContract({
})
/**
- * Single-schedule read by id. Used by the mothership resource viewer so opening
- * a scheduled-task artifact does a lightweight by-id fetch instead of pulling
- * the entire workspace schedule list (which contended with the chat stream).
+ * Single-schedule read by id: a lightweight fetch for one workflow schedule
+ * instead of pulling the whole workspace list.
*/
export const getScheduleByIdContract = defineRouteContract({
method: 'GET',
@@ -244,32 +179,9 @@ export const getScheduleByIdContract = defineRouteContract({
})
/**
- * Newly-created job schedules emit a partial summary with the canonical fields
- * the route synthesizes server-side; everything else is filled in on
- * subsequent reads.
+ * Re-arms a disabled schedule: the route recomputes `nextRunAt` from the stored
+ * cron expression and clears the failure counters.
*/
-export const createScheduleResponseSchema = z.object({
- schedule: z.object({
- id: z.string(),
- status: scheduleStatusSchema,
- /** Null for one-time tasks, which carry no recurring cadence. */
- cronExpression: z.string().nullable(),
- nextRunAt: z.string(),
- }),
-})
-
-export type CreateScheduleResponse = z.output
-
-export const createScheduleContract = defineRouteContract({
- method: 'POST',
- path: '/api/schedules',
- body: createScheduleBodySchema,
- response: {
- mode: 'json',
- schema: createScheduleResponseSchema,
- },
-})
-
export const reactivateScheduleContract = defineRouteContract({
method: 'PUT',
path: '/api/schedules/[id]',
@@ -281,17 +193,6 @@ export const reactivateScheduleContract = defineRouteContract({
},
})
-export const disableScheduleContract = defineRouteContract({
- method: 'PUT',
- path: '/api/schedules/[id]',
- params: scheduleIdParamsSchema,
- body: disableScheduleBodySchema,
- response: {
- mode: 'json',
- schema: messageResponseSchema,
- },
-})
-
export const updateScheduleContract = defineRouteContract({
method: 'PUT',
path: '/api/schedules/[id]',
@@ -303,27 +204,6 @@ export const updateScheduleContract = defineRouteContract({
},
})
-export const excludeOccurrenceContract = defineRouteContract({
- method: 'PUT',
- path: '/api/schedules/[id]',
- params: scheduleIdParamsSchema,
- body: excludeOccurrenceBodySchema,
- response: {
- mode: 'json',
- schema: messageResponseSchema,
- },
-})
-
-export const deleteScheduleContract = defineRouteContract({
- method: 'DELETE',
- path: '/api/schedules/[id]',
- params: scheduleIdParamsSchema,
- response: {
- mode: 'json',
- schema: messageResponseSchema,
- },
-})
-
export const executeSchedulesContract = defineRouteContract({
method: 'GET',
path: '/api/schedules/execute',
diff --git a/apps/sim/lib/api/contracts/workflow-references.ts b/apps/sim/lib/api/contracts/workflow-references.ts
deleted file mode 100644
index c298c4f6bec..00000000000
--- a/apps/sim/lib/api/contracts/workflow-references.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import { z } from 'zod'
-import { nonEmptyIdSchema } from '@/lib/api/contracts/primitives'
-import { defineRouteContract } from '@/lib/api/contracts/types'
-
-/**
- * One node in a workflow reference tree. Recursive, so the Zod schema below needs
- * `z.lazy` plus this explicit interface annotation — TypeScript cannot infer a
- * self-referential type. Shared by the server graph builder (its return element
- * type) and the client hook, keeping both off `z.output<...>`.
- */
-export interface ReferenceNode {
- /** Referenced workflow id. */
- id: string
- /** Referenced workflow name. */
- name: string
- /**
- * True when this node closes a cycle already on the current path (e.g. the
- * root, or `A → B → A`). Cyclic nodes carry no `children`.
- */
- cycle: boolean
- children: ReferenceNode[]
-}
-
-export const referenceNodeSchema: z.ZodType = z.lazy(() =>
- z.object({
- id: z.string(),
- name: z.string(),
- cycle: z.boolean(),
- children: z.array(referenceNodeSchema),
- })
-)
-
-export const workflowReferencesParamsSchema = z.object({
- id: nonEmptyIdSchema,
-})
-
-export const workflowReferencesResponseSchema = z.object({
- /** Workflows that call this workflow (inbound), each recursively expanded. */
- callers: z.array(referenceNodeSchema),
- /** Workflows this workflow calls (outbound), each recursively expanded. */
- callees: z.array(referenceNodeSchema),
-})
-
-export type WorkflowReferencesResponse = z.output
-
-export const getWorkflowReferencesContract = defineRouteContract({
- method: 'GET',
- path: '/api/workflows/[id]/references',
- params: workflowReferencesParamsSchema,
- response: {
- mode: 'json',
- schema: workflowReferencesResponseSchema,
- },
-})
diff --git a/apps/sim/lib/billing/workspace-permissions.ts b/apps/sim/lib/billing/workspace-permissions.ts
index f9b7ff9daa8..41c57c574eb 100644
--- a/apps/sim/lib/billing/workspace-permissions.ts
+++ b/apps/sim/lib/billing/workspace-permissions.ts
@@ -1,4 +1,5 @@
import type { WorkspaceHostContext, WorkspaceUsageGate } from '@/lib/api/contracts/workspaces'
+import { isBillingEnabled } from '@/lib/core/config/env-flags'
export type WorkspaceUsageLimitAction =
| { type: 'manage-billing'; message: null }
@@ -18,6 +19,22 @@ export function canManageWorkspaceBilling(
return hostContext.workspace.billedAccountUserId === viewerUserId
}
+/**
+ * Returns whether the Billing settings section is reachable for this viewer.
+ *
+ * Mirrors the section route's own gate, which sends a deployment running without
+ * billing back to General and 404s anyone who cannot manage the payer — on an
+ * organization-hosted workspace that is every member who is not an org admin.
+ * Menus that link to Billing drop the entry when this is false rather than
+ * offering a destination the server will refuse.
+ */
+export function canViewWorkspaceBillingSettings(
+ hostContext: WorkspaceHostContext,
+ viewerUserId?: string | null
+): boolean {
+ return isBillingEnabled && canManageWorkspaceBilling(hostContext, viewerUserId)
+}
+
/**
* Resolves the workspace-safe action and copy for an exceeded usage gate.
* Payer messages are intentionally replaced for viewers who cannot manage the
diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts
index 161926cb8c3..b581bad7c63 100644
--- a/apps/sim/lib/copilot/chat/post.ts
+++ b/apps/sim/lib/copilot/chat/post.ts
@@ -94,7 +94,6 @@ const ResourceAttachmentSchema = z.object({
'filefolder',
'task',
'log',
- 'scheduledtask',
'generic',
'browser',
// Filtered out client-side rather than sent, but accepted here so a stray
@@ -128,7 +127,6 @@ const GENERIC_RESOURCE_TITLE: Record['t
filefolder: 'File Folder',
task: 'Task',
log: 'Log',
- scheduledtask: 'Scheduled Task',
generic: 'Resource',
browser: 'Browser',
terminal: 'Terminal',
@@ -197,7 +195,6 @@ const ChatContextSchema = z
'file_selection',
'folder',
'filefolder',
- 'scheduledtask',
'integration',
'skill',
'mcp',
diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts
index abd87f1cd8f..b80c7060705 100644
--- a/apps/sim/lib/copilot/chat/process-contents.ts
+++ b/apps/sim/lib/copilot/chat/process-contents.ts
@@ -1,18 +1,17 @@
import { db, dbReplica } from '@sim/db'
-import { knowledgeBase, workflowSchedule } from '@sim/db/schema'
+import { knowledgeBase } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import {
authorizeWorkflowByWorkspacePermission,
getActiveWorkflowRecord,
} from '@sim/platform-authz/workflow'
-import { and, eq, isNull, ne } from 'drizzle-orm'
+import { and, eq, isNull } from 'drizzle-orm'
import {
MAX_TABLE_SELECTION_CONTENT_LENGTH,
safeBrowserSelectionUrl,
truncateSelectionText,
} from '@/lib/copilot/chat/selection-context'
import { QueryLogs } from '@/lib/copilot/generated/tool-catalog-v1'
-import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment'
import {
buildVfsFolderPathMap,
canonicalBlockVfsPath,
@@ -283,16 +282,6 @@ export async function processContextsServer(
path: result.path,
}
}
- if (ctx.kind === 'scheduledtask' && ctx.scheduleId && currentWorkspaceId) {
- const result = await resolveScheduledTaskResource(ctx.scheduleId, currentWorkspaceId)
- if (!result) return null
- return {
- type: 'active_resource',
- tag: ctx.label ? `@${ctx.label}` : '@',
- content: result.content,
- path: result.path,
- }
- }
if (ctx.kind === 'docs') {
try {
const { searchDocumentationServerTool } = await import(
@@ -848,9 +837,6 @@ export async function resolveActiveResourceContext(
case 'filefolder': {
return await resolveFileFolderResource(resourceId, workspaceId)
}
- case 'scheduledtask': {
- return await resolveScheduledTaskResource(resourceId, workspaceId)
- }
default:
return null
}
@@ -874,38 +860,6 @@ async function resolveTableResource(
}
}
-async function resolveScheduledTaskResource(
- scheduleId: string,
- workspaceId: string
-): Promise {
- const [row] = await db
- .select({ id: workflowSchedule.id, jobTitle: workflowSchedule.jobTitle })
- .from(workflowSchedule)
- .where(
- and(
- eq(workflowSchedule.id, scheduleId),
- eq(workflowSchedule.sourceWorkspaceId, workspaceId),
- eq(workflowSchedule.sourceType, 'job'),
- isNull(workflowSchedule.archivedAt),
- // Mirror the VFS materializer (workspace-vfs `materializeJobs`), which
- // excludes completed jobs — otherwise we'd point at a meta.json it never
- // wrote and the agent's read would dangle.
- ne(workflowSchedule.status, 'completed')
- )
- )
- .limit(1)
- if (!row) return null
- // The VFS materializes jobs at `jobs/{sanitized title}/meta.json` (see
- // workspace-vfs `materializeJobs`); emit the same lightweight path pointer so
- // the agent reads it via the VFS instead of us inlining the (heavy) row.
- return {
- type: 'active_resource',
- tag: '@active_resource',
- content: '',
- path: `jobs/${normalizeVfsSegment(row.jobTitle || row.id)}/meta.json`,
- }
-}
-
async function resolveFileResource(
fileId: string,
workspaceId: string
diff --git a/apps/sim/lib/copilot/chat/workspace-context.ts b/apps/sim/lib/copilot/chat/workspace-context.ts
index faf2b3a2170..4faca5f9d58 100644
--- a/apps/sim/lib/copilot/chat/workspace-context.ts
+++ b/apps/sim/lib/copilot/chat/workspace-context.ts
@@ -6,17 +6,11 @@ import {
mcpServers,
userTableDefinitions,
workflow,
- workflowSchedule,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
-import { truncate } from '@sim/utils/string'
import { and, eq, inArray, isNull } from 'drizzle-orm'
-import type {
- VfsSnapshotV1,
- VfsSnapshotV1Job,
- VfsSnapshotV1Workflow,
-} from '@/lib/copilot/generated/vfs-snapshot-v1'
+import type { VfsSnapshotV1, VfsSnapshotV1Workflow } from '@/lib/copilot/generated/vfs-snapshot-v1'
import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment'
import { canonicalWorkflowVfsDir, canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils'
import {
@@ -82,15 +76,6 @@ export interface WorkspaceMdData {
customBlocks?: Array<{ type: string; name: string; description?: string }>
mcpServers?: Array<{ id: string; name: string; url?: string | null; enabled: boolean }>
skills?: Array<{ id: string; name: string; description: string }>
- jobs?: Array<{
- id: string
- title: string | null
- prompt: string
- cronExpression: string | null
- status: string
- lifecycle: string
- sourceTaskName: string | null
- }>
}
/**
@@ -297,22 +282,6 @@ export function buildWorkspaceMd(data: WorkspaceMdData): string {
)
}
- if (data.jobs && data.jobs.length > 0) {
- const lines = [...data.jobs]
- .sort((a, b) => stableCompare(a.title || a.id, b.title || b.id) || stableCompare(a.id, b.id))
- .map((j) => {
- const displayName = j.title || j.id
- let line = `- **${displayName}** (${j.id}) — ${j.status}`
- if (j.lifecycle !== 'persistent') line += ` [${j.lifecycle}]`
- if (j.cronExpression) line += `, cron: ${j.cronExpression}`
- if (j.sourceTaskName) line += `, task: ${j.sourceTaskName}`
- const promptPreview = j.prompt.length > 80 ? truncate(j.prompt, 77) : j.prompt
- line += `\n ${promptPreview}`
- return line
- })
- sections.push(`## Jobs (${data.jobs.length})\n${lines.join('\n')}`)
- }
-
return sections.join('\n\n')
}
@@ -360,7 +329,6 @@ async function buildWorkspaceMdData(
customTools,
mcpServerRows,
skillRows,
- jobRows,
customBlockSummaries,
] = await Promise.all([
getUsersWithPermissions(workspaceId),
@@ -434,25 +402,6 @@ async function buildWorkspaceMdData(
listSkillsForUser({ workspaceId, userId, includeBuiltins: false, workspaceAccess }),
- db
- .select({
- id: workflowSchedule.id,
- jobTitle: workflowSchedule.jobTitle,
- prompt: workflowSchedule.prompt,
- cronExpression: workflowSchedule.cronExpression,
- status: workflowSchedule.status,
- lifecycle: workflowSchedule.lifecycle,
- sourceTaskName: workflowSchedule.sourceTaskName,
- })
- .from(workflowSchedule)
- .where(
- and(
- eq(workflowSchedule.sourceWorkspaceId, workspaceId),
- eq(workflowSchedule.sourceType, 'job'),
- isNull(workflowSchedule.archivedAt)
- )
- ),
-
listCustomBlockSummariesForWorkspace(workspaceId),
])
@@ -536,17 +485,6 @@ async function buildWorkspaceMdData(
customBlocks: customBlockSummaries,
mcpServers: mcpServerRows,
skills: skillRows.map((s) => ({ id: s.id, name: s.name, description: s.description })),
- jobs: jobRows
- .filter((j) => j.status !== 'completed')
- .map((j) => ({
- id: j.id,
- title: j.jobTitle,
- prompt: j.prompt || '',
- cronExpression: j.cronExpression,
- status: j.status,
- lifecycle: j.lifecycle,
- sourceTaskName: j.sourceTaskName,
- })),
}
} catch (err) {
logger.error('Failed to build workspace data', {
@@ -601,19 +539,6 @@ export function buildVfsSnapshot(data: WorkspaceMdData): VfsSnapshotV1 {
...(wf.isDeployed ? { isDeployed: true } : {}),
...(wf.folderPath ? { folderPath: wf.folderPath } : {}),
}))
- const jobs: VfsSnapshotV1Job[] = (data.jobs ?? [])
- .filter((j) => j.status !== 'completed')
- .map((j) => ({
- id: j.id,
- ...(j.title ? { title: j.title } : {}),
- // Match WORKSPACE.md's preview truncation — full prompts are large,
- // volatile-ish, and readable on demand at jobs/{title}/meta.json.
- ...(j.prompt ? { prompt: j.prompt.length > 80 ? truncate(j.prompt, 77) : j.prompt } : {}),
- ...(j.cronExpression ? { cronExpression: j.cronExpression } : {}),
- ...(j.status ? { status: j.status } : {}),
- ...(j.lifecycle ? { lifecycle: j.lifecycle } : {}),
- ...(j.sourceTaskName ? { sourceTaskName: j.sourceTaskName } : {}),
- }))
return {
...(data.workspace
? {
@@ -675,7 +600,6 @@ export function buildVfsSnapshot(data: WorkspaceMdData): VfsSnapshotV1 {
name: s.name,
...(s.description ? { description: s.description } : {}),
})),
- jobs,
}
}
diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts
index 8200e4f02d1..a61f6a65527 100644
--- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts
+++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts
@@ -33,7 +33,6 @@ export interface ToolCatalogEntry {
| 'browser_wait_for'
| 'call_integration_tool'
| 'check_deployment_status'
- | 'complete_scheduled_task'
| 'cp'
| 'crawl_website'
| 'create_file'
@@ -63,7 +62,6 @@ export interface ToolCatalogEntry {
| 'get_deployment_log'
| 'get_page_contents'
| 'get_platform_actions'
- | 'get_scheduled_task_logs'
| 'get_workflow_data'
| 'get_workflow_run_options'
| 'glob'
@@ -79,7 +77,6 @@ export interface ToolCatalogEntry {
| 'manage_credential'
| 'manage_custom_tool'
| 'manage_mcp_tool'
- | 'manage_scheduled_task'
| 'manage_skill'
| 'materialize_file'
| 'media'
@@ -102,7 +99,6 @@ export interface ToolCatalogEntry {
| 'run_from_block'
| 'run_workflow'
| 'run_workflow_until_block'
- | 'scheduled_task'
| 'scrape_page'
| 'search'
| 'search_documentation'
@@ -118,7 +114,6 @@ export interface ToolCatalogEntry {
| 'table'
| 'terminal'
| 'update_deployment_version'
- | 'update_scheduled_task_history'
| 'update_workspace_mcp_server'
| 'user_table'
| 'wait'
@@ -153,7 +148,6 @@ export interface ToolCatalogEntry {
| 'browser_wait_for'
| 'call_integration_tool'
| 'check_deployment_status'
- | 'complete_scheduled_task'
| 'cp'
| 'crawl_website'
| 'create_file'
@@ -183,7 +177,6 @@ export interface ToolCatalogEntry {
| 'get_deployment_log'
| 'get_page_contents'
| 'get_platform_actions'
- | 'get_scheduled_task_logs'
| 'get_workflow_data'
| 'get_workflow_run_options'
| 'glob'
@@ -199,7 +192,6 @@ export interface ToolCatalogEntry {
| 'manage_credential'
| 'manage_custom_tool'
| 'manage_mcp_tool'
- | 'manage_scheduled_task'
| 'manage_skill'
| 'materialize_file'
| 'media'
@@ -222,7 +214,6 @@ export interface ToolCatalogEntry {
| 'run_from_block'
| 'run_workflow'
| 'run_workflow_until_block'
- | 'scheduled_task'
| 'scrape_page'
| 'search'
| 'search_documentation'
@@ -238,7 +229,6 @@ export interface ToolCatalogEntry {
| 'table'
| 'terminal'
| 'update_deployment_version'
- | 'update_scheduled_task_history'
| 'update_workspace_mcp_server'
| 'user_table'
| 'wait'
@@ -258,7 +248,6 @@ export interface ToolCatalogEntry {
| 'knowledge'
| 'media'
| 'run'
- | 'scheduled_task'
| 'search'
| 'table'
| 'workflow'
@@ -1247,20 +1236,6 @@ export const CheckDeploymentStatus: ToolCatalogEntry = {
},
}
-export const CompleteScheduledTask: ToolCatalogEntry = {
- id: 'complete_scheduled_task',
- name: 'complete_scheduled_task',
- route: 'sim',
- mode: 'async',
- parameters: {
- type: 'object',
- properties: {
- jobId: { type: 'string', description: 'The ID of the scheduled task to mark as completed.' },
- },
- required: ['jobId'],
- },
-}
-
export const Cp: ToolCatalogEntry = {
id: 'cp',
name: 'cp',
@@ -2314,7 +2289,7 @@ export const FunctionExecute: ToolCatalogEntry = {
code: {
type: 'string',
description:
- 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Request each needed secret with an explicit {{VAR_NAME}} reference.',
+ 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Use each needed secret as {{VAR_NAME}}; the reference resolves to the value exactly as stored.',
},
inputs: {
type: 'object',
@@ -3030,26 +3005,6 @@ export const GetPlatformActions: ToolCatalogEntry = {
parameters: { type: 'object', properties: {} },
}
-export const GetScheduledTaskLogs: ToolCatalogEntry = {
- id: 'get_scheduled_task_logs',
- name: 'get_scheduled_task_logs',
- route: 'sim',
- mode: 'async',
- parameters: {
- type: 'object',
- properties: {
- executionId: { type: 'string', description: 'Optional execution ID for a specific run.' },
- includeDetails: {
- type: 'boolean',
- description: 'Include tool calls, outputs, and cost details.',
- },
- jobId: { type: 'string', description: 'The scheduled task (schedule) ID to get logs for.' },
- limit: { type: 'number', description: 'Max number of entries (default: 3, max: 5)' },
- },
- required: ['jobId'],
- },
-}
-
export const GetWorkflowData: ToolCatalogEntry = {
id: 'get_workflow_data',
name: 'get_workflow_data',
@@ -3501,7 +3456,7 @@ export const ManageCustomTool: ToolCatalogEntry = {
operation: {
type: 'string',
description:
- "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — manage_scheduled_task uses create/update instead of add/edit.",
+ "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — other manage_* tools may use create/update instead of add/edit.",
enum: ['add', 'edit', 'delete', 'list'],
},
schema: {
@@ -3591,7 +3546,7 @@ export const ManageMcpTool: ToolCatalogEntry = {
operation: {
type: 'string',
description:
- "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — manage_scheduled_task uses create/update instead of add/edit.",
+ "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — other manage_* tools may use create/update instead of add/edit.",
enum: ['add', 'edit', 'delete', 'list'],
},
serverId: {
@@ -3605,80 +3560,6 @@ export const ManageMcpTool: ToolCatalogEntry = {
requiredPermission: 'write',
}
-export const ManageScheduledTask: ToolCatalogEntry = {
- id: 'manage_scheduled_task',
- name: 'manage_scheduled_task',
- route: 'sim',
- mode: 'async',
- parameters: {
- type: 'object',
- properties: {
- args: {
- type: 'object',
- description:
- 'Operation-specific arguments. For create: {title, prompt, cron?, time?, timezone?, lifecycle?, successCondition?, maxRuns?}. For get/delete: {jobId}. For update: {jobId, title?, prompt?, cron?, timezone?, status?, lifecycle?, successCondition?, maxRuns?}. For list: no args needed.',
- properties: {
- cron: {
- type: 'string',
- description:
- "Cron expression for a recurring scheduled task (e.g. '0 9 * * *'). Provide cron, time, or both — with both, time anchors the recurring task's first fire.",
- },
- jobId: { type: 'string', description: 'Scheduled task ID (required for get, update)' },
- jobIds: {
- type: 'array',
- description: 'Array of scheduled task IDs (for batch delete)',
- items: { type: 'string' },
- },
- lifecycle: {
- type: 'string',
- description:
- "'persistent' (default) or 'until_complete'. Until_complete scheduled tasks stop when complete_scheduled_task is called.",
- enum: ['persistent', 'until_complete'],
- },
- maxRuns: {
- type: 'integer',
- description: 'Max executions before auto-completing. Safety limit.',
- },
- prompt: {
- type: 'string',
- description: 'The prompt to execute when the scheduled task fires',
- },
- status: {
- type: 'string',
- description: 'Scheduled task status: active, paused',
- enum: ['active', 'paused'],
- },
- successCondition: {
- type: 'string',
- description:
- 'What must happen for the scheduled task to be considered complete (until_complete lifecycle).',
- },
- time: {
- type: 'string',
- description:
- "ISO 8601 datetime. One-time scheduled task -> set time and omit cron. May also anchor a recurring cron task's first-fire time.",
- },
- timezone: {
- type: 'string',
- description: 'IANA timezone (e.g. America/New_York). Defaults to UTC.',
- },
- title: {
- type: 'string',
- description: "Short descriptive title for the scheduled task (e.g. 'Email Poller')",
- },
- },
- },
- operation: {
- type: 'string',
- description:
- 'The operation to perform: create, list, get, update, delete. These verbs are tool-specific — the custom-tool/MCP/skill managers use add/edit instead of create/update.',
- enum: ['create', 'list', 'get', 'update', 'delete'],
- },
- },
- required: ['operation'],
- },
-}
-
export const ManageSkill: ToolCatalogEntry = {
id: 'manage_skill',
name: 'manage_skill',
@@ -3703,7 +3584,7 @@ export const ManageSkill: ToolCatalogEntry = {
operation: {
type: 'string',
description:
- "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — manage_scheduled_task uses create/update instead of add/edit.",
+ "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — other manage_* tools may use create/update instead of add/edit.",
enum: ['add', 'edit', 'delete', 'list'],
},
skillId: {
@@ -3883,7 +3764,7 @@ export const OpenResource: ToolCatalogEntry = {
type: {
type: 'string',
description: 'The resource type.',
- enum: ['workflow', 'table', 'knowledgebase', 'file', 'log', 'scheduledtask'],
+ enum: ['workflow', 'table', 'knowledgebase', 'file', 'log'],
},
},
required: ['type'],
@@ -4318,7 +4199,7 @@ export const RunCode: ToolCatalogEntry = {
code: {
type: 'string',
description:
- 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Request each needed secret with an explicit {{VAR_NAME}} reference.',
+ 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Use each needed secret as {{VAR_NAME}}; the reference resolves to the value exactly as stored.',
},
inputs: {
type: 'object',
@@ -4528,22 +4409,6 @@ export const RunWorkflowUntilBlock: ToolCatalogEntry = {
requiresApproval: true,
}
-export const ScheduledTask: ToolCatalogEntry = {
- id: 'scheduled_task',
- name: 'scheduled_task',
- route: 'subagent',
- mode: 'async',
- parameters: {
- properties: {
- request: { description: 'What scheduled task action is needed.', type: 'string' },
- },
- required: ['request'],
- type: 'object',
- },
- subagentId: 'scheduled_task',
- internal: true,
-}
-
export const ScrapePage: ToolCatalogEntry = {
id: 'scrape_page',
name: 'scrape_page',
@@ -5088,25 +4953,6 @@ export const UpdateDeploymentVersion: ToolCatalogEntry = {
requiredPermission: 'write',
}
-export const UpdateScheduledTaskHistory: ToolCatalogEntry = {
- id: 'update_scheduled_task_history',
- name: 'update_scheduled_task_history',
- route: 'sim',
- mode: 'async',
- parameters: {
- type: 'object',
- properties: {
- jobId: { type: 'string', description: 'The scheduled task ID.' },
- summary: {
- type: 'string',
- description:
- "A concise summary of what was done this run (e.g., 'Sent follow-up emails to 3 leads: Alice, Bob, Carol').",
- },
- },
- required: ['jobId', 'summary'],
- },
-}
-
export const UpdateWorkspaceMcpServer: ToolCatalogEntry = {
id: 'update_workspace_mcp_server',
name: 'update_workspace_mcp_server',
@@ -5764,25 +5610,6 @@ export const ManageMcpToolOperationValues = [
ManageMcpToolOperation.list,
] as const
-export const ManageScheduledTaskOperation = {
- create: 'create',
- list: 'list',
- get: 'get',
- update: 'update',
- delete: 'delete',
-} as const
-
-export type ManageScheduledTaskOperation =
- (typeof ManageScheduledTaskOperation)[keyof typeof ManageScheduledTaskOperation]
-
-export const ManageScheduledTaskOperationValues = [
- ManageScheduledTaskOperation.create,
- ManageScheduledTaskOperation.list,
- ManageScheduledTaskOperation.get,
- ManageScheduledTaskOperation.update,
- ManageScheduledTaskOperation.delete,
-] as const
-
export const ManageSkillOperation = {
add: 'add',
edit: 'edit',
@@ -5986,7 +5813,6 @@ export const TOOL_CATALOG: Record = {
[BrowserWaitFor.id]: BrowserWaitFor,
[CallIntegrationTool.id]: CallIntegrationTool,
[CheckDeploymentStatus.id]: CheckDeploymentStatus,
- [CompleteScheduledTask.id]: CompleteScheduledTask,
[Cp.id]: Cp,
[CrawlWebsite.id]: CrawlWebsite,
[CreateFile.id]: CreateFile,
@@ -6016,7 +5842,6 @@ export const TOOL_CATALOG: Record = {
[GetDeploymentLog.id]: GetDeploymentLog,
[GetPageContents.id]: GetPageContents,
[GetPlatformActions.id]: GetPlatformActions,
- [GetScheduledTaskLogs.id]: GetScheduledTaskLogs,
[GetWorkflowData.id]: GetWorkflowData,
[GetWorkflowRunOptions.id]: GetWorkflowRunOptions,
[Glob.id]: Glob,
@@ -6032,7 +5857,6 @@ export const TOOL_CATALOG: Record = {
[ManageCredential.id]: ManageCredential,
[ManageCustomTool.id]: ManageCustomTool,
[ManageMcpTool.id]: ManageMcpTool,
- [ManageScheduledTask.id]: ManageScheduledTask,
[ManageSkill.id]: ManageSkill,
[MaterializeFile.id]: MaterializeFile,
[Media.id]: Media,
@@ -6055,7 +5879,6 @@ export const TOOL_CATALOG: Record = {
[RunFromBlock.id]: RunFromBlock,
[RunWorkflow.id]: RunWorkflow,
[RunWorkflowUntilBlock.id]: RunWorkflowUntilBlock,
- [ScheduledTask.id]: ScheduledTask,
[ScrapePage.id]: ScrapePage,
[Search.id]: Search,
[SearchDocumentation.id]: SearchDocumentation,
@@ -6071,7 +5894,6 @@ export const TOOL_CATALOG: Record = {
[Table.id]: Table,
[Terminal.id]: Terminal,
[UpdateDeploymentVersion.id]: UpdateDeploymentVersion,
- [UpdateScheduledTaskHistory.id]: UpdateScheduledTaskHistory,
[UpdateWorkspaceMcpServer.id]: UpdateWorkspaceMcpServer,
[UserTable.id]: UserTable,
[Wait.id]: Wait,
diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts
index 3f981bbaccc..d45a647d6e1 100644
--- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts
+++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts
@@ -1106,19 +1106,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- complete_scheduled_task: {
- parameters: {
- type: 'object',
- properties: {
- jobId: {
- type: 'string',
- description: 'The ID of the scheduled task to mark as completed.',
- },
- },
- required: ['jobId'],
- },
- resultSchema: undefined,
- },
cp: {
parameters: {
type: 'object',
@@ -2211,7 +2198,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
code: {
type: 'string',
description:
- 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Request each needed secret with an explicit {{VAR_NAME}} reference.',
+ 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Use each needed secret as {{VAR_NAME}}; the reference resolves to the value exactly as stored.',
},
inputs: {
type: 'object',
@@ -2921,31 +2908,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- get_scheduled_task_logs: {
- parameters: {
- type: 'object',
- properties: {
- executionId: {
- type: 'string',
- description: 'Optional execution ID for a specific run.',
- },
- includeDetails: {
- type: 'boolean',
- description: 'Include tool calls, outputs, and cost details.',
- },
- jobId: {
- type: 'string',
- description: 'The scheduled task (schedule) ID to get logs for.',
- },
- limit: {
- type: 'number',
- description: 'Max number of entries (default: 3, max: 5)',
- },
- },
- required: ['jobId'],
- },
- resultSchema: undefined,
- },
get_workflow_data: {
parameters: {
type: 'object',
@@ -3376,7 +3338,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
operation: {
type: 'string',
description:
- "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — manage_scheduled_task uses create/update instead of add/edit.",
+ "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — other manage_* tools may use create/update instead of add/edit.",
enum: ['add', 'edit', 'delete', 'list'],
},
schema: {
@@ -3483,7 +3445,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
operation: {
type: 'string',
description:
- "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — manage_scheduled_task uses create/update instead of add/edit.",
+ "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — other manage_* tools may use create/update instead of add/edit.",
enum: ['add', 'edit', 'delete', 'list'],
},
serverId: {
@@ -3496,81 +3458,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- manage_scheduled_task: {
- parameters: {
- type: 'object',
- properties: {
- args: {
- type: 'object',
- description:
- 'Operation-specific arguments. For create: {title, prompt, cron?, time?, timezone?, lifecycle?, successCondition?, maxRuns?}. For get/delete: {jobId}. For update: {jobId, title?, prompt?, cron?, timezone?, status?, lifecycle?, successCondition?, maxRuns?}. For list: no args needed.',
- properties: {
- cron: {
- type: 'string',
- description:
- "Cron expression for a recurring scheduled task (e.g. '0 9 * * *'). Provide cron, time, or both — with both, time anchors the recurring task's first fire.",
- },
- jobId: {
- type: 'string',
- description: 'Scheduled task ID (required for get, update)',
- },
- jobIds: {
- type: 'array',
- description: 'Array of scheduled task IDs (for batch delete)',
- items: {
- type: 'string',
- },
- },
- lifecycle: {
- type: 'string',
- description:
- "'persistent' (default) or 'until_complete'. Until_complete scheduled tasks stop when complete_scheduled_task is called.",
- enum: ['persistent', 'until_complete'],
- },
- maxRuns: {
- type: 'integer',
- description: 'Max executions before auto-completing. Safety limit.',
- },
- prompt: {
- type: 'string',
- description: 'The prompt to execute when the scheduled task fires',
- },
- status: {
- type: 'string',
- description: 'Scheduled task status: active, paused',
- enum: ['active', 'paused'],
- },
- successCondition: {
- type: 'string',
- description:
- 'What must happen for the scheduled task to be considered complete (until_complete lifecycle).',
- },
- time: {
- type: 'string',
- description:
- "ISO 8601 datetime. One-time scheduled task -> set time and omit cron. May also anchor a recurring cron task's first-fire time.",
- },
- timezone: {
- type: 'string',
- description: 'IANA timezone (e.g. America/New_York). Defaults to UTC.',
- },
- title: {
- type: 'string',
- description: "Short descriptive title for the scheduled task (e.g. 'Email Poller')",
- },
- },
- },
- operation: {
- type: 'string',
- description:
- 'The operation to perform: create, list, get, update, delete. These verbs are tool-specific — the custom-tool/MCP/skill managers use add/edit instead of create/update.',
- enum: ['create', 'list', 'get', 'update', 'delete'],
- },
- },
- required: ['operation'],
- },
- resultSchema: undefined,
- },
manage_skill: {
parameters: {
type: 'object',
@@ -3591,7 +3478,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
operation: {
type: 'string',
description:
- "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — manage_scheduled_task uses create/update instead of add/edit.",
+ "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — other manage_* tools may use create/update instead of add/edit.",
enum: ['add', 'edit', 'delete', 'list'],
},
skillId: {
@@ -3746,7 +3633,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
type: {
type: 'string',
description: 'The resource type.',
- enum: ['workflow', 'table', 'knowledgebase', 'file', 'log', 'scheduledtask'],
+ enum: ['workflow', 'table', 'knowledgebase', 'file', 'log'],
},
},
required: ['type'],
@@ -4185,7 +4072,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
code: {
type: 'string',
description:
- 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Request each needed secret with an explicit {{VAR_NAME}} reference.',
+ 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Use each needed secret as {{VAR_NAME}}; the reference resolves to the value exactly as stored.',
},
inputs: {
type: 'object',
@@ -4384,19 +4271,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- scheduled_task: {
- parameters: {
- properties: {
- request: {
- description: 'What scheduled task action is needed.',
- type: 'string',
- },
- },
- required: ['request'],
- type: 'object',
- },
- resultSchema: undefined,
- },
scrape_page: {
parameters: {
type: 'object',
@@ -4921,24 +4795,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
- update_scheduled_task_history: {
- parameters: {
- type: 'object',
- properties: {
- jobId: {
- type: 'string',
- description: 'The scheduled task ID.',
- },
- summary: {
- type: 'string',
- description:
- "A concise summary of what was done this run (e.g., 'Sent follow-up emails to 3 leads: Alice, Bob, Carol').",
- },
- },
- required: ['jobId', 'summary'],
- },
- resultSchema: undefined,
- },
update_workspace_mcp_server: {
parameters: {
type: 'object',
diff --git a/apps/sim/lib/copilot/generated/vfs-snapshot-v1.ts b/apps/sim/lib/copilot/generated/vfs-snapshot-v1.ts
index 37486a75078..62f614b2d22 100644
--- a/apps/sim/lib/copilot/generated/vfs-snapshot-v1.ts
+++ b/apps/sim/lib/copilot/generated/vfs-snapshot-v1.ts
@@ -10,7 +10,6 @@ export interface VfsSnapshotV1 {
envVars?: string[]
files?: VfsSnapshotV1File[]
integrations?: VfsSnapshotV1Integration[]
- jobs?: VfsSnapshotV1Job[]
knowledgeBases?: VfsSnapshotV1KnowledgeBase[]
mcpServers?: VfsSnapshotV1McpServer[]
members?: VfsSnapshotV1Member[]
@@ -58,19 +57,6 @@ export interface VfsSnapshotV1Integration {
providerId: string
role?: string
}
-/**
- * This interface was referenced by `VfsSnapshotV1`'s JSON-Schema
- * via the `definition` "VfsSnapshotV1Job".
- */
-export interface VfsSnapshotV1Job {
- cronExpression?: string
- id: string
- lifecycle?: string
- prompt?: string
- sourceTaskName?: string
- status?: string
- title?: string
-}
/**
* This interface was referenced by `VfsSnapshotV1`'s JSON-Schema
* via the `definition` "VfsSnapshotV1KnowledgeBase".
diff --git a/apps/sim/lib/copilot/resources/extraction.test.ts b/apps/sim/lib/copilot/resources/extraction.test.ts
index 150c8b217b7..65e8d40bc61 100644
--- a/apps/sim/lib/copilot/resources/extraction.test.ts
+++ b/apps/sim/lib/copilot/resources/extraction.test.ts
@@ -141,43 +141,6 @@ describe('extractResourcesFromToolResult', () => {
expect(resources).toEqual([])
})
-
- it('auto-opens a scheduledtask resource from manage_scheduled_task create results', () => {
- const resources = extractResourcesFromToolResult(
- 'manage_scheduled_task',
- { operation: 'create', args: { title: 'Daily Report' } },
- { jobId: 'sched_123', title: 'Daily Report', message: 'Job created successfully.' }
- )
-
- expect(resources).toEqual([{ type: 'scheduledtask', id: 'sched_123', title: 'Daily Report' }])
- })
-
- it('auto-opens a scheduledtask resource on update, falling back to the args title', () => {
- const resources = extractResourcesFromToolResult(
- 'manage_scheduled_task',
- { operation: 'update', args: { jobId: 'sched_123', title: 'Renamed Task' } },
- { jobId: 'sched_123', updated: ['title'], message: 'Job updated successfully' }
- )
-
- expect(resources).toEqual([{ type: 'scheduledtask', id: 'sched_123', title: 'Renamed Task' }])
- })
-
- it('does not auto-open for read-only manage_scheduled_task operations', () => {
- expect(
- extractResourcesFromToolResult(
- 'manage_scheduled_task',
- { operation: 'list' },
- { jobs: [], count: 0 }
- )
- ).toEqual([])
- expect(
- extractResourcesFromToolResult(
- 'manage_scheduled_task',
- { operation: 'get', args: { jobId: 'sched_123' } },
- { id: 'sched_123', title: 'Daily Report' }
- )
- ).toEqual([])
- })
})
describe('extractDeletedResourcesFromToolResult', () => {
@@ -230,27 +193,4 @@ describe('extractDeletedResourcesFromToolResult', () => {
)
).toEqual([{ type: 'knowledgebase', id: 'kb-1', title: 'Docs' }])
})
-
- it('removes scheduledtask resources on manage_scheduled_task delete', () => {
- const resources = extractDeletedResourcesFromToolResult(
- 'manage_scheduled_task',
- { operation: 'delete', args: { jobIds: ['sched_1', 'sched_2'] } },
- { deleted: ['sched_1', 'sched_2'], notFound: [] }
- )
-
- expect(resources).toEqual([
- { type: 'scheduledtask', id: 'sched_1', title: 'Scheduled Task' },
- { type: 'scheduledtask', id: 'sched_2', title: 'Scheduled Task' },
- ])
- })
-
- it('does not remove anything for non-delete manage_scheduled_task ops', () => {
- expect(
- extractDeletedResourcesFromToolResult(
- 'manage_scheduled_task',
- { operation: 'update', args: { jobId: 'sched_1' } },
- { jobId: 'sched_1', updated: ['title'] }
- )
- ).toEqual([])
- })
})
diff --git a/apps/sim/lib/copilot/resources/extraction.ts b/apps/sim/lib/copilot/resources/extraction.ts
index ddd7b4f52db..2a614d944b6 100644
--- a/apps/sim/lib/copilot/resources/extraction.ts
+++ b/apps/sim/lib/copilot/resources/extraction.ts
@@ -10,7 +10,6 @@ import {
GenerateVideo,
Knowledge,
KnowledgeBase,
- ManageScheduledTask,
Rm,
UserTable,
WorkspaceFile,
@@ -30,7 +29,6 @@ const RESOURCE_TOOL_NAMES: Set = new Set([
FunctionExecute.id,
KnowledgeBase.id,
Knowledge.id,
- ManageScheduledTask.id,
GenerateImage.id,
GenerateVideo.id,
GenerateAudio.id,
@@ -221,19 +219,6 @@ export function extractResourcesFromToolResult(
return resources
}
- case ManageScheduledTask.id: {
- // Read-only ops never auto-open; only create/update surface the task.
- const op = getOperation(params)
- if (op === 'list' || op === 'get') return []
- const jobId = (result.jobId as string) ?? (data.jobId as string)
- if (jobId) {
- const args = asRecord(params?.args)
- const title = (result.title as string) ?? (args.title as string) ?? 'Scheduled Task'
- return [{ type: 'scheduledtask', id: jobId, title }]
- }
- return []
- }
-
default:
return []
}
@@ -243,7 +228,6 @@ const DELETE_CAPABLE_TOOL_RESOURCE_TYPE: Record = {
[WorkspaceFile.id]: 'file',
[UserTable.id]: 'table',
[KnowledgeBase.id]: 'knowledgebase',
- [ManageScheduledTask.id]: 'scheduledtask',
// rm spans categories, so unlike every other entry its resource type comes
// from each outcome's kind rather than from this map. The entry exists so
// hasDeleteCapability(rm) holds; the rm case below ignores this value.
@@ -348,12 +332,6 @@ export function extractDeletedResourcesFromToolResult(
return []
}
- case ManageScheduledTask.id: {
- if (operation !== 'delete') return []
- const deletedIds = Array.isArray(result.deleted) ? (result.deleted as string[]) : []
- return deletedIds.map((id) => ({ type: resourceType, id, title: 'Scheduled Task' }))
- }
-
default:
return []
}
diff --git a/apps/sim/lib/copilot/resources/types.ts b/apps/sim/lib/copilot/resources/types.ts
index 718903b4ccd..6e78ddd5ebd 100644
--- a/apps/sim/lib/copilot/resources/types.ts
+++ b/apps/sim/lib/copilot/resources/types.ts
@@ -6,7 +6,6 @@ export const MothershipResourceType = {
folder: 'folder',
filefolder: 'filefolder',
task: 'task',
- scheduledtask: 'scheduledtask',
log: 'log',
integration: 'integration',
generic: 'generic',
@@ -54,7 +53,6 @@ const RESOURCE_POLICY: Record = {
folder: { persisted: true },
filefolder: { persisted: true },
task: { persisted: true },
- scheduledtask: { persisted: true },
log: { persisted: true },
integration: { persisted: true },
// A synthetic panel with no addressable entity behind it to reopen.
@@ -147,7 +145,6 @@ export const GENERIC_RESOURCE_TITLES = new Set([
'Workflow',
'Knowledge Base',
'Folder',
- 'Scheduled Task',
'Log',
])
@@ -157,5 +154,4 @@ export const VFS_DIR_TO_RESOURCE: Record = {
workflows: 'workflow',
knowledgebases: 'knowledgebase',
folders: 'folder',
- jobs: 'scheduledtask',
} as const
diff --git a/apps/sim/lib/copilot/tool-executor/register-handlers.ts b/apps/sim/lib/copilot/tool-executor/register-handlers.ts
index 29e18387f7c..6b434d65892 100644
--- a/apps/sim/lib/copilot/tool-executor/register-handlers.ts
+++ b/apps/sim/lib/copilot/tool-executor/register-handlers.ts
@@ -1,7 +1,6 @@
import { createLogger } from '@sim/logger'
import {
CheckDeploymentStatus,
- CompleteScheduledTask,
Cp as CpTool,
CreateWorkflow,
CreateWorkspaceMcpServer,
@@ -29,7 +28,6 @@ import {
ManageCredential,
ManageCustomTool,
ManageMcpTool,
- ManageScheduledTask,
ManageSkill,
MaterializeFile,
Mkdir as MkdirTool,
@@ -50,7 +48,6 @@ import {
SetBlockEnabled,
SetGlobalWorkflowVariables,
UpdateDeploymentVersion,
- UpdateScheduledTaskHistory,
UpdateWorkspaceMcpServer,
} from '@/lib/copilot/generated/tool-catalog-v1'
import { createServerToolHandler } from '@/lib/copilot/tools/registry/server-tool-adapter'
@@ -76,11 +73,6 @@ import {
} from '../tools/handlers/deployment/manage'
import { executeFunctionExecute } from '../tools/handlers/function-execute'
import { executeListIntegrationTools } from '../tools/handlers/integration-tools'
-import {
- executeCompleteJob,
- executeManageJob,
- executeUpdateJobHistory,
-} from '../tools/handlers/jobs'
import { executeManageCredential } from '../tools/handlers/management/manage-credential'
import { executeManageCustomTool } from '../tools/handlers/management/manage-custom-tool'
import { executeManageMcpTool } from '../tools/handlers/management/manage-mcp-tool'
@@ -178,10 +170,6 @@ function buildHandlerMap(): Record {
[PromoteToLive.id]: h(executePromoteToLive),
[UpdateDeploymentVersion.id]: h(executeUpdateDeploymentVersion),
- [ManageScheduledTask.id]: h(executeManageJob),
- [CompleteScheduledTask.id]: h(executeCompleteJob),
- [UpdateScheduledTaskHistory.id]: h(executeUpdateJobHistory),
-
[GrepTool.id]: h(executeVfsGrep),
[GlobTool.id]: h(executeVfsGlob),
[ReadTool.id]: h(executeVfsRead),
diff --git a/apps/sim/lib/copilot/tools/client/store-utils.ts b/apps/sim/lib/copilot/tools/client/store-utils.ts
index 92bbeb6c572..343c9e2712d 100644
--- a/apps/sim/lib/copilot/tools/client/store-utils.ts
+++ b/apps/sim/lib/copilot/tools/client/store-utils.ts
@@ -1,6 +1,6 @@
import type { ComponentType } from 'react'
import { Loader } from '@sim/emcn'
-import { FileText } from 'lucide-react'
+import { FileText } from '@sim/emcn/icons'
import { Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1'
import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resources/types'
import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools'
diff --git a/apps/sim/lib/copilot/tools/handlers/jobs.ts b/apps/sim/lib/copilot/tools/handlers/jobs.ts
deleted file mode 100644
index b9036a7e132..00000000000
--- a/apps/sim/lib/copilot/tools/handlers/jobs.ts
+++ /dev/null
@@ -1,445 +0,0 @@
-import { db } from '@sim/db'
-import { copilotChats, workflowSchedule } from '@sim/db/schema'
-import { createLogger } from '@sim/logger'
-import { toError } from '@sim/utils/errors'
-import { and, eq, isNull } from 'drizzle-orm'
-import { z } from 'zod'
-import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
-import {
- performCompleteJob,
- performCreateJob,
- performDeleteJob,
- performUpdateJob,
-} from '@/lib/workflows/schedules/orchestration'
-
-const logger = createLogger('JobTools')
-
-const ACTIVE_JOB_CONDITION = (workspaceId: string) =>
- and(
- eq(workflowSchedule.sourceWorkspaceId, workspaceId),
- eq(workflowSchedule.sourceType, 'job'),
- isNull(workflowSchedule.archivedAt)
- )
-
-const JobLifecycleSchema = z.enum(['persistent', 'until_complete'])
-
-const CreateJobParamsSchema = z
- .object({
- title: z.string().optional(),
- prompt: z.string().optional(),
- cron: z.string().optional(),
- time: z.string().optional(),
- timezone: z.string().optional(),
- lifecycle: JobLifecycleSchema.optional(),
- successCondition: z.string().optional(),
- maxRuns: z.number().optional(),
- })
- .passthrough()
-
-const ManageJobArgsSchema = z
- .object({
- jobId: z.string().optional(),
- jobIds: z.array(z.string()).optional(),
- title: z.string().optional(),
- prompt: z.string().optional(),
- cron: z.string().optional(),
- time: z.string().optional(),
- timezone: z.string().optional(),
- status: z.string().optional(),
- lifecycle: z.string().optional(),
- successCondition: z.string().optional(),
- maxRuns: z.number().optional(),
- })
- .passthrough()
-
-const ManageJobParamsSchema = z
- .object({
- operation: z.string().optional(),
- args: ManageJobArgsSchema.optional(),
- })
- .passthrough()
-
-type CreateJobParams = z.infer
-type ManageJobParams = z.infer
-
-export async function executeCreateJob(
- params: Record,
- context: ExecutionContext
-): Promise {
- const parsedParams = CreateJobParamsSchema.safeParse(params)
- if (!parsedParams.success) {
- return { success: false, error: 'Invalid create job parameters' }
- }
-
- const rawParams: CreateJobParams = parsedParams.data
- const timezone = rawParams.timezone || context.userTimezone || 'UTC'
- const { title, prompt, cron, time, lifecycle, successCondition, maxRuns } = rawParams
-
- if (!prompt) {
- return { success: false, error: 'prompt is required' }
- }
-
- if (!cron && !time) {
- return { success: false, error: 'At least one of cron or time must be provided' }
- }
-
- if (!context.userId || !context.workspaceId) {
- return { success: false, error: 'Missing user or workspace context' }
- }
-
- let taskName: string | null = null
- if (context.chatId) {
- try {
- const [chat] = await db
- .select({ title: copilotChats.title })
- .from(copilotChats)
- .where(eq(copilotChats.id, context.chatId))
- .limit(1)
- taskName = chat?.title || null
- } catch (err) {
- logger.warn('Failed to look up chat title for job', {
- chatId: context.chatId,
- error: toError(err).message,
- })
- }
- }
-
- try {
- const result = await performCreateJob({
- workspaceId: context.workspaceId,
- userId: context.userId,
- title,
- prompt,
- cronExpression: cron,
- time,
- timezone,
- lifecycle,
- successCondition,
- maxRuns,
- sourceChatId: context.chatId,
- sourceTaskName: taskName,
- })
- if (!result.success || !result.schedule) {
- return { success: false, error: result.error || 'Failed to create job' }
- }
-
- return {
- success: true,
- output: {
- jobId: result.schedule.id,
- title: result.schedule.jobTitle,
- schedule: result.humanReadable,
- nextRunAt: result.schedule.nextRunAt?.toISOString(),
- message: `Job created successfully. ${result.humanReadable}`,
- },
- }
- } catch (err) {
- logger.error('Failed to create job', {
- error: toError(err).message,
- })
- return { success: false, error: 'Failed to create job' }
- }
-}
-
-export async function executeManageJob(
- params: Record,
- context: ExecutionContext
-): Promise {
- const parsedParams = ManageJobParamsSchema.safeParse(params)
- if (!parsedParams.success) {
- return { success: false, error: 'Invalid manage job parameters' }
- }
-
- const rawParams: ManageJobParams = parsedParams.data
- const { operation, args } = rawParams
-
- if (!context.userId || !context.workspaceId) {
- return { success: false, error: 'Missing user or workspace context' }
- }
-
- switch (operation) {
- case 'create': {
- return executeCreateJob(
- {
- title: args?.title,
- prompt: args?.prompt,
- cron: args?.cron,
- time: args?.time,
- timezone: args?.timezone,
- lifecycle: args?.lifecycle,
- successCondition: args?.successCondition,
- maxRuns: args?.maxRuns,
- } as Record,
- context
- )
- }
-
- case 'list': {
- try {
- const jobs = await db
- .select({
- id: workflowSchedule.id,
- jobTitle: workflowSchedule.jobTitle,
- prompt: workflowSchedule.prompt,
- cronExpression: workflowSchedule.cronExpression,
- timezone: workflowSchedule.timezone,
- status: workflowSchedule.status,
- lifecycle: workflowSchedule.lifecycle,
- successCondition: workflowSchedule.successCondition,
- maxRuns: workflowSchedule.maxRuns,
- runCount: workflowSchedule.runCount,
- nextRunAt: workflowSchedule.nextRunAt,
- lastRanAt: workflowSchedule.lastRanAt,
- sourceTaskName: workflowSchedule.sourceTaskName,
- createdAt: workflowSchedule.createdAt,
- })
- .from(workflowSchedule)
- .where(ACTIVE_JOB_CONDITION(context.workspaceId))
-
- return {
- success: true,
- output: {
- jobs: jobs.map((j) => ({
- id: j.id,
- title: j.jobTitle,
- prompt: j.prompt,
- cronExpression: j.cronExpression,
- timezone: j.timezone,
- status: j.status,
- lifecycle: j.lifecycle,
- successCondition: j.successCondition,
- maxRuns: j.maxRuns,
- runCount: j.runCount,
- nextRunAt: j.nextRunAt?.toISOString(),
- lastRanAt: j.lastRanAt?.toISOString(),
- sourceTaskName: j.sourceTaskName,
- createdAt: j.createdAt.toISOString(),
- })),
- count: jobs.length,
- },
- }
- } catch (err) {
- logger.error('Failed to list jobs', {
- error: toError(err).message,
- })
- return { success: false, error: 'Failed to list jobs' }
- }
- }
-
- case 'get': {
- if (!args?.jobId) {
- return { success: false, error: 'jobId is required for get operation' }
- }
-
- try {
- const [job] = await db
- .select()
- .from(workflowSchedule)
- .where(
- and(eq(workflowSchedule.id, args.jobId), ACTIVE_JOB_CONDITION(context.workspaceId))
- )
- .limit(1)
-
- if (!job) {
- return { success: false, error: `Job not found: ${args.jobId}` }
- }
-
- return {
- success: true,
- output: {
- id: job.id,
- title: job.jobTitle,
- prompt: job.prompt,
- cronExpression: job.cronExpression,
- timezone: job.timezone,
- status: job.status,
- lifecycle: job.lifecycle,
- successCondition: job.successCondition,
- maxRuns: job.maxRuns,
- runCount: job.runCount,
- nextRunAt: job.nextRunAt?.toISOString(),
- lastRanAt: job.lastRanAt?.toISOString(),
- sourceTaskName: job.sourceTaskName,
- sourceChatId: job.sourceChatId,
- createdAt: job.createdAt.toISOString(),
- },
- }
- } catch (err) {
- logger.error('Failed to get job', {
- error: toError(err).message,
- })
- return { success: false, error: 'Failed to get job' }
- }
- }
-
- case 'update': {
- if (!args?.jobId) {
- return { success: false, error: 'jobId is required for update operation' }
- }
-
- try {
- const result = await performUpdateJob({
- jobId: args.jobId,
- workspaceId: context.workspaceId,
- userId: context.userId,
- title: args.title,
- prompt: args.prompt,
- cronExpression: args.cron,
- time: args.time,
- timezone: args.timezone,
- status: args.status,
- lifecycle: args.lifecycle,
- successCondition: args.successCondition,
- maxRuns: args.maxRuns,
- })
- if (!result.success) {
- return { success: false, error: result.error || 'Failed to update job' }
- }
-
- return {
- success: true,
- output: {
- jobId: args.jobId,
- updated: result.updatedFields || [],
- message: 'Job updated successfully',
- },
- }
- } catch (err) {
- logger.error('Failed to update job', {
- error: toError(err).message,
- })
- return { success: false, error: 'Failed to update job' }
- }
- }
-
- case 'delete': {
- const jobIds = args?.jobIds ?? (args?.jobId ? [args.jobId] : [])
- if (jobIds.length === 0) {
- return { success: false, error: 'jobId or jobIds is required for delete operation' }
- }
-
- try {
- const deleted: string[] = []
- const notFound: string[] = []
-
- for (const jobId of jobIds) {
- const result = await performDeleteJob({
- jobId,
- workspaceId: context.workspaceId,
- userId: context.userId,
- })
- if (!result.success) {
- notFound.push(jobId)
- continue
- }
- deleted.push(jobId)
- }
-
- return {
- success: deleted.length > 0,
- output: { deleted, notFound },
- }
- } catch (err) {
- logger.error('Failed to delete job', {
- error: toError(err).message,
- })
- return { success: false, error: 'Failed to delete job' }
- }
- }
-
- default:
- return { success: false, error: `Unknown operation: ${operation}` }
- }
-}
-
-export async function executeCompleteJob(
- params: Record,
- context: ExecutionContext
-): Promise {
- const { jobId } = params as { jobId?: string }
-
- if (!jobId) {
- return { success: false, error: 'jobId is required' }
- }
-
- try {
- if (!context.workspaceId) {
- return { success: false, error: 'Missing workspace context' }
- }
-
- const result = await performCompleteJob({
- jobId,
- workspaceId: context.workspaceId,
- userId: context.userId,
- })
- if (!result.success) {
- return { success: false, error: result.error || 'Failed to complete job' }
- }
- if (result.alreadyCompleted) {
- return {
- success: true,
- output: { jobId, message: 'Job is already completed' },
- }
- }
-
- return {
- success: true,
- output: { jobId, message: 'Job marked as completed. No further executions will occur.' },
- }
- } catch (err) {
- logger.error('Failed to complete job', {
- error: toError(err).message,
- })
- return { success: false, error: 'Failed to complete job' }
- }
-}
-
-export async function executeUpdateJobHistory(
- params: Record,
- context: ExecutionContext
-): Promise {
- const { jobId, summary } = params as { jobId?: string; summary?: string }
-
- if (!jobId || !summary) {
- return { success: false, error: 'jobId and summary are required' }
- }
-
- if (!context.workspaceId) {
- return { success: false, error: 'Missing workspace context' }
- }
-
- try {
- const [job] = await db
- .select({
- id: workflowSchedule.id,
- jobHistory: workflowSchedule.jobHistory,
- })
- .from(workflowSchedule)
- .where(and(eq(workflowSchedule.id, jobId), ACTIVE_JOB_CONDITION(context.workspaceId)))
- .limit(1)
-
- if (!job) {
- return { success: false, error: `Job not found: ${jobId}` }
- }
-
- const existing = (job.jobHistory || []) as Array<{ timestamp: string; summary: string }>
- const updated = [...existing, { timestamp: new Date().toISOString(), summary }].slice(-50)
-
- await db
- .update(workflowSchedule)
- .set({ jobHistory: updated, updatedAt: new Date() })
- .where(and(eq(workflowSchedule.id, jobId), isNull(workflowSchedule.archivedAt)))
-
- logger.info('Job history updated', { jobId, entryCount: updated.length })
-
- return {
- success: true,
- output: { jobId, entryCount: updated.length, message: 'History entry recorded.' },
- }
- } catch (err) {
- logger.error('Failed to update job history', {
- error: toError(err).message,
- })
- return { success: false, error: 'Failed to update job history' }
- }
-}
diff --git a/apps/sim/lib/copilot/tools/handlers/resources.ts b/apps/sim/lib/copilot/tools/handlers/resources.ts
index e7a970f3992..52ea670cf74 100644
--- a/apps/sim/lib/copilot/tools/handlers/resources.ts
+++ b/apps/sim/lib/copilot/tools/handlers/resources.ts
@@ -1,6 +1,3 @@
-import { db } from '@sim/db'
-import { workflowSchedule } from '@sim/db/schema'
-import { and, eq, isNull } from 'drizzle-orm'
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
import { type MothershipResource, MothershipResourceType } from '@/lib/copilot/resources/types'
import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils'
@@ -86,27 +83,6 @@ async function resolveResource(
})
title = `${workflowName} — ${timestamp}`
}
- if (resourceType === 'scheduledtask') {
- if (!item.id) return { error: 'scheduledtask resources require `id`.' }
- if (!context.workspaceId)
- return { error: 'Opening a scheduled task requires workspace context.' }
- const [schedule] = await db
- .select({ id: workflowSchedule.id, jobTitle: workflowSchedule.jobTitle })
- .from(workflowSchedule)
- .where(
- and(
- eq(workflowSchedule.id, item.id),
- eq(workflowSchedule.sourceWorkspaceId, context.workspaceId),
- eq(workflowSchedule.sourceType, 'job'),
- isNull(workflowSchedule.archivedAt)
- )
- )
- .limit(1)
- if (!schedule) return { error: `No scheduled task with id "${item.id}".` }
- resourceId = schedule.id
- title = schedule.jobTitle || 'Scheduled Task'
- }
-
return { type: resourceType, id: resourceId, title }
}
diff --git a/apps/sim/lib/copilot/tools/server/jobs/get-job-logs.test.ts b/apps/sim/lib/copilot/tools/server/jobs/get-job-logs.test.ts
deleted file mode 100644
index a77b0a606cb..00000000000
--- a/apps/sim/lib/copilot/tools/server/jobs/get-job-logs.test.ts
+++ /dev/null
@@ -1,131 +0,0 @@
-/**
- * @vitest-environment node
- */
-import { dbChainMockFns, resetDbChainMock } from '@sim/testing'
-import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
-
-const { checkWorkspaceAccessMock, materializeExecutionDataForDisplayMock } = vi.hoisted(() => ({
- checkWorkspaceAccessMock: vi.fn(),
- materializeExecutionDataForDisplayMock: vi.fn(),
-}))
-
-vi.mock('@/lib/workspaces/permissions/utils', () => ({
- checkWorkspaceAccess: checkWorkspaceAccessMock,
-}))
-
-vi.mock('@/lib/logs/execution/trace-store', () => ({
- materializeExecutionDataForDisplay: materializeExecutionDataForDisplayMock,
-}))
-
-import { getJobLogsServerTool } from './get-job-logs'
-
-const RAW_SECRET = 'sk-job-log-secret'
-const MASKED_SECRET = '{{OPENAI_API_KEY}}'
-const CONTEXT = { userId: 'user-1', workspaceId: 'workspace-1' }
-
-function jobLogRow(overrides: Record = {}) {
- return {
- id: 'log-1',
- executionId: 'execution-1',
- status: 'success',
- level: 'info',
- trigger: 'schedule',
- startedAt: new Date('2026-07-31T00:00:00.000Z'),
- endedAt: new Date('2026-07-31T00:00:01.000Z'),
- totalDurationMs: 1000,
- executionData: {
- finalOutput: { result: RAW_SECRET },
- traceSpans: [{ id: 'span-1', output: { result: RAW_SECRET } }],
- },
- cost: null,
- ...overrides,
- }
-}
-
-describe('getJobLogsServerTool', () => {
- afterAll(resetDbChainMock)
-
- beforeEach(() => {
- vi.clearAllMocks()
- resetDbChainMock()
- checkWorkspaceAccessMock.mockResolvedValue({ hasAccess: true })
- })
-
- it('returns the secret-safe log projection instead of raw successful output', async () => {
- const row = jobLogRow()
- dbChainMockFns.limit.mockResolvedValueOnce([row])
- materializeExecutionDataForDisplayMock.mockResolvedValueOnce({
- finalOutput: { result: MASKED_SECRET },
- traceSpans: [
- {
- id: 'span-1',
- name: 'Function 1',
- type: 'function',
- status: 'success',
- duration: 1000,
- startTime: '2026-07-31T00:00:00.000Z',
- endTime: '2026-07-31T00:00:01.000Z',
- output: { result: MASKED_SECRET },
- },
- ],
- })
-
- const result = await getJobLogsServerTool.execute(
- { jobId: 'schedule-1', includeDetails: true },
- CONTEXT
- )
-
- expect(result).toEqual([
- expect.objectContaining({
- executionId: 'execution-1',
- output: { result: MASKED_SECRET },
- }),
- ])
- expect(JSON.stringify(result)).not.toContain(RAW_SECRET)
- expect(materializeExecutionDataForDisplayMock).toHaveBeenCalledWith(row.executionData, {
- workspaceId: 'workspace-1',
- workflowId: null,
- executionId: 'execution-1',
- userId: 'user-1',
- })
- })
-
- it('does not fall back to raw output or errors when provenance is incomplete', async () => {
- dbChainMockFns.limit.mockResolvedValueOnce([
- jobLogRow({
- status: 'error',
- executionData: {
- finalOutput: { error: RAW_SECRET },
- error: RAW_SECRET,
- traceSpans: [{ id: 'span-1', output: { error: RAW_SECRET } }],
- },
- }),
- ])
- materializeExecutionDataForDisplayMock.mockResolvedValueOnce({
- traceSpans: [
- {
- id: 'span-1',
- name: 'Function 1',
- type: 'function',
- status: 'error',
- duration: 1000,
- startTime: '2026-07-31T00:00:00.000Z',
- endTime: '2026-07-31T00:00:01.000Z',
- },
- ],
- })
-
- const result = await getJobLogsServerTool.execute(
- { jobId: 'schedule-1', includeDetails: true },
- CONTEXT
- )
-
- expect(result).toEqual([
- expect.not.objectContaining({
- output: expect.anything(),
- error: expect.anything(),
- }),
- ])
- expect(JSON.stringify(result)).not.toContain(RAW_SECRET)
- })
-})
diff --git a/apps/sim/lib/copilot/tools/server/jobs/get-job-logs.ts b/apps/sim/lib/copilot/tools/server/jobs/get-job-logs.ts
deleted file mode 100644
index f8c9c60c9bb..00000000000
--- a/apps/sim/lib/copilot/tools/server/jobs/get-job-logs.ts
+++ /dev/null
@@ -1,242 +0,0 @@
-import { db } from '@sim/db'
-import { jobExecutionLogs } from '@sim/db/schema'
-import { createLogger } from '@sim/logger'
-import { and, desc, eq } from 'drizzle-orm'
-import { GetScheduledTaskLogs } from '@/lib/copilot/generated/tool-catalog-v1'
-import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool'
-import { materializeExecutionDataForDisplay } from '@/lib/logs/execution/trace-store'
-import type { TraceSpan } from '@/lib/logs/types'
-import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
-
-const logger = createLogger('GetJobLogsServerTool')
-
-interface GetJobLogsArgs {
- jobId: string
- executionId?: string
- limit?: number
- includeDetails?: boolean
- workspaceId?: string
-}
-
-interface ToolCallDetail {
- name: string
- input: unknown
- output: unknown
- error?: string
- duration: number
-}
-
-interface JobLogEntry {
- executionId: string
- status: string
- trigger: string
- startedAt: string
- endedAt: string | null
- durationMs: number | null
- error?: string
- toolCalls?: ToolCallDetail[]
- output?: unknown
- cost?: unknown
- tokens?: unknown
-}
-
-/**
- * Walks the trace-span tree and collects tool invocations from both data shapes:
- * - New: `type: 'tool'` spans nested under agent blocks in `children`.
- * - Legacy: a `toolCalls` array hanging off the agent span directly (pre-unification).
- */
-function collectToolCalls(spans: TraceSpan[] | undefined): ToolCallDetail[] {
- if (!spans?.length) return []
- const collected: ToolCallDetail[] = []
-
- const visit = (span: TraceSpan) => {
- if (span.type === 'tool') {
- const output = span.output as { result?: unknown } | undefined
- collected.push({
- name: span.name || 'unknown',
- input: span.input ?? {},
- output: output?.result ?? span.output,
- error: span.status === 'error' ? errorMessageFromSpan(span) : undefined,
- duration: span.duration || 0,
- })
- return
- }
-
- if (span.toolCalls?.length) {
- for (const tc of span.toolCalls) {
- collected.push({
- name: tc.name || 'unknown',
- input: tc.input ?? {},
- output: tc.output ?? undefined,
- error: tc.error || undefined,
- duration: tc.duration || 0,
- })
- }
- }
-
- if (span.children?.length) {
- for (const child of span.children) visit(child)
- }
- }
-
- for (const span of spans) visit(span)
- return collected
-}
-
-function errorMessageFromSpan(span: TraceSpan): string | undefined {
- const out = span.output as { error?: unknown } | undefined
- if (typeof out?.error === 'string') return out.error
- return undefined
-}
-
-function extractOutputAndError(
- executionData: { traceSpans?: TraceSpan[] } & Record
-): {
- output: unknown
- error: string | undefined
- toolCalls: ToolCallDetail[]
- cost: unknown
- tokens: unknown
-} {
- const traceSpans = executionData?.traceSpans ?? []
- const mainSpan = traceSpans[0]
-
- const toolCalls = collectToolCalls(traceSpans)
- const output = mainSpan?.output || executionData?.finalOutput || undefined
- const cost = mainSpan?.cost || executionData?.cost || undefined
- const tokens = mainSpan?.tokens || undefined
-
- const errorMsg =
- mainSpan?.status === 'error'
- ? mainSpan?.output?.error || executionData?.error
- : executionData?.error || undefined
-
- return {
- output,
- error: errorMsg
- ? typeof errorMsg === 'string'
- ? errorMsg
- : JSON.stringify(errorMsg)
- : undefined,
- toolCalls,
- cost,
- tokens,
- }
-}
-
-export const getJobLogsServerTool: BaseServerTool = {
- name: GetScheduledTaskLogs.id,
- async execute(rawArgs: GetJobLogsArgs, context?: ServerToolContext): Promise {
- const withMessageId = (message: string) =>
- context?.messageId ? `${message} [messageId:${context.messageId}]` : message
-
- const {
- jobId,
- executionId,
- limit = 3,
- includeDetails = false,
- workspaceId,
- } = rawArgs || ({} as GetJobLogsArgs)
-
- if (!jobId || typeof jobId !== 'string') {
- throw new Error('jobId is required')
- }
- if (!context?.userId) {
- throw new Error('Unauthorized access')
- }
-
- const wsId = workspaceId || context.workspaceId
- if (!wsId) {
- throw new Error('Workspace context required')
- }
- const access = await checkWorkspaceAccess(wsId, context.userId)
- if (!access.hasAccess) {
- throw new Error('Unauthorized workspace access')
- }
-
- const clampedLimit = Math.min(Math.max(1, limit), 5)
-
- logger.info('Fetching job logs', {
- jobId,
- executionId,
- limit: clampedLimit,
- includeDetails,
- })
-
- const conditions = [
- eq(jobExecutionLogs.scheduleId, jobId),
- eq(jobExecutionLogs.workspaceId, wsId),
- ]
- if (executionId) {
- conditions.push(eq(jobExecutionLogs.executionId, executionId))
- }
-
- const rows = await db
- .select({
- id: jobExecutionLogs.id,
- executionId: jobExecutionLogs.executionId,
- status: jobExecutionLogs.status,
- level: jobExecutionLogs.level,
- trigger: jobExecutionLogs.trigger,
- startedAt: jobExecutionLogs.startedAt,
- endedAt: jobExecutionLogs.endedAt,
- totalDurationMs: jobExecutionLogs.totalDurationMs,
- executionData: jobExecutionLogs.executionData,
- cost: jobExecutionLogs.cost,
- })
- .from(jobExecutionLogs)
- .where(and(...conditions))
- .orderBy(desc(jobExecutionLogs.startedAt))
- .limit(executionId ? 1 : clampedLimit)
-
- const entries: JobLogEntry[] = await Promise.all(
- rows.map(async (row) => {
- const executionData = await materializeExecutionDataForDisplay(
- row.executionData as Record | null,
- {
- workspaceId: wsId,
- workflowId: null,
- executionId: row.executionId,
- userId: context.userId,
- }
- )
- const details = includeDetails ? extractOutputAndError(executionData) : null
-
- const entry: JobLogEntry = {
- executionId: row.executionId,
- status: row.status,
- trigger: row.trigger,
- startedAt: row.startedAt.toISOString(),
- endedAt: row.endedAt ? row.endedAt.toISOString() : null,
- durationMs: row.totalDurationMs ?? null,
- }
-
- if (details) {
- if (details.error) entry.error = details.error
- if (details.toolCalls.length > 0) entry.toolCalls = details.toolCalls
- if (details.output) entry.output = details.output
- if (details.cost) entry.cost = details.cost
- if (details.tokens) entry.tokens = details.tokens
- } else {
- const traceSpans = Array.isArray(executionData.traceSpans)
- ? (executionData.traceSpans as TraceSpan[])
- : []
- const errorMsg = executionData.error || traceSpans[0]?.output?.error
- if (row.status === 'error' && errorMsg) {
- entry.error = typeof errorMsg === 'string' ? errorMsg : JSON.stringify(errorMsg)
- }
- }
-
- return entry
- })
- )
-
- logger.info('Job logs prepared', {
- jobId,
- count: entries.length,
- resultSizeKB: Math.round(JSON.stringify(entries).length / 1024),
- })
-
- return entries
- },
-}
diff --git a/apps/sim/lib/copilot/tools/server/router.ts b/apps/sim/lib/copilot/tools/server/router.ts
index 8a99814333f..e1067314263 100644
--- a/apps/sim/lib/copilot/tools/server/router.ts
+++ b/apps/sim/lib/copilot/tools/server/router.ts
@@ -41,7 +41,6 @@ import { shareFileServerTool } from '@/lib/copilot/tools/server/files/share-file
import { workspaceFileServerTool } from '@/lib/copilot/tools/server/files/workspace-file'
import { validateGeneratedToolPayload } from '@/lib/copilot/tools/server/generated-schema'
import { generateImageServerTool } from '@/lib/copilot/tools/server/image/generate-image'
-import { getJobLogsServerTool } from '@/lib/copilot/tools/server/jobs/get-job-logs'
import { knowledgeBaseServerTool } from '@/lib/copilot/tools/server/knowledge/knowledge-base'
import { searchKnowledgeBaseServerTool } from '@/lib/copilot/tools/server/knowledge/search-knowledge-base'
import { ffmpegServerTool } from '@/lib/copilot/tools/server/media/ffmpeg'
@@ -154,7 +153,6 @@ const baseServerToolRegistry: Record = {
[getTriggerBlocksServerTool.name]: getTriggerBlocksServerTool,
[editWorkflowServerTool.name]: editWorkflowServerTool,
[queryLogsServerTool.name]: queryLogsServerTool,
- [getJobLogsServerTool.name]: getJobLogsServerTool,
[searchDocumentationServerTool.name]: searchDocumentationServerTool,
[searchOnlineServerTool.name]: searchOnlineServerTool,
[setEnvironmentVariablesServerTool.name]: setEnvironmentVariablesServerTool,
diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts
index feaa674f753..a519f326339 100644
--- a/apps/sim/lib/copilot/tools/tool-display.test.ts
+++ b/apps/sim/lib/copilot/tools/tool-display.test.ts
@@ -56,7 +56,7 @@ function toolPropertyEnum(entry: ToolCatalogEntry, property: string): unknown[]
describe('humanizeToolName', () => {
it('title-cases snake_case names', () => {
- expect(humanizeToolName('manage_scheduled_task')).toBe('Manage Scheduled Task')
+ expect(humanizeToolName('manage_custom_tool')).toBe('Manage Custom Tool')
})
it('title-cases kebab-case names', () => {
@@ -308,11 +308,6 @@ describe('getToolDisplayTitle for managed resources', () => {
],
['manage_mcp_tool', { operation: 'edit', config: { name: 'Linear' } }, 'Updating Linear'],
['manage_skill', { operation: 'delete', name: 'sales-research' }, 'Deleting sales-research'],
- [
- 'manage_scheduled_task',
- { operation: 'create', args: { title: 'Morning Digest' } },
- 'Creating Morning Digest',
- ],
[
'manage_credential',
{
@@ -326,8 +321,6 @@ describe('getToolDisplayTitle for managed resources', () => {
['manage_custom_tool', { operation: 'list' }, 'Viewing custom tools'],
['manage_mcp_tool', { operation: 'list' }, 'Viewing MCP servers'],
['manage_skill', { operation: 'list' }, 'Viewing skills'],
- ['manage_scheduled_task', { operation: 'get' }, 'Reading scheduled task'],
- ['manage_scheduled_task', { operation: 'list' }, 'Viewing scheduled tasks'],
])('uses verb + resource name for %s', (toolName, args, expected) => {
expect(getToolDisplayTitle(toolName, args)).toBe(expected)
})
@@ -414,9 +407,6 @@ describe('getToolDisplayTitle for operation-driven tools', () => {
expect(getToolDisplayTitle('restore_resource', { type: 'knowledgebase' })).toBe(
'Restoring knowledge base'
)
- expect(getToolDisplayTitle('open_resource', { resources: [{ type: 'scheduledtask' }] })).toBe(
- 'Opening scheduled task'
- )
})
it('includes deployment versions when available', () => {
diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts
index 091d289a5b1..c8623c9b237 100644
--- a/apps/sim/lib/copilot/tools/tool-display.ts
+++ b/apps/sim/lib/copilot/tools/tool-display.ts
@@ -62,7 +62,6 @@ function deploymentTitle(args: ToolArgs, deploymentType: string): string {
function resourceTypeLabel(type: string): string {
const labels: Record = {
knowledgebase: 'knowledge base',
- scheduledtask: 'scheduled task',
file_folder: 'file folder',
log: 'logs',
}
@@ -434,7 +433,6 @@ const TOOL_TITLES: Record = {
generate_audio: 'Generating audio',
ffmpeg: 'Processing media',
check_deployment_status: 'Checking deployment status',
- complete_scheduled_task: 'Completing scheduled task',
create_file: 'Creating file',
create_file_folder: 'Creating folder',
create_workspace_mcp_server: 'Creating MCP server',
@@ -452,7 +450,6 @@ const TOOL_TITLES: Record = {
get_deployed_workflow_state: 'Getting deployed workflow',
get_deployment_log: 'Getting deployment logs',
get_platform_actions: 'Getting platform actions',
- get_scheduled_task_logs: 'Getting scheduled task logs',
get_workflow_data: 'Getting workflow data',
get_workflow_run_options: 'Getting run options',
list_file_folders: 'Listing folders',
@@ -479,7 +476,6 @@ const TOOL_TITLES: Record = {
set_environment_variables: 'Setting environment variables',
set_global_workflow_variables: 'Setting workflow variables',
update_deployment_version: 'Updating deployment',
- update_scheduled_task_history: 'Updating task history',
update_workspace_mcp_server: 'Updating MCP server',
// Browser agent tools without an argument-aware title.
browser_go_back: 'Going back',
@@ -502,7 +498,6 @@ const TOOL_TITLES: Record = {
auth: 'Auth Agent',
knowledge: 'Knowledge Agent',
table: 'Table Agent',
- scheduled_task: 'Scheduled Task Agent',
agent: 'Tools Agent',
research: 'Research Agent',
scout: 'Scout Agent',
@@ -892,17 +887,6 @@ export function getToolDisplayTitle(name: string, args?: Record
list: { verb: 'Viewing', resource: 'skills' },
})
}
- case 'manage_scheduled_task': {
- const target =
- firstStringArg(args, 'title', 'taskName', 'name') || nestedStringArg(args, 'args', 'title')
- return namedOperationTitle(args, target, 'Scheduled task action', {
- create: { verb: 'Creating', resource: 'scheduled task' },
- get: { verb: 'Reading', resource: 'scheduled task' },
- update: { verb: 'Updating', resource: 'scheduled task' },
- delete: { verb: 'Deleting', resource: 'scheduled task' },
- list: { verb: 'Viewing', resource: 'scheduled tasks' },
- })
- }
case 'manage_credential': {
const operation = stringArg(args, 'operation')
if (operation === 'rename') {
diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts
index 5dd5959a86e..53109f4062b 100644
--- a/apps/sim/lib/copilot/vfs/serializers.ts
+++ b/apps/sim/lib/copilot/vfs/serializers.ts
@@ -1102,49 +1102,6 @@ export function serializeTriggerOverview(
return lines.join('\n')
}
-/**
- * Serialize job metadata for VFS jobs/{id}/meta.json
- */
-export function serializeJobMeta(job: {
- id: string
- title: string | null
- prompt: string
- cronExpression: string | null
- timezone: string | null
- status: string
- lifecycle: string
- successCondition: string | null
- maxRuns: number | null
- runCount: number
- nextRunAt: Date | null
- lastRanAt: Date | null
- sourceTaskName: string | null
- sourceChatId: string | null
- createdAt: Date
-}): string {
- return JSON.stringify(
- {
- id: job.id,
- title: job.title || undefined,
- prompt: job.prompt,
- cronExpression: job.cronExpression || undefined,
- timezone: job.timezone || 'UTC',
- status: job.status,
- lifecycle: job.lifecycle,
- successCondition: job.successCondition || undefined,
- maxRuns: job.maxRuns ?? undefined,
- runCount: job.runCount,
- nextRunAt: job.nextRunAt?.toISOString(),
- lastRanAt: job.lastRanAt?.toISOString(),
- sourceTaskName: job.sourceTaskName || undefined,
- sourceChatId: job.sourceChatId || undefined,
- createdAt: job.createdAt.toISOString(),
- },
- null,
- 2
- )
-}
-
export function serializeTaskSession(task: {
id: string
title: string
diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts
index 2be11810418..8e112a553ca 100644
--- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts
+++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts
@@ -6,7 +6,6 @@ import {
customTools as customToolsTable,
document,
folder as folderTable,
- jobExecutionLogs,
knowledgeBaseTagDefinitions,
knowledgeConnector,
mcpServers as mcpServersTable,
@@ -15,11 +14,10 @@ import {
workflowExecutionLogs,
workflowMcpServer,
workflowMcpTool,
- workflowSchedule,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
-import { and, desc, eq, inArray, isNotNull, isNull, ne, or, sql } from 'drizzle-orm'
+import { and, desc, eq, inArray, isNotNull, isNull, or, sql } from 'drizzle-orm'
import { listApiKeys } from '@/lib/api-key/service'
import {
buildWorkspaceContextMd,
@@ -72,7 +70,6 @@ import {
serializeEnvironmentVariables,
serializeFileMeta,
serializeIntegrationSchema,
- serializeJobMeta,
serializeKBMeta,
serializeMcpServer,
serializeRecentExecutions,
@@ -452,9 +449,6 @@ function getStaticComponentFiles(): Map {
* files/{name} (workspace file leaf; dynamic content on read)
* files/{path}/{name}/style (dynamic — style extraction for .docx/.pptx/.pdf)
* files/{path}/{name}/compiled-check (dynamic — compile generated source / validate diagrams, returns {ok,error?})
- * jobs/{title}/meta.json
- * jobs/{title}/history.json
- * jobs/{title}/executions.json
* tasks/{title}/session.md
* tasks/{title}/chat.json
* custom-tools/{name}.json
@@ -683,7 +677,6 @@ export class WorkspaceVFS {
customBlocksSummary,
mcpServersSummary,
skillsSummary,
- jobsSummary,
wsRow,
members,
] = await Promise.all([
@@ -696,7 +689,6 @@ export class WorkspaceVFS {
timed('custom_blocks', this.materializeCustomBlocks(workspaceId)),
timed('mcp_servers', this.materializeMcpServers(workspaceId)),
timed('skills', this.materializeSkills(workspaceId)),
- timed('jobs', this.materializeJobs(workspaceId)),
timed('workspace_row', getWorkspaceWithOwner(workspaceId)),
timed('members', getUsersWithPermissions(workspaceId)),
// Writes tasks/ files only — WORKSPACE.md has no Tasks section
@@ -718,7 +710,6 @@ export class WorkspaceVFS {
customBlocks: customBlocksSummary,
mcpServers: mcpServersSummary,
skills: skillsSummary,
- jobs: jobsSummary,
}
this.files.set('WORKSPACE.md', buildWorkspaceMd(workspaceMdData))
@@ -2090,114 +2081,6 @@ export class WorkspaceVFS {
}
}
- /**
- * Materialize scheduled jobs using the workflowSchedule table.
- * Returns a summary for WORKSPACE.md generation.
- */
- private async materializeJobs(
- workspaceId: string
- ): Promise> {
- try {
- const jobRows = await db
- .select({
- id: workflowSchedule.id,
- jobTitle: workflowSchedule.jobTitle,
- prompt: workflowSchedule.prompt,
- cronExpression: workflowSchedule.cronExpression,
- timezone: workflowSchedule.timezone,
- status: workflowSchedule.status,
- lifecycle: workflowSchedule.lifecycle,
- successCondition: workflowSchedule.successCondition,
- maxRuns: workflowSchedule.maxRuns,
- runCount: workflowSchedule.runCount,
- nextRunAt: workflowSchedule.nextRunAt,
- lastRanAt: workflowSchedule.lastRanAt,
- sourceTaskName: workflowSchedule.sourceTaskName,
- sourceChatId: workflowSchedule.sourceChatId,
- jobHistory: workflowSchedule.jobHistory,
- createdAt: workflowSchedule.createdAt,
- })
- .from(workflowSchedule)
- .where(
- and(
- eq(workflowSchedule.sourceWorkspaceId, workspaceId),
- eq(workflowSchedule.sourceType, 'job'),
- isNull(workflowSchedule.archivedAt),
- ne(workflowSchedule.status, 'completed')
- )
- )
-
- for (const job of jobRows) {
- const safeName = sanitizeName(job.jobTitle || job.id)
- this.files.set(
- `jobs/${safeName}/meta.json`,
- serializeJobMeta({
- id: job.id,
- title: job.jobTitle,
- prompt: job.prompt || '',
- cronExpression: job.cronExpression,
- timezone: job.timezone,
- status: job.status,
- lifecycle: job.lifecycle,
- successCondition: job.successCondition,
- maxRuns: job.maxRuns,
- runCount: job.runCount,
- nextRunAt: job.nextRunAt,
- lastRanAt: job.lastRanAt,
- sourceTaskName: job.sourceTaskName,
- sourceChatId: job.sourceChatId,
- createdAt: job.createdAt,
- })
- )
-
- const history = job.jobHistory as Array<{ timestamp: string; summary: string }> | null
- if (history && history.length > 0) {
- this.files.set(`jobs/${safeName}/history.json`, JSON.stringify(history, null, 2))
- }
-
- // executions.json is lazy, advertised only when the job has run (cheap
- // signal: lastRanAt) — no per-job query on a read/glob.
- if (job.lastRanAt) {
- this.registerLazy(`jobs/${safeName}/executions.json`, async () => {
- const execRows = await db
- .select({
- id: jobExecutionLogs.id,
- executionId: jobExecutionLogs.executionId,
- status: jobExecutionLogs.status,
- trigger: jobExecutionLogs.trigger,
- startedAt: jobExecutionLogs.startedAt,
- endedAt: jobExecutionLogs.endedAt,
- totalDurationMs: jobExecutionLogs.totalDurationMs,
- })
- .from(jobExecutionLogs)
- .where(eq(jobExecutionLogs.scheduleId, job.id))
- .orderBy(desc(jobExecutionLogs.startedAt))
- .limit(5)
- return execRows.length > 0 ? serializeRecentExecutions(execRows) : null
- })
- }
- }
-
- return jobRows
- .filter((j) => j.status !== 'completed')
- .map((j) => ({
- id: j.id,
- title: j.jobTitle,
- prompt: j.prompt || '',
- cronExpression: j.cronExpression,
- status: j.status,
- lifecycle: j.lifecycle,
- sourceTaskName: j.sourceTaskName,
- }))
- } catch (err) {
- logger.warn('Failed to materialize jobs', {
- workspaceId,
- error: toError(err).message,
- })
- return []
- }
- }
-
private async materializeRecentlyDeleted(workspaceId: string, userId: string): Promise {
try {
const [
diff --git a/apps/sim/lib/mothership/inbox/response.ts b/apps/sim/lib/mothership/inbox/response.ts
index fbd04227574..9f76951cf8c 100644
--- a/apps/sim/lib/mothership/inbox/response.ts
+++ b/apps/sim/lib/mothership/inbox/response.ts
@@ -70,7 +70,7 @@ const emailStyles = {
fontSize: '15px',
lineHeight: '25px',
color: '#1a1a1a',
- fontWeight: 430,
+ fontWeight: 400,
},
content: {
margin: 0,
@@ -105,7 +105,7 @@ const markdownStyles = {
lineHeight: '25px',
color: '#1a1a1a',
fontFamily: FONT_FAMILY,
- fontWeight: 430,
+ fontWeight: 400,
},
h1: {
fontWeight: 600,
diff --git a/apps/sim/lib/posthog/events.ts b/apps/sim/lib/posthog/events.ts
index aa520209e87..48462b7be1b 100644
--- a/apps/sim/lib/posthog/events.ts
+++ b/apps/sim/lib/posthog/events.ts
@@ -596,11 +596,6 @@ export interface PostHogEventMap {
connected_provider_count: number
}
- suggested_actions_shuffled: {
- workspace_id: string
- connected_provider_count: number
- }
-
suggested_actions_toggled: {
workspace_id: string
expanded: boolean
@@ -672,14 +667,6 @@ export interface PostHogEventMap {
workspace_id: string
}
- scheduled_task_created: {
- workspace_id: string
- }
-
- scheduled_task_deleted: {
- workspace_id: string
- }
-
workspace_logo_uploaded: {
workspace_id: string
file_name: string
diff --git a/apps/sim/lib/workflows/references/operations.test.ts b/apps/sim/lib/workflows/references/operations.test.ts
deleted file mode 100644
index 02ad33e67c9..00000000000
--- a/apps/sim/lib/workflows/references/operations.test.ts
+++ /dev/null
@@ -1,309 +0,0 @@
-/**
- * @vitest-environment node
- */
-import { describe, expect, it } from 'vitest'
-import {
- type CustomBlockLink,
- type ReferenceBlockRow,
- resolveWorkflowReferences,
- type WorkflowNode,
-} from '@/lib/workflows/references/operations'
-
-const workflows: WorkflowNode[] = [
- { id: 'a', name: 'A' },
- { id: 'b', name: 'B' },
- { id: 'c', name: 'C' },
- { id: 'd', name: 'D' },
-]
-
-function workflowBlock(
- parentId: string,
- childId: string,
- mode: 'basic' | 'manual' = 'basic',
- type: 'workflow' | 'workflow_input' = 'workflow'
-): ReferenceBlockRow {
- return {
- parentId,
- type,
- childFromSelector: mode === 'basic' ? childId : null,
- childFromManual: mode === 'manual' ? childId : null,
- canonicalModes: null,
- toolInputValues: null,
- }
-}
-
-describe('resolveWorkflowReferences', () => {
- it('resolves direct callers and callees', () => {
- const blocks = [workflowBlock('a', 'b'), workflowBlock('a', 'c')]
- const { callers, callees } = resolveWorkflowReferences('a', workflows, blocks, [])
-
- expect(callers).toEqual([])
- expect(callees.map((n) => n.id)).toEqual(['b', 'c'])
-
- const bResult = resolveWorkflowReferences('b', workflows, blocks, [])
- expect(bResult.callers.map((n) => n.id)).toEqual(['a'])
- expect(bResult.callees).toEqual([])
- })
-
- it('resolves references made through workflow_input blocks', () => {
- const blocks = [
- workflowBlock('a', 'b', 'basic', 'workflow_input'),
- workflowBlock('b', 'c', 'basic', 'workflow_input'),
- ]
- const { callees } = resolveWorkflowReferences('a', workflows, blocks, [])
- expect(callees.map((n) => n.id)).toEqual(['b'])
- expect(callees[0].children.map((n) => n.id)).toEqual(['c'])
-
- const cResult = resolveWorkflowReferences('c', workflows, blocks, [])
- expect(cResult.callers.map((n) => n.id)).toEqual(['b'])
- expect(cResult.callers[0].children.map((n) => n.id)).toEqual(['a'])
- })
-
- it('resolves the advanced-mode manualWorkflowId value', () => {
- const blocks = [workflowBlock('a', 'b', 'manual')]
- const { callees } = resolveWorkflowReferences('a', workflows, blocks, [])
- expect(callees.map((n) => n.id)).toEqual(['b'])
- })
-
- it('uses the active mode, not a retained inactive value', () => {
- // Advanced mode active (canonicalModes override), but a stale basic value
- // (`b`) lingers. Must resolve to the advanced value (`c`), not the stale basic.
- const blocks: ReferenceBlockRow[] = [
- {
- parentId: 'a',
- type: 'workflow',
- childFromSelector: 'b',
- childFromManual: 'c',
- canonicalModes: { workflowId: 'advanced' },
- toolInputValues: null,
- },
- ]
- const { callees } = resolveWorkflowReferences('a', workflows, blocks, [])
- expect(callees.map((n) => n.id)).toEqual(['c'])
- })
-
- it('marks cycles as leaves and stops recursing', () => {
- const blocks = [workflowBlock('a', 'b'), workflowBlock('b', 'a')]
- const { callees } = resolveWorkflowReferences('a', workflows, blocks, [])
-
- expect(callees).toHaveLength(1)
- expect(callees[0]).toMatchObject({ id: 'b', cycle: false })
- expect(callees[0].children).toHaveLength(1)
- expect(callees[0].children[0]).toMatchObject({ id: 'a', cycle: true, children: [] })
- })
-
- it('shows a self-reference as a cycle leaf', () => {
- // A → A: the reference is real and belongs in the cycle-safe viewer.
- const blocks = [workflowBlock('a', 'a')]
- const { callers, callees } = resolveWorkflowReferences('a', workflows, blocks, [])
- expect(callees).toEqual([{ id: 'a', name: 'A', cycle: true, children: [] }])
- expect(callers).toEqual([{ id: 'a', name: 'A', cycle: true, children: [] }])
- })
-
- it('bounds converging paths (diamond) instead of re-expanding', () => {
- // A → B, A → C, B → D, C → D, D → E. D reconverges; it must appear under both
- // B and C but expand its subtree (E) only under the first-visited branch.
- const workflowsWithE = [...workflows, { id: 'e', name: 'E' }]
- const blocks = [
- workflowBlock('a', 'b'),
- workflowBlock('a', 'c'),
- workflowBlock('b', 'd'),
- workflowBlock('c', 'd'),
- workflowBlock('d', 'e'),
- ]
- const { callees } = resolveWorkflowReferences('a', workflowsWithE, blocks, [])
- const b = callees.find((n) => n.id === 'b')
- const c = callees.find((n) => n.id === 'c')
- // D expands under the first-visited branch (B) and is a collapsed leaf under C.
- expect(b?.children).toEqual([
- {
- id: 'd',
- name: 'D',
- cycle: false,
- children: [{ id: 'e', name: 'E', cycle: false, children: [] }],
- },
- ])
- expect(c?.children).toEqual([{ id: 'd', name: 'D', cycle: false, children: [] }])
- })
-
- it('truncates expansion at the depth ceiling', () => {
- // A linear chain longer than MAX_REFERENCE_DEPTH (25): w0 → w1 → … → w29.
- const chain = Array.from({ length: 30 }, (_, i) => ({ id: `w${i}`, name: `W${i}` }))
- const blocks = Array.from({ length: 29 }, (_, i) => workflowBlock(`w${i}`, `w${i + 1}`))
- const { callees } = resolveWorkflowReferences('w0', chain, blocks, [])
- let depth = 0
- let node = callees[0]
- while (node) {
- depth += 1
- node = node.children[0]
- }
- expect(depth).toBe(25)
- })
-
- it('re-expands a depth-truncated node when a shallower path reaches it', () => {
- // Root fans out to a 25-deep chain (visited first by name sort: "A…") whose
- // tail X gets truncated at the ceiling, and a direct edge (via "Z") to X.
- // The shallow path must still show X's child Y instead of a collapsed leaf.
- const nodes = [
- { id: 'root', name: 'Root' },
- ...Array.from({ length: 24 }, (_, i) => ({
- id: `a${i}`,
- name: `A${String(i).padStart(2, '0')}`,
- })),
- { id: 'x', name: 'X' },
- { id: 'y', name: 'Y' },
- { id: 'z', name: 'Z' },
- ]
- const blocks = [
- workflowBlock('root', 'a0'),
- ...Array.from({ length: 23 }, (_, i) => workflowBlock(`a${i}`, `a${i + 1}`)),
- workflowBlock('a23', 'x'),
- workflowBlock('root', 'z'),
- workflowBlock('z', 'x'),
- workflowBlock('x', 'y'),
- ]
- const { callees } = resolveWorkflowReferences('root', nodes, blocks, [])
- const z = callees.find((n) => n.id === 'z')
- const xUnderZ = z?.children.find((n) => n.id === 'x')
- expect(xUnderZ?.children.map((n) => n.id)).toEqual(['y'])
- })
-
- it('drops dangling / out-of-workspace child ids', () => {
- const blocks = [workflowBlock('a', 'missing')]
- const { callees } = resolveWorkflowReferences('a', workflows, blocks, [])
- expect(callees).toEqual([])
- })
-
- it('resolves references made through custom blocks', () => {
- const blocks: ReferenceBlockRow[] = [
- {
- parentId: 'd',
- type: 'custom_block_x',
- childFromSelector: null,
- childFromManual: null,
- canonicalModes: null,
- toolInputValues: null,
- },
- ]
- const customBlocks: CustomBlockLink[] = [{ type: 'custom_block_x', workflowId: 'c' }]
-
- const cResult = resolveWorkflowReferences('c', workflows, blocks, customBlocks)
- expect(cResult.callers.map((n) => n.id)).toEqual(['d'])
-
- const dResult = resolveWorkflowReferences('d', workflows, blocks, customBlocks)
- expect(dResult.callees.map((n) => n.id)).toEqual(['c'])
- })
-
- it('ignores custom blocks with no bound source in scope', () => {
- const blocks: ReferenceBlockRow[] = [
- {
- parentId: 'd',
- type: 'custom_block_unknown',
- childFromSelector: null,
- childFromManual: null,
- canonicalModes: null,
- toolInputValues: null,
- },
- ]
- const { callees } = resolveWorkflowReferences('d', workflows, blocks, [])
- expect(callees).toEqual([])
- })
-
- it('returns empty trees when the workflow is not a workspace node', () => {
- const blocks = [workflowBlock('a', 'b')]
- const result = resolveWorkflowReferences('unknown', workflows, blocks, [])
- expect(result).toEqual({ callers: [], callees: [] })
- })
-
- it('sorts children by name', () => {
- // Names: B, C — insert in reverse to prove sorting.
- const blocks = [workflowBlock('a', 'c'), workflowBlock('a', 'b')]
- const { callees } = resolveWorkflowReferences('a', workflows, blocks, [])
- expect(callees.map((n) => n.name)).toEqual(['B', 'C'])
- })
-
- it('resolves workflow tools inside tool-input sub-blocks', () => {
- // Agent block on A carrying a workflow_input tool that calls B; a non-workflow
- // tool and a malformed entry must be ignored.
- const blocks: ReferenceBlockRow[] = [
- {
- parentId: 'a',
- type: 'agent',
- childFromSelector: null,
- childFromManual: null,
- canonicalModes: null,
- toolInputValues: [
- [
- { type: 'workflow_input', params: { workflowId: 'b' } },
- { type: 'function', params: {} },
- { type: 'workflow_input' },
- ],
- ],
- },
- ]
- const { callees } = resolveWorkflowReferences('a', workflows, blocks, [])
- expect(callees.map((n) => n.id)).toEqual(['b'])
-
- const bResult = resolveWorkflowReferences('b', workflows, blocks, [])
- expect(bResult.callers.map((n) => n.id)).toEqual(['a'])
- })
-
- it('resolves a workflow tool to its active advanced-mode value', () => {
- // Tool 0 is toggled to advanced via the index-scoped canonicalModes key; the
- // stale basic selector (`b`) must not mask the live manual value (`c`).
- const blocks: ReferenceBlockRow[] = [
- {
- parentId: 'a',
- type: 'agent',
- childFromSelector: null,
- childFromManual: null,
- canonicalModes: { '0:workflowId': 'advanced' },
- toolInputValues: [
- [{ type: 'workflow_input', params: { workflowId: 'b', manualWorkflowId: 'c' } }],
- ],
- },
- ]
- const { callees } = resolveWorkflowReferences('a', workflows, blocks, [])
- expect(callees.map((n) => n.id)).toEqual(['c'])
- })
-
- it('resolves legacy workflow-typed tools and isolates index-scoped modes per tool', () => {
- // Tool 0 is a legacy `workflow`-typed entry (still rendered/executed by the
- // editor); tool 1 is advanced-mode via its own index-scoped key. Tool 0 must
- // stay basic (`b`) — tool 1's override must not bleed into it.
- const blocks: ReferenceBlockRow[] = [
- {
- parentId: 'a',
- type: 'agent',
- childFromSelector: null,
- childFromManual: null,
- canonicalModes: { '1:workflowId': 'advanced' },
- toolInputValues: [
- [
- { type: 'workflow', params: { workflowId: 'b' } },
- { type: 'workflow_input', params: { workflowId: 'c', manualWorkflowId: 'd' } },
- ],
- ],
- },
- ]
- const { callees } = resolveWorkflowReferences('a', workflows, blocks, [])
- expect(callees.map((n) => n.id)).toEqual(['b', 'd'])
- })
-
- it('resolves workflow tools from a JSON-stringified tool-input value', () => {
- const blocks: ReferenceBlockRow[] = [
- {
- parentId: 'a',
- type: 'agent',
- childFromSelector: null,
- childFromManual: null,
- canonicalModes: null,
- toolInputValues: [
- JSON.stringify([{ type: 'workflow_input', params: { workflowId: 'c' } }]),
- ],
- },
- ]
- const { callees } = resolveWorkflowReferences('a', workflows, blocks, [])
- expect(callees.map((n) => n.id)).toEqual(['c'])
- })
-})
diff --git a/apps/sim/lib/workflows/references/operations.ts b/apps/sim/lib/workflows/references/operations.ts
deleted file mode 100644
index 768539b286d..00000000000
--- a/apps/sim/lib/workflows/references/operations.ts
+++ /dev/null
@@ -1,327 +0,0 @@
-import { db } from '@sim/db'
-import { workflow, workflowBlocks } from '@sim/db/schema'
-import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm'
-import type { ReferenceNode } from '@/lib/api/contracts/workflow-references'
-import { MAX_CALL_CHAIN_DEPTH } from '@/lib/execution/call-chain'
-import { getCustomBlockRowsForWorkspace } from '@/lib/workflows/custom-blocks/operations'
-import { coerceObjectArray, isRecord } from '@/lib/workflows/persistence/remap-internal-ids'
-import {
- type CanonicalGroup,
- type CanonicalModeOverrides,
- resolveActiveCanonicalValue,
- scopeCanonicalModesForTool,
-} from '@/lib/workflows/subblocks/visibility'
-import { CUSTOM_BLOCK_TYPE_PREFIX } from '@/blocks/custom/build-config'
-import { BlockType, isWorkflowBlockType } from '@/executor/constants'
-
-/**
- * Depth ceiling for a reference tree — the runtime call-chain bound; a display
- * tree never needs to show more than the executor allows. The per-path visited
- * set is the real cycle guard; this is a belt-and-suspenders bound on
- * pathological graphs.
- */
-const MAX_REFERENCE_DEPTH = MAX_CALL_CHAIN_DEPTH
-
-/**
- * The `workflowId` canonical pair on a workflow / workflow_input block: the basic
- * `workflowId` selector and the advanced `manualWorkflowId` input. Used with
- * {@link resolveActiveCanonicalValue} so the reference resolves to the value of the
- * block's ACTIVE mode — never a dormant mode's retained (stale) value.
- * (`remapWorkflowReferencesInSubBlocks` in
- * `@/lib/workflows/persistence/remap-internal-ids` builds the same pair from
- * positional keys — keep the two in sync if the pair ever changes.)
- */
-const WORKFLOW_ID_CANONICAL_GROUP: CanonicalGroup = {
- canonicalId: 'workflowId',
- basicId: 'workflowId',
- advancedIds: ['manualWorkflowId'],
-}
-
-/** `custom_block_` with LIKE wildcards (`_`) escaped, for the SQL prefix match. */
-const CUSTOM_BLOCK_LIKE_PREFIX = CUSTOM_BLOCK_TYPE_PREFIX.replace(/[\\%_]/g, '\\$&')
-
-/** A workspace-local, non-archived workflow node. */
-export interface WorkflowNode {
- id: string
- name: string
-}
-
-/** A placed block that may reference another workflow (raw, pre-resolution). */
-export interface ReferenceBlockRow {
- parentId: string
- type: string
- /** `workflowId` sub-block value (basic mode), if a workflow block. */
- childFromSelector: string | null
- /** `manualWorkflowId` sub-block value (advanced mode), if a workflow block. */
- childFromManual: string | null
- /**
- * The block's `data.canonicalModes` override (basic/advanced per canonical id),
- * used to pick the active `workflowId` value. Absent for most blocks.
- */
- canonicalModes: CanonicalModeOverrides | null
- /**
- * Aggregated `tool-input` sub-block values on this block (agent-style tool
- * lists). Each entry is one sub-block's raw value — an array of tool objects,
- * or that array JSON-stringified. `workflow_input` tools carry the callee id
- * in `params.workflowId` (basic) or `params.manualWorkflowId` (advanced).
- * Null when the block has no tool-input sub-blocks.
- */
- toolInputValues: unknown[] | null
-}
-
-/** A custom-block type slug bound to its source workflow. */
-export interface CustomBlockLink {
- type: string
- workflowId: string
-}
-
-/**
- * The workspace-wide reference graph derived from live editor state: every
- * workflow node's name, plus forward (callee) and reverse (caller) adjacency.
- */
-interface ReferenceGraph {
- nameById: Map
- forward: Map>
- reverse: Map>
-}
-
-/**
- * Callee workflow ids referenced by a block's tool-input values: workflow tools
- * (`workflow_input`, plus legacy stored entries typed `workflow`) resolved to
- * their ACTIVE canonical member — the basic `params.workflowId` selector or the
- * advanced `params.manualWorkflowId` input, per the tool's index-scoped
- * `canonicalModes` override ({@link scopeCanonicalModesForTool}) — mirroring how
- * execution picks the live value.
- */
-function toolInputCallees(
- toolInputValues: unknown[] | null,
- canonicalModes: CanonicalModeOverrides | null
-): string[] {
- if (!toolInputValues) return []
- const callees: string[] = []
- for (const value of toolInputValues) {
- const { array } = coerceObjectArray(value)
- if (!array) continue
- array.forEach((tool, toolIndex) => {
- if (
- !isRecord(tool) ||
- typeof tool.type !== 'string' ||
- !isWorkflowBlockType(tool.type) ||
- !isRecord(tool.params)
- ) {
- return
- }
- const scoped = scopeCanonicalModesForTool(canonicalModes ?? undefined, toolIndex, tool.type)
- const active = resolveActiveCanonicalValue(
- WORKFLOW_ID_CANONICAL_GROUP,
- {
- workflowId: typeof tool.params.workflowId === 'string' ? tool.params.workflowId : null,
- manualWorkflowId:
- typeof tool.params.manualWorkflowId === 'string' ? tool.params.manualWorkflowId : null,
- },
- scoped
- )
- if (typeof active === 'string' && active) callees.push(active)
- })
- }
- return callees
-}
-
-/**
- * Build the directed reference graph from raw workspace rows. Pure (no I/O) so it
- * can be unit-tested directly. Resolves three call-edge shapes:
- * - direct **workflow blocks** (`workflow` and `workflow_input`), whose child id
- * is the `workflowId` (basic) or `manualWorkflowId` (advanced) sub-block value;
- * - **custom blocks** (`custom_block_`), whose type slug maps to a bound
- * source workflow via `customBlocks`; and
- * - **workflow tools** — `workflow_input` entries inside a block's `tool-input`
- * sub-blocks (an agent invoking another workflow as a tool).
- *
- * Non-call reference shapes (the logs block's `workflowSelector` monitor list and
- * the workspace-event trigger's `workflowIds`; see
- * `remapWorkflowReferencesInSubBlocks`) are deliberately excluded — the viewer
- * shows call relationships.
- *
- * The workflow-block child is the value of the block's ACTIVE mode
- * ({@link resolveActiveCanonicalValue}), so a dormant basic/advanced value can't
- * mask the live one. Edges are scoped to workspace-local, non-archived workflows;
- * empty values and ids outside `workflows` are dropped. A workflow that calls
- * itself is kept — the tree builder renders it as a `cycle` leaf.
- */
-function buildReferenceGraph(
- workflows: WorkflowNode[],
- blocks: ReferenceBlockRow[],
- customBlocks: CustomBlockLink[]
-): ReferenceGraph {
- const nameById = new Map()
- for (const node of workflows) nameById.set(node.id, node.name)
-
- const sourceByCustomType = new Map()
- for (const link of customBlocks) sourceByCustomType.set(link.type, link.workflowId)
-
- const forward = new Map>()
- const reverse = new Map>()
-
- const addEdge = (parentId: string, childId: string) => {
- if (!childId) return
- if (!nameById.has(parentId) || !nameById.has(childId)) return
- let callees = forward.get(parentId)
- if (!callees) forward.set(parentId, (callees = new Set()))
- callees.add(childId)
- let callers = reverse.get(childId)
- if (!callers) reverse.set(childId, (callers = new Set()))
- callers.add(parentId)
- }
-
- for (const block of blocks) {
- if (isWorkflowBlockType(block.type)) {
- const active = resolveActiveCanonicalValue(
- WORKFLOW_ID_CANONICAL_GROUP,
- { workflowId: block.childFromSelector, manualWorkflowId: block.childFromManual },
- block.canonicalModes ?? undefined
- )
- if (typeof active === 'string' && active) addEdge(block.parentId, active)
- } else {
- const sourceId = sourceByCustomType.get(block.type)
- if (sourceId) addEdge(block.parentId, sourceId)
- }
- for (const calleeId of toolInputCallees(block.toolInputValues, block.canonicalModes)) {
- addEdge(block.parentId, calleeId)
- }
- }
-
- return { nameById, forward, reverse }
-}
-
-/**
- * Expand a direction of the graph into a tree rooted at `rootId`. `adjacency` is
- * either the forward (callees) or reverse (callers) map.
- *
- * A node already on the current DFS path is emitted as a `cycle: true` leaf and
- * not re-expanded, so `A → B → A` (and a self-call `A → A`) terminates. A node
- * already fully expanded elsewhere in this tree (reachable via another acyclic
- * path — a diamond) is emitted once more as a plain leaf without re-expanding its
- * subtree: the edge stays visible, but a densely reconverging graph can't blow up
- * exponentially. Children are sorted by name for stable rendering.
- */
-function buildTree(
- rootId: string,
- adjacency: Map>,
- nameById: Map
-): ReferenceNode[] {
- const path = new Set([rootId])
- const expanded = new Set()
- const nameOf = (id: string) => nameById.get(id) as string
-
- const expand = (id: string, depth: number): ReferenceNode[] => {
- if (depth >= MAX_REFERENCE_DEPTH) return []
- const neighbors = adjacency.get(id)
- if (!neighbors || neighbors.size === 0) return []
-
- const sorted = [...neighbors].sort((a, b) => nameOf(a).localeCompare(nameOf(b)))
-
- const nodes: ReferenceNode[] = []
- for (const childId of sorted) {
- const name = nameOf(childId)
- if (path.has(childId)) {
- nodes.push({ id: childId, name, cycle: true, children: [] })
- continue
- }
- if (expanded.has(childId)) {
- nodes.push({ id: childId, name, cycle: false, children: [] })
- continue
- }
- expanded.add(childId)
- path.add(childId)
- nodes.push({ id: childId, name, cycle: false, children: expand(childId, depth + 1) })
- path.delete(childId)
- // A depth-capped expansion is incomplete — allow a shallower path to retry
- // it in full instead of collapsing to a leaf. Each retry starts strictly
- // shallower, so this stays bounded.
- if (depth + 1 >= MAX_REFERENCE_DEPTH) expanded.delete(childId)
- }
- return nodes
- }
-
- return expand(rootId, 0)
-}
-
-/**
- * Resolve the reference trees for one workflow from raw workspace rows. Pure so it
- * can be unit-tested without a database. Returns empty arrays when the workflow is
- * not a workspace-local node or has no references.
- */
-export function resolveWorkflowReferences(
- workflowId: string,
- workflows: WorkflowNode[],
- blocks: ReferenceBlockRow[],
- customBlocks: CustomBlockLink[]
-): { callers: ReferenceNode[]; callees: ReferenceNode[] } {
- const { nameById, forward, reverse } = buildReferenceGraph(workflows, blocks, customBlocks)
-
- if (!nameById.has(workflowId)) {
- return { callers: [], callees: [] }
- }
-
- return {
- callers: buildTree(workflowId, reverse, nameById),
- callees: buildTree(workflowId, forward, nameById),
- }
-}
-
-/**
- * Resolve the reference trees for one workflow: `callers` (workflows that call
- * it, inbound) and `callees` (workflows it calls, outbound), read from the live
- * `workflowBlocks` (draft) table — the state the sidebar and editor show. The
- * root itself is not a node; each array holds its direct references, recursively
- * expanded.
- */
-export async function getWorkflowReferences(
- workspaceId: string,
- workflowId: string
-): Promise<{ callers: ReferenceNode[]; callees: ReferenceNode[] }> {
- const hasToolInput = sql`EXISTS (
- SELECT 1 FROM jsonb_each(${workflowBlocks.subBlocks}) AS kv
- WHERE kv.value ->> 'type' = 'tool-input'
- )`
-
- const [workflowRows, blockRows, customBlockRows] = await Promise.all([
- db
- .select({ id: workflow.id, name: workflow.name })
- .from(workflow)
- .where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt))),
- db
- .select({
- parentId: workflowBlocks.workflowId,
- type: workflowBlocks.type,
- childFromSelector: sql<
- string | null
- >`${workflowBlocks.subBlocks} -> 'workflowId' ->> 'value'`,
- childFromManual: sql<
- string | null
- >`${workflowBlocks.subBlocks} -> 'manualWorkflowId' ->> 'value'`,
- canonicalModes: sql`${workflowBlocks.data} -> 'canonicalModes'`,
- toolInputValues: sql`(
- SELECT jsonb_agg(kv.value -> 'value')
- FROM jsonb_each(${workflowBlocks.subBlocks}) AS kv
- WHERE kv.value ->> 'type' = 'tool-input'
- )`,
- })
- .from(workflowBlocks)
- .innerJoin(workflow, eq(workflow.id, workflowBlocks.workflowId))
- .where(
- and(
- eq(workflow.workspaceId, workspaceId),
- isNull(workflow.archivedAt),
- or(
- inArray(workflowBlocks.type, [BlockType.WORKFLOW, BlockType.WORKFLOW_INPUT]),
- sql`${workflowBlocks.type} LIKE ${`${CUSTOM_BLOCK_LIKE_PREFIX}%`} ESCAPE '\\'`,
- hasToolInput
- )
- )
- ),
- getCustomBlockRowsForWorkspace(workspaceId),
- ])
-
- return resolveWorkflowReferences(workflowId, workflowRows, blockRows, customBlockRows)
-}
diff --git a/apps/sim/lib/workflows/schedules/disable-notifications.test.ts b/apps/sim/lib/workflows/schedules/disable-notifications.test.ts
index 020105c8ccb..156087025af 100644
--- a/apps/sim/lib/workflows/schedules/disable-notifications.test.ts
+++ b/apps/sim/lib/workflows/schedules/disable-notifications.test.ts
@@ -106,33 +106,6 @@ describe('notifyScheduleAutoDisabled', () => {
)
})
- it('resolves a job schedule through sourceUserId and links to scheduled tasks', async () => {
- queueTableRows(schemaMock.workflowSchedule, [
- {
- ...WORKFLOW_SCHEDULE_ROW,
- sourceType: 'job',
- jobTitle: 'Weekly report',
- sourceUserId: 'job-owner',
- sourceWorkspaceId: 'ws-9',
- workflowName: null,
- workflowUserId: null,
- workflowWorkspaceId: null,
- },
- ])
- queueTableRows(schemaMock.user, [CREATOR])
-
- await notifyScheduleAutoDisabled({ scheduleId: 's-1', reason: 'authentication_error' })
-
- expect(renderMock).toHaveBeenCalledWith(
- expect.objectContaining({
- kind: 'job',
- resourceName: 'Weekly report',
- reason: 'authentication_error',
- manageLink: 'https://app.sim.ai/workspace/ws-9/scheduled-tasks',
- })
- )
- })
-
it('falls back to the creator alone when the workflow has no workspace', async () => {
queueTableRows(schemaMock.workflowSchedule, [
{ ...WORKFLOW_SCHEDULE_ROW, workflowWorkspaceId: null },
diff --git a/apps/sim/lib/workflows/schedules/disable-notifications.ts b/apps/sim/lib/workflows/schedules/disable-notifications.ts
index 2bebe8bfd80..833f54a4026 100644
--- a/apps/sim/lib/workflows/schedules/disable-notifications.ts
+++ b/apps/sim/lib/workflows/schedules/disable-notifications.ts
@@ -38,11 +38,7 @@ export async function notifyScheduleAutoDisabled(params: {
try {
const rows = await db
.select({
- sourceType: workflowSchedule.sourceType,
- jobTitle: workflowSchedule.jobTitle,
failedCount: workflowSchedule.failedCount,
- sourceUserId: workflowSchedule.sourceUserId,
- sourceWorkspaceId: workflowSchedule.sourceWorkspaceId,
workflowId: workflowSchedule.workflowId,
workflowName: workflow.name,
workflowUserId: workflow.userId,
@@ -59,11 +55,9 @@ export async function notifyScheduleAutoDisabled(params: {
return
}
- const isJob = row.sourceType === 'job'
- const kind = isJob ? 'job' : 'workflow'
- const ownerUserId = isJob ? row.sourceUserId : row.workflowUserId
- const workspaceId = isJob ? row.sourceWorkspaceId : row.workflowWorkspaceId
- const resourceName = (isJob ? row.jobTitle : row.workflowName) ?? undefined
+ const ownerUserId = row.workflowUserId
+ const workspaceId = row.workflowWorkspaceId
+ const resourceName = row.workflowName ?? undefined
const recipients = await resolveRecipients(ownerUserId, workspaceId)
if (recipients.length === 0) {
@@ -75,14 +69,13 @@ export async function notifyScheduleAutoDisabled(params: {
return
}
- const manageLink = buildManageLink(workspaceId, isJob ? null : row.workflowId)
+ const manageLink = buildManageLink(workspaceId, row.workflowId)
const subject = getEmailSubject('schedule-disabled')
for (const recipient of recipients) {
try {
const html = await renderScheduleDisabledEmail({
recipientName: recipient.name ?? undefined,
- kind,
resourceName,
reason,
failedCount: row.failedCount,
@@ -169,7 +162,6 @@ function buildManageLink(
workspaceId: string | null,
workflowId: string | null
): string | undefined {
- if (!workspaceId) return undefined
- const base = `${getBaseUrl()}/workspace/${workspaceId}`
- return workflowId ? `${base}/w/${workflowId}` : `${base}/scheduled-tasks`
+ if (!workspaceId || !workflowId) return undefined
+ return `${getBaseUrl()}/workspace/${workspaceId}/w/${workflowId}`
}
diff --git a/apps/sim/lib/workflows/schedules/orchestration.test.ts b/apps/sim/lib/workflows/schedules/orchestration.test.ts
deleted file mode 100644
index 771cdd5840c..00000000000
--- a/apps/sim/lib/workflows/schedules/orchestration.test.ts
+++ /dev/null
@@ -1,133 +0,0 @@
-/**
- * @vitest-environment node
- */
-import {
- dbChainMock,
- dbChainMockFns,
- queueTableRows,
- resetDbChainMock,
- schemaMock,
-} from '@sim/testing'
-import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
-
-const { mockRecordAudit, mockCaptureServerEvent } = vi.hoisted(() => ({
- mockRecordAudit: vi.fn(),
- mockCaptureServerEvent: vi.fn(),
-}))
-
-vi.mock('@sim/audit', () => ({
- AuditAction: { SCHEDULE_UPDATED: 'SCHEDULE_UPDATED' },
- AuditResourceType: { SCHEDULE: 'SCHEDULE' },
- recordAudit: mockRecordAudit,
-}))
-
-vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock }))
-
-vi.mock('@/lib/posthog/server', () => ({
- captureServerEvent: mockCaptureServerEvent,
-}))
-
-import { performUpdateJob } from '@/lib/workflows/schedules/orchestration'
-
-const BASE_JOB = {
- id: 'job-1',
- sourceWorkspaceId: 'workspace-1',
- sourceUserId: 'user-1',
- sourceType: 'job',
- archivedAt: null,
- timezone: 'UTC',
- cronExpression: null,
- jobTitle: 'Nightly task',
- status: 'disabled',
- secretScope: 'all',
- mountedSecrets: [],
-}
-
-describe('performUpdateJob', () => {
- beforeEach(() => {
- vi.clearAllMocks()
- resetDbChainMock()
- })
-
- afterAll(() => {
- resetDbChainMock()
- })
-
- it('does not schedule a next run when editing time on a disabled job', async () => {
- queueTableRows(schemaMock.workflowSchedule, [{ ...BASE_JOB, status: 'disabled' }])
-
- const result = await performUpdateJob({
- jobId: 'job-1',
- workspaceId: 'workspace-1',
- userId: 'user-1',
- time: '2099-01-01T09:00:00Z',
- })
-
- expect(result.success).toBe(true)
- expect(dbChainMockFns.set).toHaveBeenCalledTimes(1)
- expect(dbChainMockFns.set.mock.calls[0][0]).not.toHaveProperty('nextRunAt')
- })
-
- it('schedules the next run when editing time on an active job', async () => {
- queueTableRows(schemaMock.workflowSchedule, [{ ...BASE_JOB, status: 'active' }])
-
- const result = await performUpdateJob({
- jobId: 'job-1',
- workspaceId: 'workspace-1',
- userId: 'user-1',
- time: '2099-01-01T09:00:00Z',
- })
-
- expect(result.success).toBe(true)
- expect(dbChainMockFns.set).toHaveBeenCalledTimes(1)
- expect(dbChainMockFns.set.mock.calls[0][0]).toMatchObject({
- nextRunAt: new Date('2099-01-01T09:00:00Z'),
- })
- })
-
- it('denies task content edits from a non-creator without writing', async () => {
- queueTableRows(schemaMock.workflowSchedule, [BASE_JOB])
-
- const result = await performUpdateJob({
- jobId: 'job-1',
- workspaceId: 'workspace-1',
- userId: 'workspace-writer',
- prompt: 'Changed prompt',
- })
-
- expect(result).toMatchObject({ success: false, errorCode: 'forbidden' })
- expect(dbChainMockFns.set).not.toHaveBeenCalled()
- })
-
- it('allows a non-creator to pause a task', async () => {
- queueTableRows(schemaMock.workflowSchedule, [{ ...BASE_JOB, status: 'active' }])
-
- const result = await performUpdateJob({
- jobId: 'job-1',
- workspaceId: 'workspace-1',
- userId: 'workspace-writer',
- status: 'paused',
- })
-
- expect(result.success).toBe(true)
- expect(dbChainMockFns.set.mock.calls[0][0]).toMatchObject({ status: 'disabled' })
- })
-
- it('persists a canonical selected secret policy for the creator', async () => {
- queueTableRows(schemaMock.workflowSchedule, [BASE_JOB])
-
- const result = await performUpdateJob({
- jobId: 'job-1',
- workspaceId: 'workspace-1',
- userId: 'user-1',
- secretScope: 'selected',
- mountedSecrets: [' B ', 'A', 'B'],
- })
-
- expect(result.success).toBe(true)
- expect(dbChainMockFns.set.mock.calls[0][0]).toMatchObject({
- secretScope: 'selected',
- mountedSecrets: ['B', 'A'],
- })
- })
-})
diff --git a/apps/sim/lib/workflows/schedules/orchestration.ts b/apps/sim/lib/workflows/schedules/orchestration.ts
deleted file mode 100644
index 28a49eec405..00000000000
--- a/apps/sim/lib/workflows/schedules/orchestration.ts
+++ /dev/null
@@ -1,600 +0,0 @@
-import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
-import { db, workflowSchedule } from '@sim/db'
-import { createLogger } from '@sim/logger'
-import { toError } from '@sim/utils/errors'
-import { generateId } from '@sim/utils/id'
-import { and, eq, isNull } from 'drizzle-orm'
-import type { NextRequest } from 'next/server'
-import type { ScheduleContext } from '@/lib/api/contracts/schedules'
-import {
- normalizeSecretMountPolicy,
- type SecretMountScope,
-} from '@/lib/copilot/secret-mount-policy'
-import { captureServerEvent } from '@/lib/posthog/server'
-import {
- computeNextRunAt,
- parseCronToHumanReadable,
- validateCronExpression,
-} from '@/lib/workflows/schedules/utils'
-
-const logger = createLogger('ScheduleOrchestration')
-
-type ScheduleErrorCode = 'not_found' | 'forbidden' | 'validation' | 'internal'
-
-interface ActorMetadata {
- actorName?: string | null
- actorEmail?: string | null
- request?: NextRequest
-}
-
-export interface PerformCreateJobParams extends ActorMetadata {
- workspaceId: string
- userId: string
- title?: string | null
- prompt: string
- cronExpression?: string | null
- time?: string | null
- timezone: string
- lifecycle?: 'persistent' | 'until_complete'
- successCondition?: string | null
- maxRuns?: number | null
- startDate?: string | null
- /** Recurrence end on a date (ISO 8601); the schedule completes once its next run would fall after this. */
- endsAt?: string | null
- /** `@`-mentioned resources / `/`-invoked skills captured with the prompt. */
- contexts?: ScheduleContext[] | null
- secretScope?: SecretMountScope
- mountedSecrets?: string[]
- sourceChatId?: string | null
- sourceTaskName?: string | null
-}
-
-export interface PerformScheduleResult {
- success: boolean
- error?: string
- errorCode?: ScheduleErrorCode
- schedule?: typeof workflowSchedule.$inferSelect
- humanReadable?: string
- updatedFields?: string[]
- alreadyCompleted?: boolean
-}
-
-export interface PerformUpdateJobParams extends ActorMetadata {
- jobId: string
- workspaceId: string
- userId: string
- title?: string
- prompt?: string
- cronExpression?: string | null
- time?: string | null
- timezone?: string
- status?: string
- lifecycle?: string
- successCondition?: string | null
- maxRuns?: number | null
- endsAt?: string | null
- contexts?: ScheduleContext[] | null
- secretScope?: SecretMountScope
- mountedSecrets?: string[]
-}
-
-export interface PerformExcludeOccurrenceParams extends ActorMetadata {
- jobId: string
- workspaceId: string
- userId: string
- /** The exact occurrence instant to skip (ISO 8601), as produced by the recurrence. */
- occurrence: string
-}
-
-export interface PerformDeleteJobParams extends ActorMetadata {
- jobId: string
- workspaceId: string
- userId: string
-}
-
-export interface PerformCompleteJobParams extends ActorMetadata {
- jobId: string
- workspaceId: string
- userId: string
-}
-
-const activeJobCondition = (jobId: string, workspaceId: string) =>
- and(
- eq(workflowSchedule.id, jobId),
- eq(workflowSchedule.sourceWorkspaceId, workspaceId),
- eq(workflowSchedule.sourceType, 'job'),
- isNull(workflowSchedule.archivedAt)
- )
-
-function parseOneTimeRun(time: string, timezone: string): Date | null {
- let timeStr = time
- const hasOffset = /[Zz]|[+-]\d{2}(:\d{2})?$/.test(timeStr)
- if (!hasOffset && timezone !== 'UTC') {
- try {
- const formatter = new Intl.DateTimeFormat('en-US', {
- timeZone: timezone,
- timeZoneName: 'shortOffset',
- })
- const parts = formatter.formatToParts(new Date())
- const offsetPart = parts.find((part) => part.type === 'timeZoneName')
- const match = offsetPart?.value.match(/GMT([+-]\d{1,2}(?::\d{2})?)/)
- if (match) {
- const [rawHours, rawMinutes = '00'] = match[1].split(':')
- const sign = rawHours.startsWith('-') ? '-' : '+'
- const hour = Number(rawHours.replace(/^[+-]/, ''))
- if (Number.isFinite(hour)) {
- const offset = `${sign}${String(hour).padStart(2, '0')}:${rawMinutes.padStart(2, '0')}`
- timeStr = `${timeStr}${offset}`
- }
- }
- } catch {}
- }
-
- const parsed = new Date(timeStr)
- return Number.isNaN(parsed.getTime()) ? null : parsed
-}
-
-export async function performCreateJob(
- params: PerformCreateJobParams
-): Promise {
- if (!params.prompt.trim()) {
- return { success: false, error: 'prompt is required', errorCode: 'validation' }
- }
-
- const cronExpression = params.cronExpression || null
- if (!cronExpression && !params.time) {
- return {
- success: false,
- error: 'At least one of cronExpression or time must be provided',
- errorCode: 'validation',
- }
- }
-
- let endsAt: Date | null = null
- if (params.endsAt) {
- const parsedEndsAt = new Date(params.endsAt)
- if (Number.isNaN(parsedEndsAt.getTime())) {
- return {
- success: false,
- error: `Invalid endsAt value: ${params.endsAt}`,
- errorCode: 'validation',
- }
- }
- endsAt = parsedEndsAt
- }
-
- let nextRunAt: Date | null = null
- if (cronExpression) {
- const validation = validateCronExpression(cronExpression, params.timezone)
- if (!validation.isValid) {
- return {
- success: false,
- error: validation.error || 'Invalid cron expression',
- errorCode: 'validation',
- }
- }
- nextRunAt = computeNextRunAt({ cronExpression, timezone: params.timezone, endsAt })
- }
-
- if (params.time) {
- const parsed = parseOneTimeRun(params.time, params.timezone)
- if (!parsed) {
- return {
- success: false,
- error: `Invalid time value: ${params.time}`,
- errorCode: 'validation',
- }
- }
- if (!cronExpression || parsed > new Date()) nextRunAt = parsed
- }
-
- if (params.startDate) {
- const start = new Date(params.startDate)
- if (start > new Date()) nextRunAt = start
- }
-
- if (!nextRunAt) {
- return { success: false, error: 'Could not determine next run time', errorCode: 'validation' }
- }
-
- try {
- const id = generateId()
- const now = new Date()
- const secretMountPolicy = normalizeSecretMountPolicy(params)
- await db.insert(workflowSchedule).values({
- id,
- workflowId: null,
- cronExpression,
- triggerType: 'schedule',
- sourceType: 'job',
- status: 'active',
- timezone: params.timezone,
- nextRunAt,
- createdAt: now,
- updatedAt: now,
- failedCount: 0,
- jobTitle: params.title?.trim() || null,
- prompt: params.prompt.trim(),
- lifecycle: params.lifecycle || 'persistent',
- successCondition: params.successCondition || null,
- maxRuns: params.maxRuns ?? null,
- runCount: 0,
- contexts: params.contexts ?? null,
- excludedDates: null,
- endsAt,
- sourceChatId: params.sourceChatId || null,
- sourceTaskName: params.sourceTaskName || null,
- sourceUserId: params.userId,
- sourceWorkspaceId: params.workspaceId,
- secretScope: secretMountPolicy.secretScope,
- mountedSecrets: secretMountPolicy.mountedSecrets,
- })
-
- const [schedule] = await db
- .select()
- .from(workflowSchedule)
- .where(eq(workflowSchedule.id, id))
- .limit(1)
-
- const humanReadable = cronExpression
- ? parseCronToHumanReadable(cronExpression, params.timezone)
- : `Once at ${nextRunAt.toISOString()}`
-
- recordAudit({
- workspaceId: params.workspaceId,
- actorId: params.userId,
- actorName: params.actorName ?? undefined,
- actorEmail: params.actorEmail ?? undefined,
- action: AuditAction.SCHEDULE_CREATED,
- resourceType: AuditResourceType.SCHEDULE,
- resourceId: id,
- resourceName: params.title?.trim() || undefined,
- description: `Created job schedule "${params.title?.trim() || id}"`,
- metadata: {
- cronExpression,
- timezone: params.timezone,
- lifecycle: params.lifecycle || 'persistent',
- maxRuns: params.maxRuns ?? null,
- },
- request: params.request,
- })
-
- captureServerEvent(
- params.userId,
- 'scheduled_task_created',
- { workspace_id: params.workspaceId },
- { groups: { workspace: params.workspaceId } }
- )
-
- if (schedule?.workflowId) {
- captureServerEvent(
- params.userId,
- 'workflow_schedule_created',
- { workflow_id: schedule.workflowId, workspace_id: params.workspaceId },
- { groups: { workspace: params.workspaceId } }
- )
- }
-
- return { success: true, schedule, humanReadable }
- } catch (error) {
- logger.error('Failed to create job', { error: toError(error).message })
- return { success: false, error: 'Failed to create job', errorCode: 'internal' }
- }
-}
-
-export async function performUpdateJob(
- params: PerformUpdateJobParams
-): Promise {
- try {
- const [job] = await db
- .select()
- .from(workflowSchedule)
- .where(activeJobCondition(params.jobId, params.workspaceId))
- .limit(1)
-
- if (!job)
- return { success: false, error: `Job not found: ${params.jobId}`, errorCode: 'not_found' }
-
- const hasCreatorOnlyUpdate =
- params.title !== undefined ||
- params.prompt !== undefined ||
- params.cronExpression !== undefined ||
- params.time !== undefined ||
- params.timezone !== undefined ||
- params.lifecycle !== undefined ||
- params.successCondition !== undefined ||
- params.maxRuns !== undefined ||
- params.endsAt !== undefined ||
- params.contexts !== undefined ||
- params.secretScope !== undefined ||
- params.mountedSecrets !== undefined
- if (hasCreatorOnlyUpdate && job.sourceUserId !== params.userId) {
- return {
- success: false,
- error: 'Only the task creator can edit this task',
- errorCode: 'forbidden',
- }
- }
-
- const updates: Partial = { updatedAt: new Date() }
- if (params.title !== undefined) updates.jobTitle = params.title.trim()
- if (params.prompt !== undefined) updates.prompt = params.prompt.trim()
- if (params.timezone !== undefined) updates.timezone = params.timezone
- if (params.status !== undefined) {
- if (!['active', 'paused', 'disabled'].includes(params.status)) {
- return {
- success: false,
- error: 'status must be "active" or "paused"',
- errorCode: 'validation',
- }
- }
- updates.status = params.status === 'paused' ? 'disabled' : params.status
- }
- if (params.lifecycle !== undefined) {
- if (params.lifecycle !== 'persistent' && params.lifecycle !== 'until_complete') {
- return {
- success: false,
- error: 'lifecycle must be "persistent" or "until_complete"',
- errorCode: 'validation',
- }
- }
- updates.lifecycle = params.lifecycle
- if (params.lifecycle === 'persistent') updates.maxRuns = null
- }
- if (params.successCondition !== undefined) updates.successCondition = params.successCondition
- if (params.maxRuns !== undefined) updates.maxRuns = params.maxRuns
- if (params.contexts !== undefined) updates.contexts = params.contexts
- if (params.secretScope !== undefined || params.mountedSecrets !== undefined) {
- const secretMountPolicy = normalizeSecretMountPolicy({
- secretScope: params.secretScope ?? job.secretScope,
- mountedSecrets: params.mountedSecrets ?? job.mountedSecrets,
- })
- updates.secretScope = secretMountPolicy.secretScope
- updates.mountedSecrets = secretMountPolicy.mountedSecrets
- }
- const effectiveStatus = updates.status ?? job.status
-
- let endsAt: Date | null = job.endsAt
- if (params.endsAt !== undefined) {
- if (params.endsAt === null) {
- endsAt = null
- } else {
- const parsedEndsAt = new Date(params.endsAt)
- if (Number.isNaN(parsedEndsAt.getTime())) {
- return {
- success: false,
- error: `Invalid endsAt value: ${params.endsAt}`,
- errorCode: 'validation',
- }
- }
- endsAt = parsedEndsAt
- }
- updates.endsAt = endsAt
- }
-
- if (params.cronExpression !== undefined && params.cronExpression !== null) {
- const timezone = params.timezone || job.timezone || 'UTC'
- const validation = validateCronExpression(params.cronExpression, timezone)
- if (!validation.isValid) {
- return {
- success: false,
- error: validation.error || 'Invalid cron expression',
- errorCode: 'validation',
- }
- }
- updates.cronExpression = params.cronExpression
- if (effectiveStatus === 'active') {
- updates.nextRunAt = computeNextRunAt({
- cronExpression: params.cronExpression,
- timezone,
- excludedDates: job.excludedDates,
- endsAt,
- })
- }
- } else if (params.cronExpression === null) {
- updates.cronExpression = null
- }
- if (params.time !== undefined && params.time !== null) {
- const timezone = params.timezone || job.timezone || 'UTC'
- const parsed = parseOneTimeRun(params.time, timezone)
- if (!parsed) {
- return {
- success: false,
- error: `Invalid time value: ${params.time}`,
- errorCode: 'validation',
- }
- }
- const cronExpression =
- params.cronExpression !== undefined ? params.cronExpression : job.cronExpression
- if (effectiveStatus === 'active' && (!cronExpression || parsed > new Date())) {
- updates.nextRunAt = parsed
- }
- }
-
- const updatedFields = Object.keys(updates).filter((key) => key !== 'updatedAt')
-
- await db
- .update(workflowSchedule)
- .set(updates)
- .where(and(eq(workflowSchedule.id, params.jobId), isNull(workflowSchedule.archivedAt)))
-
- recordAudit({
- workspaceId: params.workspaceId,
- actorId: params.userId,
- actorName: params.actorName ?? undefined,
- actorEmail: params.actorEmail ?? undefined,
- action: AuditAction.SCHEDULE_UPDATED,
- resourceType: AuditResourceType.SCHEDULE,
- resourceId: params.jobId,
- resourceName: job.jobTitle ?? undefined,
- description: `Updated job schedule "${job.jobTitle ?? params.jobId}"`,
- metadata: { operation: 'update', updatedFields },
- request: params.request,
- })
-
- return { success: true, updatedFields }
- } catch (error) {
- logger.error('Failed to update job', { error: toError(error).message })
- return { success: false, error: 'Failed to update job', errorCode: 'internal' }
- }
-}
-
-export async function performExcludeOccurrence(
- params: PerformExcludeOccurrenceParams
-): Promise {
- const occurrence = new Date(params.occurrence)
- if (Number.isNaN(occurrence.getTime())) {
- return {
- success: false,
- error: `Invalid occurrence value: ${params.occurrence}`,
- errorCode: 'validation',
- }
- }
-
- try {
- const [job] = await db
- .select()
- .from(workflowSchedule)
- .where(activeJobCondition(params.jobId, params.workspaceId))
- .limit(1)
-
- if (!job)
- return { success: false, error: `Job not found: ${params.jobId}`, errorCode: 'not_found' }
- if (!job.cronExpression) {
- return {
- success: false,
- error: 'Only recurring tasks have individual occurrences to delete',
- errorCode: 'validation',
- }
- }
-
- const occurrenceIso = occurrence.toISOString()
- const excludedDates = Array.from(new Set([...(job.excludedDates ?? []), occurrenceIso]))
-
- const updates: Partial = {
- excludedDates,
- updatedAt: new Date(),
- }
-
- if (job.nextRunAt && job.nextRunAt.getTime() === occurrence.getTime()) {
- const nextRunAt = computeNextRunAt({
- cronExpression: job.cronExpression,
- timezone: job.timezone || 'UTC',
- from: occurrence,
- excludedDates,
- endsAt: job.endsAt,
- })
- updates.nextRunAt = nextRunAt
- if (!nextRunAt) updates.status = 'completed'
- }
-
- await db
- .update(workflowSchedule)
- .set(updates)
- .where(and(eq(workflowSchedule.id, params.jobId), isNull(workflowSchedule.archivedAt)))
-
- recordAudit({
- workspaceId: params.workspaceId,
- actorId: params.userId,
- actorName: params.actorName ?? undefined,
- actorEmail: params.actorEmail ?? undefined,
- action: AuditAction.SCHEDULE_UPDATED,
- resourceType: AuditResourceType.SCHEDULE,
- resourceId: params.jobId,
- resourceName: job.jobTitle ?? undefined,
- description: `Deleted one occurrence of job "${job.jobTitle ?? params.jobId}"`,
- metadata: { operation: 'exclude_occurrence', occurrence: occurrenceIso },
- request: params.request,
- })
-
- return { success: true }
- } catch (error) {
- logger.error('Failed to exclude occurrence', { error: toError(error).message })
- return { success: false, error: 'Failed to delete occurrence', errorCode: 'internal' }
- }
-}
-
-export async function performDeleteJob(
- params: PerformDeleteJobParams
-): Promise {
- const [job] = await db
- .select()
- .from(workflowSchedule)
- .where(activeJobCondition(params.jobId, params.workspaceId))
- .limit(1)
-
- if (!job)
- return { success: false, error: `Job not found: ${params.jobId}`, errorCode: 'not_found' }
-
- await db.delete(workflowSchedule).where(eq(workflowSchedule.id, params.jobId))
- recordAudit({
- workspaceId: params.workspaceId,
- actorId: params.userId,
- actorName: params.actorName ?? undefined,
- actorEmail: params.actorEmail ?? undefined,
- action: AuditAction.SCHEDULE_DELETED,
- resourceType: AuditResourceType.SCHEDULE,
- resourceId: params.jobId,
- resourceName: job.jobTitle ?? undefined,
- description: `Deleted job "${job.jobTitle ?? params.jobId}"`,
- metadata: {
- sourceType: job.sourceType,
- cronExpression: job.cronExpression,
- timezone: job.timezone,
- },
- request: params.request,
- })
-
- captureServerEvent(
- params.userId,
- 'scheduled_task_deleted',
- { workspace_id: params.workspaceId },
- { groups: { workspace: params.workspaceId } }
- )
-
- if (job.workflowId) {
- captureServerEvent(
- params.userId,
- 'workflow_schedule_deleted',
- { workflow_id: job.workflowId, workspace_id: params.workspaceId },
- { groups: { workspace: params.workspaceId } }
- )
- }
-
- return { success: true, schedule: job }
-}
-
-export async function performCompleteJob(
- params: PerformCompleteJobParams
-): Promise {
- const [job] = await db
- .select()
- .from(workflowSchedule)
- .where(activeJobCondition(params.jobId, params.workspaceId))
- .limit(1)
-
- if (!job)
- return { success: false, error: `Job not found: ${params.jobId}`, errorCode: 'not_found' }
- if (job.status === 'completed') return { success: true, schedule: job, alreadyCompleted: true }
-
- const [updatedJob] = await db
- .update(workflowSchedule)
- .set({ status: 'completed', nextRunAt: null, updatedAt: new Date() })
- .where(and(eq(workflowSchedule.id, params.jobId), isNull(workflowSchedule.archivedAt)))
- .returning()
-
- recordAudit({
- workspaceId: params.workspaceId,
- actorId: params.userId,
- actorName: params.actorName ?? undefined,
- actorEmail: params.actorEmail ?? undefined,
- action: AuditAction.SCHEDULE_UPDATED,
- resourceType: AuditResourceType.SCHEDULE,
- resourceId: params.jobId,
- description: 'Completed job',
- metadata: { operation: 'complete' },
- request: params.request,
- })
-
- return { success: true, schedule: updatedJob, alreadyCompleted: false }
-}
diff --git a/apps/sim/lib/workflows/schedules/utils.ts b/apps/sim/lib/workflows/schedules/utils.ts
index 53f7bf01ab4..ab30c80bb27 100644
--- a/apps/sim/lib/workflows/schedules/utils.ts
+++ b/apps/sim/lib/workflows/schedules/utils.ts
@@ -53,45 +53,6 @@ export function validateCronExpression(
/** Upper bound on how many excluded occurrences the next-run search skips before giving up. */
const MAX_OCCURRENCE_SKIP = 1000
-/**
- * Computes the next run instant for a recurring schedule, skipping occurrences
- * the user deleted individually and stopping at the recurrence end boundary.
- * Returns `null` when the recurrence has no remaining run (past `endsAt`, or
- * every candidate within the search bound is excluded).
- *
- * Excluded occurrences are matched by exact instant, so callers must record the
- * cron-produced occurrence time (not a rounded value) when excluding.
- */
-export function computeNextRunAt(params: {
- cronExpression: string
- timezone?: string
- from?: Date
- excludedDates?: string[] | null
- endsAt?: Date | null
-}): Date | null {
- const { cronExpression, timezone, from, excludedDates, endsAt } = params
- let cron: Cron
- try {
- cron = new Cron(cronExpression, timezone ? { timezone } : undefined)
- } catch {
- return null
- }
-
- const excluded = new Set(
- (excludedDates ?? []).map((iso) => new Date(iso).getTime()).filter((ms) => !Number.isNaN(ms))
- )
-
- let cursor = from ?? new Date()
- for (let i = 0; i < MAX_OCCURRENCE_SKIP; i++) {
- const next = cron.nextRun(cursor)
- if (!next) return null
- if (endsAt && next.getTime() > endsAt.getTime()) return null
- if (!excluded.has(next.getTime())) return next
- cursor = next
- }
- return null
-}
-
interface SubBlockValue {
value: string
}
diff --git a/apps/sim/lib/workspaces/lifecycle.test.ts b/apps/sim/lib/workspaces/lifecycle.test.ts
index 2102e84ae97..e6a4efc0e50 100644
--- a/apps/sim/lib/workspaces/lifecycle.test.ts
+++ b/apps/sim/lib/workspaces/lifecycle.test.ts
@@ -77,7 +77,7 @@ describe('workspace lifecycle', () => {
expect(mockArchiveWorkflowsForWorkspace).toHaveBeenCalledWith('workspace-1', {
requestId: 'req-1',
})
- expect(tx.update).toHaveBeenCalledTimes(10)
+ expect(tx.update).toHaveBeenCalledTimes(9)
expect(tx.delete).toHaveBeenCalledTimes(1)
})
diff --git a/apps/sim/lib/workspaces/lifecycle.ts b/apps/sim/lib/workspaces/lifecycle.ts
index 59162b42682..27b6514e410 100644
--- a/apps/sim/lib/workspaces/lifecycle.ts
+++ b/apps/sim/lib/workspaces/lifecycle.ts
@@ -9,7 +9,6 @@ import {
mcpServers,
userTableDefinitions,
workflowMcpServer,
- workflowSchedule,
workspace,
workspaceFiles,
} from '@sim/db/schema'
@@ -145,23 +144,6 @@ export async function archiveWorkspace(
})
.where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt)))
- await tx
- .update(workflowSchedule)
- .set({
- archivedAt: now,
- updatedAt: now,
- status: 'disabled',
- nextRunAt: null,
- lastQueuedAt: null,
- })
- .where(
- and(
- eq(workflowSchedule.sourceWorkspaceId, workspaceId),
- eq(workflowSchedule.sourceType, 'job'),
- isNull(workflowSchedule.archivedAt)
- )
- )
-
await tx
.update(workspace)
.set({
diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts
index 1ecee9fd736..172bf16d220 100644
--- a/apps/sim/next.config.ts
+++ b/apps/sim/next.config.ts
@@ -33,7 +33,6 @@ const LANDING_ROUTES = [
'models',
'pricing',
'privacy',
- 'scheduled-tasks',
'solutions',
'tables',
'terms',
@@ -476,6 +475,15 @@ const nextConfig: NextConfig = {
}
)
+ // The scheduled-tasks marketing page is retired with the feature. The URL is
+ // indexed, so send it to the surface that still carries scheduled execution
+ // (the workflow Schedule trigger) instead of letting it 404.
+ redirects.push({
+ source: '/scheduled-tasks',
+ destination: '/workflows',
+ permanent: true,
+ })
+
/**
* The marketing Academy course/lesson pages were removed; content is
* consolidated into the docs site instead. Old course/lesson slugs have
diff --git a/apps/sim/package.json b/apps/sim/package.json
index 8f6a6b96078..a2faac62d1f 100644
--- a/apps/sim/package.json
+++ b/apps/sim/package.json
@@ -185,7 +185,6 @@
"jszip": "3.10.1",
"lib0": "0.2.117",
"lru-cache": "11.3.6",
- "lucide-react": "^0.511.0",
"mammoth": "^1.9.0",
"mermaid": "11.15.0",
"micromatch": "4.0.8",
diff --git a/apps/sim/public/static/readme-scheduled-tasks.png b/apps/sim/public/static/readme-scheduled-tasks.png
deleted file mode 100644
index 57eaa363874..00000000000
Binary files a/apps/sim/public/static/readme-scheduled-tasks.png and /dev/null differ
diff --git a/apps/sim/stores/constants.ts b/apps/sim/stores/constants.ts
index c44e4e89181..f04814333e0 100644
--- a/apps/sim/stores/constants.ts
+++ b/apps/sim/stores/constants.ts
@@ -23,8 +23,8 @@ export const CONTENT_WINDOW_GAP = 8
/** Sidebar width constraints */
export const SIDEBAR_WIDTH = {
- DEFAULT: 248,
- MIN: 248,
+ DEFAULT: 238,
+ MIN: 238,
/** Width when sidebar is collapsed to icon-only mode */
COLLAPSED: 51,
/** Maximum is 30% of viewport, enforced dynamically */
diff --git a/apps/sim/stores/modals/search/store.ts b/apps/sim/stores/modals/search/store.ts
index 7084b591044..f21d516ddc4 100644
--- a/apps/sim/stores/modals/search/store.ts
+++ b/apps/sim/stores/modals/search/store.ts
@@ -1,4 +1,4 @@
-import { RepeatIcon, SplitIcon } from 'lucide-react'
+import { Repeat, Split } from '@sim/emcn/icons'
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { getToolOperationsIndex } from '@/lib/search/tool-operations'
@@ -128,14 +128,14 @@ export const useSearchModalStore = create()(
{
id: 'loop',
name: 'Loop',
- icon: RepeatIcon,
+ icon: Repeat,
bgColor: '#2FB3FF',
type: 'loop',
},
{
id: 'parallel',
name: 'Parallel',
- icon: SplitIcon,
+ icon: Split,
bgColor: '#FEE12B',
type: 'parallel',
},
diff --git a/apps/sim/stores/panel/types.ts b/apps/sim/stores/panel/types.ts
index b026fe028e0..42c4a8cfd54 100644
--- a/apps/sim/stores/panel/types.ts
+++ b/apps/sim/stores/panel/types.ts
@@ -77,7 +77,6 @@ export type ChatContext =
}
| { kind: 'folder'; folderId: string; label: string }
| { kind: 'filefolder'; fileFolderId: string; label: string }
- | { kind: 'scheduledtask'; scheduleId: string; label: string }
| { kind: 'docs'; label: string }
/**
* A tab in the desktop browser or terminal panel, dragged into the input to
diff --git a/apps/sim/tailwind.config.ts b/apps/sim/tailwind.config.ts
index 76d8940f930..576f1528e06 100644
--- a/apps/sim/tailwind.config.ts
+++ b/apps/sim/tailwind.config.ts
@@ -66,6 +66,16 @@ export default {
},
spacing: {
'4.5': '18px',
+ /**
+ * Hairline scale key. Overriding `spacing` rather than `width`/`height`
+ * keeps every derived scale in agreement — a `w-px` line and the
+ * `-right-px` offset that positions it resolve to the same value, which
+ * a width-only override desynchronizes by half a device pixel.
+ */
+ px: 'var(--border-width)',
+ },
+ borderWidth: {
+ DEFAULT: 'var(--border-width)',
},
colors: {
background: 'hsl(var(--background))',
@@ -98,7 +108,13 @@ export default {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
- border: 'hsl(var(--border))',
+ /**
+ * Neutral border colors are plain hex, not the HSL triplets the shadcn
+ * keys below use — `hsl(var(--border))` would be invalid CSS and get
+ * dropped, leaving the utility to fall through to the `*` border-color
+ * rule in globals by accident.
+ */
+ border: 'var(--border)',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
chart: {
@@ -122,11 +138,6 @@ export default {
950: '#0a0a0a',
},
},
- fontWeight: {
- base: 'var(--font-weight-base)',
- medium: 'var(--font-weight-medium)',
- semibold: 'var(--font-weight-semibold)',
- },
borderRadius: {
xs: '2px',
sm: 'calc(var(--radius) - 4px)',
@@ -140,8 +151,18 @@ export default {
kbd: 'var(--shadow-kbd)',
'kbd-sm': 'var(--shadow-kbd-sm)',
card: 'var(--shadow-card)',
+ ambient: 'var(--shadow-ambient)',
},
dropShadow: {},
+ maxWidth: {
+ /**
+ * The home/chat reading column. The heading, input, suggested actions,
+ * transcript, and its skeleton must all share one value or the footer
+ * input visibly misaligns with the messages above it — so they read this
+ * key rather than repeating a literal.
+ */
+ chat: '44rem',
+ },
transitionProperty: {
width: 'width',
left: 'left',
diff --git a/bun.lock b/bun.lock
index 6085287fa66..efcbfdcc57a 100644
--- a/bun.lock
+++ b/bun.lock
@@ -75,7 +75,6 @@
"fumadocs-mdx": "14.3.2",
"fumadocs-openapi": "10.8.1",
"fumadocs-ui": "16.8.5",
- "lucide-react": "^0.511.0",
"next": "16.2.12",
"next-themes": "^0.4.6",
"react": "19.2.4",
@@ -291,7 +290,6 @@
"jszip": "3.10.1",
"lib0": "0.2.117",
"lru-cache": "11.3.6",
- "lucide-react": "^0.511.0",
"mammoth": "^1.9.0",
"mermaid": "11.15.0",
"micromatch": "4.0.8",
@@ -489,7 +487,6 @@
"class-variance-authority": "^0.7.1",
"framer-motion": "^12.5.0",
"input-otp": "^1.4.2",
- "lucide-react": "^0.511.0",
"next": "16.2.12",
"prismjs": "^1.30.0",
"react": "19.2.4",
@@ -514,7 +511,6 @@
"class-variance-authority": "^0.7.1",
"framer-motion": "^12.5.0",
"input-otp": "^1.4.2",
- "lucide-react": ">=0.479.0",
"next": ">=15",
"prismjs": "^1.30.0",
"react": "^19",
@@ -663,7 +659,6 @@
"@sim/tsconfig": "workspace:*",
"@sim/utils": "workspace:*",
"@types/react": "^19",
- "lucide-react": "^0.511.0",
"react": "19.2.4",
"reactflow": "^11.11.4",
"remark-breaks": "^4.0.0",
@@ -673,7 +668,6 @@
"peerDependencies": {
"@sim/emcn": "workspace:*",
"@sim/utils": "workspace:*",
- "lucide-react": ">=0.479.0",
"react": "^19",
"reactflow": "^11.11.4",
"remark-breaks": "^4.0.0",
diff --git a/packages/emcn/package.json b/packages/emcn/package.json
index 6089d9afbe7..55455c98a66 100644
--- a/packages/emcn/package.json
+++ b/packages/emcn/package.json
@@ -56,7 +56,6 @@
"class-variance-authority": "^0.7.1",
"framer-motion": "^12.5.0",
"input-otp": "^1.4.2",
- "lucide-react": ">=0.479.0",
"next": ">=15",
"prismjs": "^1.30.0",
"react": "^19",
@@ -83,7 +82,6 @@
"class-variance-authority": "^0.7.1",
"framer-motion": "^12.5.0",
"input-otp": "^1.4.2",
- "lucide-react": "^0.511.0",
"next": "16.2.12",
"prismjs": "^1.30.0",
"react": "19.2.4",
diff --git a/packages/emcn/src/AGENTS.md b/packages/emcn/src/AGENTS.md
index ebbfb1ad49a..8f8497c5913 100644
--- a/packages/emcn/src/AGENTS.md
+++ b/packages/emcn/src/AGENTS.md
@@ -1,6 +1,6 @@
# EMCN Components Scope
-These rules apply to `apps/sim/components/emcn/**`.
+These rules apply to `packages/emcn/**`.
- Import from `@sim/emcn`, never from subpaths except CSS files.
- Use Radix UI primitives for accessibility where applicable.
diff --git a/packages/emcn/src/components/button/button.tsx b/packages/emcn/src/components/button/button.tsx
index 80eebd30722..e1916bda1e4 100644
--- a/packages/emcn/src/components/button/button.tsx
+++ b/packages/emcn/src/components/button/button.tsx
@@ -2,6 +2,24 @@ import { type ButtonHTMLAttributes, forwardRef } from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '../../lib/cn'
+/**
+ * `size='icon'` is the square 20px icon-only button — a chip field's trailing
+ * affordance, a toast dismiss, a section-header action. It drops the text padding
+ * the `sm`/`md` sizes carry and tightens the radius to `rounded-sm` (4px), which
+ * reads correctly at this size where the base 5px does not. The box is deliberately
+ * larger than every glyph it holds; that margin IS the button's padding, since the
+ * glyph is sized at the call site rather than here.
+ *
+ * Glyphs also draw one step thinner than the 1.55 the icon set ships, so a lone
+ * icon reads as a secondary affordance rather than a piece of UI text. CSS wins
+ * over the SVG's own `stroke-width` attribute, so this reaches every stroked icon
+ * without touching the icon components — including the few that ship at 2.
+ *
+ * Compose it with `quiet` (the usual choice) or `ghost` (where the surrounding
+ * surface owns the hover) — those pairings also pick up the muted icon color.
+ *
+ * @example
+ */
const buttonVariants = cva(
'inline-flex items-center justify-center font-medium transition-colors disabled:pointer-events-none disabled:opacity-70 outline-none focus:outline-none focus-visible:outline-none rounded-[5px]',
{
@@ -30,8 +48,19 @@ const buttonVariants = cva(
size: {
sm: 'px-1.5 py-1 text-[length:11px]',
md: 'px-2 py-1.5 text-[length:12px]',
+ icon: 'size-[20px] rounded-sm p-0 [&_svg]:[stroke-width:1.25]',
},
},
+ compoundVariants: [
+ /**
+ * A lone glyph is icon content, not text, so the neutral icon buttons paint
+ * with `--text-icon-muted` rather than the variant's text color. Scoped to
+ * the neutral variants: the filled ones (`primary`, `destructive`, …) carry
+ * inverse text that must keep winning over their own surface.
+ */
+ { size: 'icon', variant: 'quiet', className: 'text-[var(--text-icon-muted)]' },
+ { size: 'icon', variant: 'ghost', className: 'text-[var(--text-icon-muted)]' },
+ ],
defaultVariants: {
variant: 'default',
size: 'md',
diff --git a/packages/emcn/src/components/calendar/calendar-day-cell.tsx b/packages/emcn/src/components/calendar/calendar-day-cell.tsx
index 33f070af65c..e3c49796c15 100644
--- a/packages/emcn/src/components/calendar/calendar-day-cell.tsx
+++ b/packages/emcn/src/components/calendar/calendar-day-cell.tsx
@@ -45,7 +45,6 @@ export const CalendarDayCell = forwardRefTime
-
+