From fc14cbeaca3011036b4e9a4ff525a32c6a809a82 Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Wed, 10 Jun 2026 17:16:31 -0700 Subject: [PATCH 1/4] improvement(resource): simplify table shell, toasts, and loading breadcrumbs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Resource.Table: remove internal sorting (defaultSort/sortValues) and the emptyMessage state — rows render in the order given, chrome always paints - Resource: root is now the positioning context for overlays; consumers (files, tables, knowledge, document) wrap detail views in instead of hand-rolled divs - ResourceHeader: root titles no longer truncate during initial layout; LocationFocusVeil gates the portal on mount to fix a hydration mismatch - Toasts: drop the StackDismiss ring and stack countdown — each toast runs its own timer; remove the Mod+E clear-notifications command; align toast typography and icons with chip chrome - Breadcrumbs: use the canonical '…' placeholder while names load - incident.io: fix display name and catalog slug (with redirect) - Add dev:capped / dev:full:capped scripts with a 4GB heap cap --- .../docs/en/integrations/incidentio.mdx | 2 +- .../resource-header/resource-header.tsx | 32 ++- .../components/resource/resource.tsx | 129 ++++-------- .../workspace/[workspaceId]/files/files.tsx | 33 +-- .../knowledge/[id]/[documentId]/document.tsx | 54 ++--- .../knowledge/[id]/[documentId]/loading.tsx | 2 +- .../[workspaceId]/knowledge/[id]/base.tsx | 15 +- .../[workspaceId]/knowledge/[id]/loading.tsx | 2 +- .../workspace/[workspaceId]/logs/loading.tsx | 3 +- .../app/workspace/[workspaceId]/logs/logs.tsx | 1 - .../workspace-permissions-provider.tsx | 16 -- .../[workspaceId]/tables/[tableId]/table.tsx | 6 +- .../[workspaceId]/utils/commands-utils.ts | 6 - apps/sim/blocks/blocks/incidentio.ts | 2 +- .../emcn/components/toast/toast.tsx | 199 +++--------------- .../tools/handlers/platform-actions.ts | 1 - apps/sim/lib/integrations/integrations.json | 4 +- apps/sim/next.config.ts | 9 + apps/sim/package.json | 1 + package.json | 1 + 20 files changed, 161 insertions(+), 357 deletions(-) diff --git a/apps/docs/content/docs/en/integrations/incidentio.mdx b/apps/docs/content/docs/en/integrations/incidentio.mdx index 43c1bf81c60..919f8cf00f0 100644 --- a/apps/docs/content/docs/en/integrations/incidentio.mdx +++ b/apps/docs/content/docs/en/integrations/incidentio.mdx @@ -1,5 +1,5 @@ --- -title: incidentio +title: incident.io description: Manage incidents with incident.io --- 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 375e1c1dada..6ee9bb054cb 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 @@ -175,17 +175,21 @@ export const ResourceHeader = memo(function ResourceHeader({ ) }) ) : ( - + /** + * Root titles are short static labels ("Tables", "Files"), so the + * span is non-shrinkable and the label never truncates — matching + * the `shrink-0` guarantee the breadcrumb root crumb gets from + * {@link getBreadcrumbSegmentClassName}. Without this, the + * `flex-1` left column collapses during transient initial-load + * layout (the JS-driven `--sidebar-width` settling) and the title + * CSS-truncates to "T…" while the `shrink-0` actions hold width. + */ + {TitleIcon && } {titleLabel && ( )} @@ -485,6 +489,18 @@ function LocationFocusVeil({ boundaryRef: React.RefObject }) { const [bounds, setBounds] = useState({ top: 0, left: 0 }) + /** + * Portal-mount gate. The veil must render `null` on BOTH the server render + * and the first client (hydration) render — branching on + * `typeof document === 'undefined'` made the two renders diverge, which + * failed hydration and forced React to regenerate the whole page tree on + * the client (a visible header flash during load). + */ + const [mounted, setMounted] = useState(false) + + useEffect(() => { + setMounted(true) + }, []) useEffect(() => { if (!visible) return @@ -507,7 +523,7 @@ function LocationFocusVeil({ } }, [boundaryRef, visible]) - if (typeof document === 'undefined') return null + if (!mounted) return null return createPortal(
- sortValues?: Record } export interface SelectableConfig { @@ -108,12 +104,19 @@ interface ResourceProps { * - `Resource.Options` — required, the search/filter/sort toolbar * - `Resource.Table` — optional; swap for any custom body (dashboard, grid, …) * - * The shell owns the fixed column layout; the children own their own chrome. + * Invariant: the shell renders identically for every consumer. Consumers supply + * content (columns, rows, cells) and behavior (handlers, configs) only — no + * prop changes the shell's chrome, spacing, or structure. The only sanctioned + * variation is replacing `Resource.Table` with a custom body. + * + * The shell owns the fixed column layout and is the positioning context for + * absolutely-positioned overlays (action bars, slide-out sidebars); the + * children own their own chrome. */ function ResourceRoot({ children, onContextMenu }: ResourceProps) { return (
{children} @@ -124,7 +127,12 @@ function ResourceRoot({ children, onContextMenu }: ResourceProps) { interface ResourceTableProps { columns: ResourceColumn[] rows: ResourceRow[] - defaultSort?: string + /** + * Declares that row ordering is managed externally (by the consumer, surfaced + * through `Resource.Options`'s Sort chip). The table never sorts rows itself — + * `rows` render in the order given — so this prop has no rendering effect; it + * exists to document sort ownership at the callsite. + */ sort?: SortConfig selectedRowId?: string | null selectable?: SelectableConfig @@ -132,44 +140,51 @@ interface ResourceTableProps { onRowClick?: (rowId: string) => void onRowHover?: (rowId: string) => void onRowContextMenu?: (e: React.MouseEvent, rowId: string) => void + /** + * Reserved. Loading no longer alters rendering — the column headers always + * paint and an empty `rows` array renders an empty body, so navigations never + * flash placeholder chrome. Kept for API stability and future semantics (e.g. + * aria-busy) so consumers can keep threading their query state. + */ isLoading?: boolean onLoadMore?: () => void hasMore?: boolean isLoadingMore?: boolean pagination?: PaginationConfig - emptyMessage?: string + /** + * Sanctioned overlay slot. Rendered absolutely against the table region + * (action bars, slide-out sidebars, drop targets). The overlay owns its own + * chrome and positioning; it never alters the table's rendering. + */ overlay?: ReactNode } /** * Data table body, module-private and exposed only as `Resource.Table` — the * compound member is the sole way consumers render it. + * + * Chrome guarantee: the ``, ``, and column headers render + * unconditionally — no prop or row state (empty, loading, error) ever drops + * them. Structural additions (checkbox column, load-more sentinel, pagination + * bar) are driven purely by which configs the consumer supplies and always + * render the canonical chrome. */ const ResourceTable = memo(function ResourceTable({ columns, rows, - defaultSort, - sort: externalSort, selectedRowId, selectable, rowDragDrop, onRowClick, onRowHover, onRowContextMenu, - isLoading, onLoadMore, hasMore, isLoadingMore, pagination, - emptyMessage, overlay, }: ResourceTableProps) { const loadMoreRef = useRef(null) - const sortEnabled = defaultSort != null - const [internalSort, setInternalSort] = useState<{ column: string; direction: 'asc' | 'desc' }>({ - column: defaultSort ?? '', - direction: 'desc', - }) const [contextMenuRowId, setContextMenuRowId] = useState(null) @@ -201,24 +216,6 @@ const ResourceTable = memo(function ResourceTable({ } }, [contextMenuRowId]) - const handleSort = useCallback((column: string, direction: 'asc' | 'desc') => { - setInternalSort({ column, direction }) - }, []) - - const displayRows = useMemo(() => { - if (!sortEnabled || externalSort) return rows - return [...rows].sort((a, b) => { - const col = internalSort.column - const aVal = a.sortValues?.[col] ?? a.cells[col]?.label ?? '' - const bVal = b.sortValues?.[col] ?? b.cells[col]?.label ?? '' - const cmp = - typeof aVal === 'number' && typeof bVal === 'number' - ? aVal - bVal - : String(aVal).localeCompare(String(bVal)) - return internalSort.direction === 'asc' ? -cmp : cmp - }) - }, [rows, internalSort, sortEnabled, externalSort]) - useEffect(() => { if (!onLoadMore || !hasMore) return const el = loadMoreRef.current @@ -242,22 +239,9 @@ const ResourceTable = memo(function ResourceTable({ [selectable] ) - /** - * While loading, the table chrome (column headers) renders with an empty body - * and the rows "just load in" — never a skeleton, and never a false - * empty-state (the empty message is gated on `!isLoading`). - */ - if (!isLoading && rows.length === 0 && emptyMessage) { - return ( -
- {emptyMessage} -
- ) - } - return (
-
+
@@ -273,41 +257,18 @@ const ResourceTable = memo(function ResourceTable({ /> )} - {columns.map((col) => { - if (!sortEnabled) { - return ( - - ) - } - const isActive = internalSort.column === col.id - const SortIcon = internalSort.direction === 'asc' ? ArrowUp : ArrowDown - return ( - - ) - })} + {columns.map((col) => ( + + ))} - {displayRows.map((row) => ( + {rows.map((row) => ( m.userId === folder.userId)?.name ?? '', - }, })) const fileRows = filteredFiles.map((file) => { @@ -467,14 +459,6 @@ export function Files() { owner: ownerCell(file.uploadedBy, members), updated: timeCell(file.updatedAt), }, - sortValues: { - name: file.name, - size: file.size, - type: formatFileType(file.type, file.name), - created: new Date(file.uploadedAt).getTime(), - updated: new Date(file.updatedAt).getTime(), - owner: members?.find((m) => m.userId === file.uploadedBy)?.name ?? '', - }, } return row }) @@ -1559,7 +1543,7 @@ export function Files() { }, [router, workspaceId]) const loadingBreadcrumbs = useMemo( - () => [{ label: 'Files', onClick: handleNavigateToFiles }, { label: '...' }], + () => [{ label: 'Files', onClick: handleNavigateToFiles }, { label: '…' }], [handleNavigateToFiles] ) @@ -1678,12 +1662,6 @@ export function Files() { const hasActiveFilters = typeFilter.length > 0 || sizeFilter.length > 0 || uploadedByFilter.length > 0 - const emptyMessage = debouncedSearchTerm - ? `No files match "${debouncedSearchTerm}"` - : hasActiveFilters - ? 'No files match the active filters' - : undefined - const filterContent = useMemo(() => { const typeDisplayLabel = typeFilter.length === 0 @@ -1835,19 +1813,19 @@ export function Files() { if (fileIdFromRoute && !selectedFile && isLoading) { return ( -
+
-
+ ) } if (selectedFile) { return ( <> -
+ -
+ [ { label: 'Knowledge Base', icon: Database, onClick: handleNavToKB }, { - label: effectiveKnowledgeBaseName, + label: knowledgeBaseCrumbLabel, icon: Database, onClick: handleNavToKBDetail, }, - { label: effectiveDocumentName, icon: DocumentIcon, onClick: handleBackAttempt }, + { label: documentCrumbLabel, icon: DocumentIcon, onClick: handleBackAttempt }, ], [ handleNavToKB, handleNavToKBDetail, - effectiveKnowledgeBaseName, - effectiveDocumentName, + knowledgeBaseCrumbLabel, + documentCrumbLabel, DocumentIcon, handleBackAttempt, ] @@ -978,18 +983,18 @@ export function Document({ () => [ { label: 'Knowledge Base', icon: Database, onClick: handleNavToKB }, { - label: effectiveKnowledgeBaseName, + label: knowledgeBaseCrumbLabel, icon: Database, onClick: handleNavToKBDetail, }, - { label: effectiveDocumentName, icon: DocumentIcon, onClick: handleClearSelectedChunk }, - { label: 'Loading...', terminal: true }, + { label: documentCrumbLabel, icon: DocumentIcon, onClick: handleClearSelectedChunk }, + { label: '…', terminal: true }, ], [ handleNavToKB, handleNavToKBDetail, - effectiveKnowledgeBaseName, - effectiveDocumentName, + knowledgeBaseCrumbLabel, + documentCrumbLabel, DocumentIcon, handleClearSelectedChunk, ] @@ -1056,7 +1061,7 @@ export function Document({ if (isCreatingNewChunk && documentData) { return ( <> -
+ -
+ +
Loading chunk…
- +
) } return ( <> -
+ -
+ diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/loading.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/loading.tsx index 0bdb64ee9a0..14d6fc5fb81 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/loading.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/loading.tsx @@ -22,7 +22,7 @@ const ACTIONS: ChromeActionSpec[] = [{ text: 'New chunk', icon: Plus, variant: ' const BREADCRUMBS: BreadcrumbItem[] = [ { label: 'Knowledge Base', icon: Database, onClick: noop }, - { label: '…' }, + { label: '…', icon: Database }, { label: '…', terminal: true }, ] diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index f0bbc811cec..3d04d7efe22 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -371,6 +371,12 @@ export function KnowledgeBase({ }, [hasSyncingConnectors, refreshKnowledgeBase, refreshDocuments]) const knowledgeBaseName = knowledgeBase?.name || passedKnowledgeBaseName || 'Knowledge Base' + /** + * Breadcrumb leaf label. Falls back to the canonical '…' placeholder while + * the name loads (mirroring loading.tsx) instead of duplicating the root + * "Knowledge Base" crumb. + */ + const knowledgeBaseCrumbLabel = knowledgeBase?.name || passedKnowledgeBaseName || '…' const error = knowledgeBaseError || documentsError const totalPages = Math.ceil(pagination.total / pagination.limit) @@ -803,7 +809,7 @@ export function KnowledgeBase({ onClick: () => router.push(`/workspace/${workspaceId}/knowledge`), }, { - label: knowledgeBaseName, + label: knowledgeBaseCrumbLabel, icon: Database, editing: kbRename.editingId ? { @@ -1116,12 +1122,6 @@ export function KnowledgeBase({ [documents, tagDefinitions, searchQuery] ) - const emptyMessage = searchQuery - ? 'No documents found' - : enabledFilter !== 'all' || activeTagFilters.length > 0 - ? 'Nothing matches your filter' - : undefined - if (error && !knowledgeBase) { return (
@@ -1182,7 +1182,6 @@ export function KnowledgeBase({ totalPages, onPageChange: (page) => setCurrentPage(page), }} - emptyMessage={emptyMessage} overlay={ 1 ? 'bottom-[72px]' : undefined} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/loading.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/loading.tsx index b1ea25f26b5..0a79d94c593 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/loading.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/loading.tsx @@ -27,7 +27,7 @@ const ACTIONS: ChromeActionSpec[] = [ const BREADCRUMBS: BreadcrumbItem[] = [ { label: 'Knowledge Base', icon: Database, onClick: noop }, - { label: '…', terminal: true }, + { label: '…', icon: Database, terminal: true }, ] export default function KnowledgeBaseLoading() { diff --git a/apps/sim/app/workspace/[workspaceId]/logs/loading.tsx b/apps/sim/app/workspace/[workspaceId]/logs/loading.tsx index 518c280d2cf..c885da5666a 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/loading.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/loading.tsx @@ -1,6 +1,6 @@ 'use client' -import { Bell, Library, RefreshCw } from '@/components/emcn' +import { Library, RefreshCw } from '@/components/emcn' import { Download } from '@/components/emcn/icons' import { type ChromeActionSpec, @@ -18,7 +18,6 @@ const COLUMNS = [ const ACTIONS: ChromeActionSpec[] = [ { text: 'Export', icon: Download }, - { text: 'Notifications', icon: Bell }, { text: 'Refresh', icon: RefreshCw }, { text: 'Logs', active: true }, { text: 'Dashboard' }, diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 90967d51ee2..13898e56f85 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -1137,7 +1137,6 @@ export default function Logs() { onLoadMore={loadMoreLogs} hasMore={logsQuery.hasNextPage ?? false} isLoadingMore={logsQuery.isFetchingNextPage} - emptyMessage='No logs found' overlay={sidebarOverlay} /> )} diff --git a/apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx b/apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx index 5423cae324a..6849c91cbae 100644 --- a/apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx +++ b/apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx @@ -6,8 +6,6 @@ import { createLogger } from '@sim/logger' import { useQueryClient } from '@tanstack/react-query' import { useParams } from 'next/navigation' import { useToast } from '@/components/emcn' -import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' -import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils' import { useSocket } from '@/app/workspace/providers/socket-provider' import { useWorkspacePermissionsQuery, @@ -106,20 +104,6 @@ export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsP return clearRealtimeStatusNotification }, [clearRealtimeStatusNotification]) - useRegisterGlobalCommands(() => - createCommands([ - { - id: 'clear-notifications', - handler: () => { - toast.dismissAll() - }, - overrides: { - allowInEditable: false, - }, - }, - ]) - ) - useEffect(() => { if (!isOfflineMode || hasShownOfflineNotification) { return diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index 57a68a8485f..3619358f6c5 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -420,7 +420,7 @@ export function Table({ () => [ { label: 'Tables', onClick: handleNavigateBack }, { - label: tableData?.name ?? '', + label: tableData?.name ?? '…', editing: tableHeaderRename.editingId ? { isEditing: true, @@ -525,7 +525,7 @@ export function Table({ const { data: executionLog } = useLogByExecutionId(workspaceId, executionId) return ( -
+ {!embedded && ( )} -
+ ) } diff --git a/apps/sim/app/workspace/[workspaceId]/utils/commands-utils.ts b/apps/sim/app/workspace/[workspaceId]/utils/commands-utils.ts index 3c2ac2a63bc..24a6eead3ad 100644 --- a/apps/sim/app/workspace/[workspaceId]/utils/commands-utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/utils/commands-utils.ts @@ -16,7 +16,6 @@ export type CommandId = | 'run-workflow' | 'clear-terminal-console' | 'focus-toolbar-search' - | 'clear-notifications' | 'fit-to-view' /** @@ -88,11 +87,6 @@ export const COMMAND_DEFINITIONS: Record = { shortcut: 'Mod+Alt+F', allowInEditable: false, }, - 'clear-notifications': { - id: 'clear-notifications', - shortcut: 'Mod+E', - allowInEditable: false, - }, 'fit-to-view': { id: 'fit-to-view', shortcut: 'Mod+Shift+F', diff --git a/apps/sim/blocks/blocks/incidentio.ts b/apps/sim/blocks/blocks/incidentio.ts index ffd0d3d3aef..e096eb36e4b 100644 --- a/apps/sim/blocks/blocks/incidentio.ts +++ b/apps/sim/blocks/blocks/incidentio.ts @@ -5,7 +5,7 @@ import type { IncidentioResponse } from '@/tools/incidentio/types' export const IncidentioBlock: BlockConfig = { type: 'incidentio', - name: 'incidentio', + name: 'incident.io', description: 'Manage incidents with incident.io', authMode: AuthMode.ApiKey, longDescription: diff --git a/apps/sim/components/emcn/components/toast/toast.tsx b/apps/sim/components/emcn/components/toast/toast.tsx index 34c218915de..d979c2a99e0 100644 --- a/apps/sim/components/emcn/components/toast/toast.tsx +++ b/apps/sim/components/emcn/components/toast/toast.tsx @@ -14,11 +14,15 @@ import { useState, } from 'react' import { generateId } from '@sim/utils/id' -import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from 'framer-motion' +import { AnimatePresence, motion, useReducedMotion } from 'framer-motion' import { usePathname } from 'next/navigation' import { createPortal } from 'react-dom' import { Button } from '@/components/emcn/components/button/button' import { Chip } from '@/components/emcn/components/chip/chip' +import { + chipContentIconClass, + chipFilledFillTokens, +} from '@/components/emcn/components/chip/chip-chrome' import { Bell } from '@/components/emcn/icons/bell' import { CircleAlert } from '@/components/emcn/icons/circle-alert' import { CircleCheck } from '@/components/emcn/icons/circle-check' @@ -28,10 +32,6 @@ import { X } from '@/components/emcn/icons/x' import { cn } from '@/lib/core/utils/cn' const AUTO_DISMISS_MS = 5000 -/** Whole-stack auto-dismiss window once the dismiss-all control appears (2+ toasts). */ -const STACK_DISMISS_MS = 6000 -/** Toast count at which the dismiss-all control and stack countdown take over. */ -const STACK_DISMISS_THRESHOLD = 2 /** Card width; tracks the workflow-panel inset on narrow viewports. */ const TOAST_WIDTH = 'min(100vw - 2rem, 280px)' @@ -62,7 +62,7 @@ const CONCENTRIC_RADIUS_PX = 16 type ToastVariant = 'default' | 'info' | 'success' | 'warning' | 'error' -/** Leading icon per variant; the shape signals intent, {@link VARIANT_ICON_COLOR} tints it. */ +/** Leading icon per variant; the shape alone signals intent, tinted with the canonical chip icon color. */ const VARIANT_ICON: Record>> = { default: Bell, info: CircleInfo, @@ -71,15 +71,6 @@ const VARIANT_ICON: Record>> error: CircleAlert, } -/** Per-variant icon tint from the shared badge intent palette; `default` stays neutral. */ -const VARIANT_ICON_COLOR: Record = { - default: 'text-[var(--text-icon)]', - info: 'text-[var(--badge-blue-text)]', - success: 'text-[var(--badge-success-text)]', - warning: 'text-[var(--badge-amber-text)]', - error: 'text-[var(--badge-error-text)]', -} - interface ToastAction { label: string onClick: () => void @@ -330,33 +321,20 @@ function ToastItem({ toast: t, geometry, reduceMotion, onDismiss, onMeasure }: T >
-
+
+ + + } - className='font-medium text-[14px] text-[var(--text-primary)] leading-5' + className='text-[var(--text-body)] text-sm leading-5' reduceMotion={reduceMotion} /> - {t.description ? ( - - ) : null}
+ {t.description ? ( + + ) : null} {t.action ? ( {t.action.label} @@ -388,110 +376,6 @@ function ToastItem({ toast: t, geometry, reduceMotion, onDismiss, onMeasure }: T ) } -interface StackDismissProps { - /** When held (stack hovered), the countdown pauses so it can't dismiss mid-read. */ - paused: boolean - /** - * Whether the ring auto-fires. `false` when the stack holds a persistent - * toast, so an actionable toast can't be cleared from under the user; the - * control still dismisses on click. - */ - autoDismiss: boolean - reduceMotion: boolean - /** Changes whenever a new toast arrives; restarts the countdown from zero. */ - resetKey: string - onDismiss: () => void -} - -/** - * Dismiss-all control shown once multiple toasts pile up: a ring fills over - * `STACK_DISMISS_MS` and clears the whole stack, hovering holds it, and clicking - * dismisses immediately. - */ -function StackDismiss({ - paused, - autoDismiss, - reduceMotion, - resetKey, - onDismiss, -}: StackDismissProps) { - const progress = useMotionValue(0) - const onDismissRef = useRef(onDismiss) - const controlsRef = useRef | null>(null) - const [hovered, setHovered] = useState(false) - - const held = paused || hovered - const heldRef = useRef(held) - useEffect(() => { - heldRef.current = held - }, [held]) - - useEffect(() => { - onDismissRef.current = onDismiss - }, [onDismiss]) - - useEffect(() => { - progress.set(0) - if (!autoDismiss) { - controlsRef.current = null - return - } - const controls = animate(progress, 1, { - duration: STACK_DISMISS_MS / 1000, - ease: 'linear', - onComplete: () => onDismissRef.current(), - }) - controlsRef.current = controls - if (heldRef.current) controls.pause() - return () => controls.stop() - }, [progress, resetKey, autoDismiss]) - - useEffect(() => { - const controls = controlsRef.current - if (!controls) return - if (held) controls.pause() - else controls.play() - }, [held]) - - return ( - setHovered(true)} - onMouseLeave={() => setHovered(false)} - onClick={() => onDismissRef.current()} - aria-label='Dismiss all notifications' - initial={reduceMotion ? false : { opacity: 0, scale: 0.5 }} - animate={{ opacity: 1, scale: 1 }} - exit={ - reduceMotion - ? { opacity: 0 } - : { opacity: 0, scale: 0.5, transition: { duration: 0.12, ease: 'easeIn' } } - } - transition={reduceMotion ? { duration: 0 } : { type: 'spring', stiffness: 520, damping: 24 }} - className='pointer-events-auto absolute bottom-[8px] left-[-30px] z-50 flex size-[22px] items-center justify-center rounded-full bg-[var(--bg)] text-[var(--text-icon)] shadow-[var(--shadow-overlay)] transition-colors hover-hover:text-[var(--text-body)]' - > - - - - - - - ) -} - /** * Toast container, mounted once in the root layout. Toasts pile bottom-right as * a collapsed stack that fans open on hover or keyboard focus, mirroring the @@ -513,8 +397,6 @@ export function ToastProvider({ children }: { children?: ReactNode }) { const [heights, setHeights] = useState>({}) const [expanded, setExpanded] = useState(false) const [mounted, setMounted] = useState(false) - /** Monotonic arrival count; changes only on a new toast, so dismissing the front card doesn't restart the stack countdown. */ - const [arrivalCount, setArrivalCount] = useState(0) const timersRef = useRef(new Map>()) useEffect(() => { @@ -555,7 +437,6 @@ export function ToastProvider({ children }: { children?: ReactNode }) { next.splice(evictIndex === -1 ? 0 : evictIndex, 1) return next }) - setArrivalCount((c) => c + 1) return id }, []) @@ -619,22 +500,14 @@ export function ToastProvider({ children }: { children?: ReactNode }) { }, [toasts]) /** - * A persistent toast (`duration <= 0`) pins the stack: the dismiss-all ring's - * auto-countdown is suppressed so the action can't be cleared from under the - * user. The ring still renders and dismisses on click. - */ - const hasPersistentToast = toasts.some((t) => t.duration <= 0) - - /** - * Per-toast auto-dismiss timers. The stack ring owns dismissal when it - * auto-fires (2+ toasts, none persistent); otherwise — a lone toast, or a - * stack pinned by a persistent toast — each timed toast runs its own timer so - * it still expires while the persistent one stays. Hover holds them. + * Per-toast auto-dismiss timers. Each timed toast runs its own timer so it + * expires independently regardless of how many toasts are stacked; persistent + * toasts (`duration <= 0`) never get a timer, and hovering (`expanded`) holds + * every timer so a toast can't be cleared mid-read. */ useEffect(() => { const timers = timersRef.current - const stackAutoDismiss = toasts.length >= STACK_DISMISS_THRESHOLD && !hasPersistentToast - if (toasts.length === 0 || expanded || stackAutoDismiss) { + if (toasts.length === 0 || expanded) { for (const timer of timers.values()) clearTimeout(timer) timers.clear() return @@ -657,7 +530,7 @@ export function ToastProvider({ children }: { children?: ReactNode }) { timers.delete(id) } } - }, [toasts, expanded, hasPersistentToast, dismissToast]) + }, [toasts, expanded, dismissToast]) useEffect(() => { const timers = timersRef.current @@ -735,18 +608,6 @@ export function ToastProvider({ children }: { children?: ReactNode }) { height: containerHeight, }} > - - {toasts.length >= STACK_DISMISS_THRESHOLD ? ( - - ) : null} -
setExpanded(true)} onMouseLeave={() => setExpanded(false)} diff --git a/apps/sim/lib/copilot/tools/handlers/platform-actions.ts b/apps/sim/lib/copilot/tools/handlers/platform-actions.ts index a3e122d214e..a59afaf30d0 100644 --- a/apps/sim/lib/copilot/tools/handlers/platform-actions.ts +++ b/apps/sim/lib/copilot/tools/handlers/platform-actions.ts @@ -40,7 +40,6 @@ export const PLATFORM_ACTIONS_CONTENT = `# Sim Platform Quick Reference & Keyboa | Shortcut | Action | |----------|--------| | Mod+D | Clear terminal console | -| Mod+E | Clear notifications | ### Mouse Controls | Action | Control | diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index b6b6beeaf42..05f5dedfbc1 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -7077,8 +7077,8 @@ }, { "type": "incidentio", - "slug": "incidentio", - "name": "incidentio", + "slug": "incident-io", + "name": "incident.io", "description": "Manage incidents with incident.io", "longDescription": "Integrate incident.io into the workflow. Manage incidents, actions, follow-ups, workflows, schedules, escalations, custom fields, and more.", "bgColor": "#FFFFFF", diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index 4bbcb456b1a..204ada466ee 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -326,6 +326,15 @@ const nextConfig: NextConfig = { permanent: true, }) + // Legacy integration slug: the incident.io block's display name was fixed + // from `incidentio` to `incident.io`, which moved its catalog slug. + // Preserve the previously indexed landing URL. + redirects.push({ + source: '/integrations/incidentio', + destination: '/integrations/incident-io', + permanent: true, + }) + return redirects }, async rewrites() { diff --git a/apps/sim/package.json b/apps/sim/package.json index 31d799eac3c..f103f750b14 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -9,6 +9,7 @@ }, "scripts": { "dev": "next dev --port 3000", + "dev:capped": "NODE_OPTIONS='--max-old-space-size=4096' next dev --port 3000", "dev:clean": "rm -rf .next/dev/cache", "dev:webpack": "next dev --webpack", "load:workflow": "bun run load:workflow:baseline", diff --git a/package.json b/package.json index 0dcaf676f30..71cb6c978a1 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "dev": "turbo run dev", "dev:sockets": "cd apps/realtime && bun run dev", "dev:full": "bunx concurrently -n \"App,Realtime\" -c \"cyan,magenta\" \"cd apps/sim && bun run dev\" \"cd apps/realtime && bun run dev\"", + "dev:full:capped": "bunx concurrently -n \"App,Realtime\" -c \"cyan,magenta\" \"cd apps/sim && bun run dev:capped\" \"cd apps/realtime && bun run dev\"", "test": "turbo run test", "format": "turbo run format", "format:check": "turbo run format:check", From c8dc9d6ecf882a075464ccdb3326c19fb7f8af55 Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Thu, 11 Jun 2026 11:51:45 -0700 Subject: [PATCH 2/4] feat(scheduled-tasks): calendar views; rename Mothership to Sim/Chat Add month/time calendar views for scheduled tasks with toolbar, event chips, and a create-task modal, backed by calendar-grid and schedule-events utils (with tests) and a use-calendar hook. Replace the old schedule-modal/context-menu flow. Rename the "Mothership" agent to "Sim" and the chat surface to "Chat" across landing copy, constitution, block metadata, API error messages, and copilot/data-drain internals. Drop unused workspace route layouts. --- .claude/rules/constitution.md | 8 +- README.md | 16 +- .../components/features/features.tsx | 6 +- .../(landing)/components/footer/footer.tsx | 2 +- .../app/(landing)/components/hero/hero.tsx | 2 +- .../landing-preview-home.tsx | 2 +- .../landing-preview-panel.tsx | 6 +- .../(landing)/components/structured-data.tsx | 8 +- apps/sim/app/api/files/presigned/route.ts | 6 +- apps/sim/app/api/files/upload/route.ts | 2 +- apps/sim/app/api/mothership/execute/route.ts | 12 +- apps/sim/app/llms.txt/route.ts | 2 +- apps/sim/app/playground/page.tsx | 2 +- .../[workspaceId]/components/index.ts | 1 + .../resource-chrome-fallback.tsx | 2 +- .../resource-header/resource-header.tsx | 13 +- .../components/resource/resource.tsx | 15 - .../workspace/[workspaceId]/files/files.tsx | 5 +- .../workspace/[workspaceId]/files/layout.tsx | 3 - .../thinking-block/thinking-block.tsx | 2 +- .../message-content/message-content.tsx | 3 +- .../[workspaceId]/integrations/layout.tsx | 3 - .../knowledge/[id]/[documentId]/document.tsx | 28 +- .../[workspaceId]/knowledge/[id]/base.tsx | 20 +- .../[workspaceId]/knowledge/knowledge.tsx | 4 +- .../[workspaceId]/knowledge/layout.tsx | 3 - .../workflows-list/workflows-list.tsx | 2 +- .../workspace/[workspaceId]/logs/layout.tsx | 3 - .../app/workspace/[workspaceId]/logs/logs.tsx | 1 - .../components/create-schedule-modal/index.ts | 1 - .../create-schedule-modal/schedule-modal.tsx | 528 ------------------ .../create-task-modal/create-task-modal.tsx | 114 ++++ .../components/create-task-modal/index.ts | 1 + .../calendar-event-chip.tsx | 30 + .../components/calendar-event-chip/index.ts | 1 + .../calendar-toolbar/calendar-toolbar.tsx | 72 +++ .../components/calendar-toolbar/index.ts | 1 + .../schedule-calendar/components/index.ts | 4 + .../components/month-grid/index.ts | 1 + .../components/month-grid/month-grid.tsx | 108 ++++ .../components/time-grid/index.ts | 1 + .../components/time-grid/time-grid.tsx | 164 ++++++ .../components/schedule-calendar/index.ts | 1 + .../schedule-calendar/schedule-calendar.tsx | 111 ++++ .../components/schedule-context-menu/index.ts | 1 - .../schedule-context-menu.tsx | 84 --- .../scheduled-tasks/hooks/use-calendar.ts | 78 +++ .../[workspaceId]/scheduled-tasks/layout.tsx | 3 - .../scheduled-tasks/scheduled-tasks.tsx | 449 +-------------- .../utils/calendar-grid.test.ts | 96 ++++ .../scheduled-tasks/utils/calendar-grid.ts | 155 +++++ .../utils/schedule-events.test.ts | 77 +++ .../scheduled-tasks/utils/schedule-events.ts | 73 +++ .../[workspaceId]/settings/[section]/page.tsx | 2 +- .../settings/components/copilot/copilot.tsx | 6 +- .../[workspaceId]/settings/navigation.ts | 2 +- .../workspace/[workspaceId]/skills/layout.tsx | 3 - .../workspace/[workspaceId]/tables/layout.tsx | 3 - .../workspace/[workspaceId]/tables/tables.tsx | 3 +- .../w/[workflowId]/components/panel/panel.tsx | 2 +- .../log-row-context-menu.tsx | 4 +- .../components/output-panel/output-panel.tsx | 4 +- .../components/terminal/terminal.tsx | 4 +- apps/sim/background/schedule-execution.ts | 4 +- apps/sim/blocks/blocks/mothership.ts | 18 +- .../components/access-control.tsx | 2 +- .../components/data-drains-settings.tsx | 4 +- apps/sim/ee/whitelabeling/metadata.ts | 2 +- .../mothership/mothership-handler.test.ts | 4 +- .../handlers/mothership/mothership-handler.ts | 20 +- .../lib/copilot/request/lifecycle/headless.ts | 2 +- .../lib/copilot/request/lifecycle/start.ts | 2 +- apps/sim/lib/copilot/tools/mcp/definitions.ts | 4 +- .../lib/data-drains/sources/copilot-chats.ts | 2 +- .../lib/data-drains/sources/copilot-runs.ts | 2 +- apps/sim/lib/logs/get-trigger-options.ts | 4 +- apps/sim/lib/mothership/inbox/lifecycle.ts | 2 +- apps/sim/lib/mothership/inbox/response.ts | 2 +- apps/sim/public/llms.txt | 2 +- apps/sim/stores/terminal/console/store.ts | 2 +- 80 files changed, 1229 insertions(+), 1218 deletions(-) delete mode 100644 apps/sim/app/workspace/[workspaceId]/files/layout.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/integrations/layout.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/knowledge/layout.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/logs/layout.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/create-schedule-modal/index.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/create-schedule-modal/schedule-modal.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/create-task-modal/create-task-modal.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/create-task-modal/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/calendar-event-chip/calendar-event-chip.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/calendar-event-chip/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/calendar-toolbar/calendar-toolbar.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/calendar-toolbar/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/month-grid/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/month-grid/month-grid.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/time-grid/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/time-grid/time-grid.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/schedule-calendar.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-context-menu/index.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-context-menu/schedule-context-menu.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-calendar.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/layout.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/calendar-grid.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/calendar-grid.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/skills/layout.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/tables/layout.tsx diff --git a/.claude/rules/constitution.md b/.claude/rules/constitution.md index 6881c060ee8..62dd7df3c56 100644 --- a/.claude/rules/constitution.md +++ b/.claude/rules/constitution.md @@ -23,7 +23,8 @@ Sim is the **AI workspace** where teams build and run AI agents. Not a workflow | The product | "AI workspace" | "workflow tool", "automation platform", "agent framework" | | Building | "build agents", "create agents" | "create workflows" (unless describing the workflow module specifically) | | Visual builder | "workflow builder" or "visual builder" | "canvas", "graph editor" | -| Mothership | "Mothership" (capitalized) | "chat", "AI assistant", "copilot" | +| The agent | "Sim" — you talk to Sim | "Mothership", "copilot", "AI assistant" | +| The chat surface | "Chat" (capitalized, the module) | "Mothership", "copilot" | | Deployment | "deploy", "ship" | "publish", "activate" | | Audience | "teams", "builders" | "users", "customers" (in marketing copy) | | What agents do | "automate real work" | "automate tasks", "automate workflows" | @@ -50,7 +51,7 @@ When describing Sim, always lead with the most differentiated claim: | Module | One-liner | |--------|-----------| -| **Mothership** | Your AI command center. Build and manage everything in natural language. | +| **Chat** | Your AI command center. Talk to Sim — build and manage everything in natural language. | | **Workflows** | The visual builder. Connect blocks, models, and integrations into agent logic. | | **Knowledge Base** | Your agents' memory. Upload docs, sync sources, build vector databases. | | **Tables** | A database, built in. Store, query, and wire structured data into agent runs. | @@ -65,7 +66,8 @@ When describing Sim, always lead with the most differentiated claim: - Never promise unshipped features - Never use jargon ("RAG", "vector database", "MCP") without plain-English explanation on public pages - Avoid "agentic workforce" as a primary term — use "AI agents" +- Never say "Mothership" or "copilot" — the agent is "Sim", the surface is "Chat" (in run logs the trigger reads "Sim agent") ## Vision -Sim becomes the default environment where teams build AI agents — not a tool you visit for one task, but a workspace you live in. Workflows are one module; Mothership is another. The workspace is the constant; the interface adapts. +Sim becomes the default environment where teams build AI agents — not a tool you visit for one task, but a workspace you live in. Workflows are one module; Chat is another. The workspace is the constant; the interface adapts. diff --git a/README.md b/README.md index 6a8508bcc3c..90246943df1 100644 --- a/README.md +++ b/README.md @@ -21,18 +21,18 @@ Ask DeepWiki Set Up with Cursor

-### Build everything in Mothership -Your AI command center. Describe what you want in plain language. Mothership knows your entire workspace and takes action: building agents, running them, querying data, and more. +### Build everything in Chat +Your AI command center. Describe what you want in plain language. Sim knows your entire workspace and takes action: building agents, running them, querying data, and more.

- Mothership building and running an agent from chat + Sim building and running an agent from chat

### Create files and documents Generate documents, reports, and presentations from a single prompt, grounded in your workspace data.

- Mothership generating a document from a prompt + Sim generating a document from a prompt

### Ground agents in your knowledge @@ -50,7 +50,7 @@ A database, built in. Store, query, and wire structured data into agent runs.

### Build visually with Workflows -Prefer a canvas? Design agents block by block in the visual builder, and let Copilot generate blocks, wire variables, and fix errors from natural language. +Prefer a canvas? Design agents block by block in the visual builder, and let Sim generate blocks, wire variables, and fix errors from natural language.

Workflow builder demo @@ -138,11 +138,11 @@ bun run dev:full # Starts Next.js app and realtime socket server Or run separately: `bun run dev` (Next.js) and `cd apps/sim && bun run dev:sockets` (realtime). -## Copilot API Keys +## Chat API Keys -Copilot is a Sim-managed service. To use Copilot on a self-hosted instance: +Chat is a Sim-managed service. To use Chat on a self-hosted instance: -- Go to https://sim.ai → Settings → Copilot and generate a Copilot API key +- Go to https://sim.ai → Settings → Chat keys and generate a Chat API key - Set `COPILOT_API_KEY` environment variable in your self-hosted apps/sim/.env file to that value ## Environment Variables diff --git a/apps/sim/app/(landing)/components/features/features.tsx b/apps/sim/app/(landing)/components/features/features.tsx index e30cf62e516..abca2c74d9f 100644 --- a/apps/sim/app/(landing)/components/features/features.tsx +++ b/apps/sim/app/(landing)/components/features/features.tsx @@ -41,12 +41,12 @@ interface FeatureTab { const FEATURE_TABS: FeatureTab[] = [ { - label: 'Mothership', + label: 'Chat', color: '#FA4EDF', title: 'Your AI command center', description: 'Direct your entire AI workforce from one place. Build agents, spin up workflows, query tables, and manage every resource across your workspace — in natural language.', - cta: 'Explore mothership', + cta: 'Explore chat', segments: [ [0.3, 8], [0.25, 10], @@ -186,7 +186,7 @@ export default function Features() { Workspace

- Sim's workspace includes four core features: Mothership, an AI command center for + Sim's workspace includes four core features: Chat, an AI command center for natural-language control of your entire workspace; Tables, a built-in database for filtering, sorting, and wiring data directly into workflows; Files, a shared document store for uploading, creating, and sharing documents, spreadsheets, and media across diff --git a/apps/sim/app/(landing)/components/footer/footer.tsx b/apps/sim/app/(landing)/components/footer/footer.tsx index 60767274a8b..9e06300511c 100644 --- a/apps/sim/app/(landing)/components/footer/footer.tsx +++ b/apps/sim/app/(landing)/components/footer/footer.tsx @@ -14,7 +14,7 @@ interface FooterItem { } const PRODUCT_LINKS: FooterItem[] = [ - { label: 'Mothership', href: 'https://docs.sim.ai/mothership', external: true }, + { label: 'Chat', href: 'https://docs.sim.ai/mothership', external: true }, { label: 'Workflows', href: 'https://docs.sim.ai', external: true }, { label: 'Knowledge Base', href: 'https://docs.sim.ai/knowledgebase', external: true }, { label: 'Tables', href: 'https://docs.sim.ai/tables', external: true }, diff --git a/apps/sim/app/(landing)/components/hero/hero.tsx b/apps/sim/app/(landing)/components/hero/hero.tsx index baf93168b4c..512630ecb1a 100644 --- a/apps/sim/app/(landing)/components/hero/hero.tsx +++ b/apps/sim/app/(landing)/components/hero/hero.tsx @@ -44,7 +44,7 @@ export default function Hero() { Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. Connect 1,000+ integrations and every major LLM, including OpenAI, Anthropic Claude, Google Gemini, Mistral, and xAI Grok, to create agents that automate real work. Build agents visually with - the workflow builder, conversationally through Mothership, or programmatically with the API. + the workflow builder, conversationally through Chat, or programmatically with the API. Trusted by over 100,000 builders at startups and Fortune 500 companies. SOC2 compliant.

diff --git a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-home/landing-preview-home.tsx b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-home/landing-preview-home.tsx index 3f411f608c5..249ddaea85f 100644 --- a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-home/landing-preview-home.tsx +++ b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-home/landing-preview-home.tsx @@ -218,7 +218,7 @@ export const LandingPreviewHome = memo(function LandingPreviewHome({
- Mothership + Sim { expirationSeconds: 3600, }) } catch (error) { - throw new ValidationError(getErrorMessage(error, 'Copilot validation failed')) + throw new ValidationError(getErrorMessage(error, 'Chat validation failed')) } } else if (uploadType === 'mothership') { const workspaceId = request.nextUrl.searchParams.get('workspaceId') if (!workspaceId?.trim()) { - throw new ValidationError('workspaceId query parameter is required for mothership uploads') + throw new ValidationError('workspaceId query parameter is required for chat uploads') } const permission = await getUserEntityPermissions(sessionUserId, 'workspace', workspaceId) if (permission !== 'write' && permission !== 'admin') { return NextResponse.json( - { error: 'Write or Admin access required for mothership uploads' }, + { error: 'Write or Admin access required for chat uploads' }, { status: 403 } ) } diff --git a/apps/sim/app/api/files/upload/route.ts b/apps/sim/app/api/files/upload/route.ts index c5835858647..9181ca36a26 100644 --- a/apps/sim/app/api/files/upload/route.ts +++ b/apps/sim/app/api/files/upload/route.ts @@ -251,7 +251,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { // Handle mothership context (chat-scoped uploads to workspace S3) if (context === 'mothership') { if (!workspaceId) { - throw new InvalidRequestError('Mothership context requires workspaceId parameter') + throw new InvalidRequestError('Chat context requires workspaceId parameter') } logger.info(`Uploading mothership file: ${originalName}`) diff --git a/apps/sim/app/api/mothership/execute/route.ts b/apps/sim/app/api/mothership/execute/route.ts index 3a49cc52ef7..1c5b64dbe8b 100644 --- a/apps/sim/app/api/mothership/execute/route.ts +++ b/apps/sim/app/api/mothership/execute/route.ts @@ -255,7 +255,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { allowExplicitAbort = false if (lifecycleAbortController.signal.aborted) { - send({ type: 'error', error: 'Mothership execution aborted' }) + send({ type: 'error', error: 'Sim execution aborted' }) return } @@ -274,7 +274,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { ) send({ type: 'error', - error: result.error || 'Mothership execution failed', + error: result.error || 'Sim execution failed', content: result.content || '', }) return @@ -296,7 +296,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { : 'Mothership execute aborted', { requestId } ) - send({ type: 'error', error: 'Mothership execution aborted' }) + send({ type: 'error', error: 'Sim execution aborted' }) return } @@ -350,7 +350,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { if (lifecycleAbortController.signal.aborted || req.signal.aborted) { reqLogger.info('Mothership execute aborted after lifecycle completion') - return NextResponse.json({ error: 'Mothership execution aborted' }, { status: 499 }) + return NextResponse.json({ error: 'Sim execution aborted' }, { status: 499 }) } if (!result.success) { @@ -368,7 +368,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { ) return NextResponse.json( { - error: result.error || 'Mothership execution failed', + error: result.error || 'Sim execution failed', content: result.content || '', }, { status: 500 } @@ -394,7 +394,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { } ) - return NextResponse.json({ error: 'Mothership execution aborted' }, { status: 499 }) + return NextResponse.json({ error: 'Sim execution aborted' }, { status: 499 }) } if (isWorkspaceAccessDeniedError(error)) { diff --git a/apps/sim/app/llms.txt/route.ts b/apps/sim/app/llms.txt/route.ts index 963acc0e2bc..73c24e3dc41 100644 --- a/apps/sim/app/llms.txt/route.ts +++ b/apps/sim/app/llms.txt/route.ts @@ -7,7 +7,7 @@ export function GET() { > Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. Connect 1,000+ integrations and every major LLM to create agents that automate real work. -Sim lets teams create agents visually with the workflow builder, conversationally through Mothership, or programmatically with the API. The workspace includes knowledge bases, tables, files, and full observability. +Sim lets teams create agents visually with the workflow builder, conversationally through Chat, or programmatically with the API. The workspace includes knowledge bases, tables, files, and full observability. ## Preferred URLs diff --git a/apps/sim/app/playground/page.tsx b/apps/sim/app/playground/page.tsx index fe041cff403..9206679acbd 100644 --- a/apps/sim/app/playground/page.tsx +++ b/apps/sim/app/playground/page.tsx @@ -246,7 +246,7 @@ export default function PlaygroundPage() { toast.error('Workflow Validation', { description: 'Usage limit exceeded: $0.00 used of $5.00 limit. Please upgrade your plan to continue running this workflow.', - action: { label: 'Fix in Copilot', onClick: () => {} }, + action: { label: 'Fix in Chat', onClick: () => {} }, }) } > diff --git a/apps/sim/app/workspace/[workspaceId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/components/index.ts index dd30b61d6d1..4c9c20a7d2b 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/index.ts @@ -3,6 +3,7 @@ export type { ErrorBoundaryProps, ErrorStateProps } from './error' export { ErrorShell, ErrorState } from './error' export { InlineRenameInput } from './inline-rename-input' export { MessageActions } from './message-actions' +export { FloatingOverflowText } from './resource/components/floating-overflow-text' export { ownerCell } from './resource/components/owner-cell' export { type ChromeActionSpec, diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-chrome-fallback/resource-chrome-fallback.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-chrome-fallback/resource-chrome-fallback.tsx index 5328e163c47..5dbb7656374 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-chrome-fallback/resource-chrome-fallback.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-chrome-fallback/resource-chrome-fallback.tsx @@ -86,7 +86,7 @@ export function ResourceChromeFallback({ sort={hasSort ? { options: [], active: null, onSort: noop } : undefined} filter={hasFilter ? { content: null } : undefined} /> - {columns ? : null} + {columns ? : null} ) } 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 6ee9bb054cb..3007aa402da 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 @@ -13,6 +13,7 @@ import { createPortal } from 'react-dom' import { Chip, ChipChevronDown, + chipContentIconClass, chipGeometryClass, chipVariants, DropdownMenu, @@ -185,7 +186,7 @@ export const ResourceHeader = memo(function ResourceHeader({ * CSS-truncates to "T…" while the `shrink-0` actions hold width. */ - {TitleIcon && } + {TitleIcon && } {titleLabel && ( - {Icon && } + {Icon && } - {Icon && } + {Icon && } ) @@ -425,11 +426,11 @@ function BreadcrumbLocationPopover({ className )} > - - + + {rootBreadcrumb?.label && ( diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx index fb5b6e040fc..b7b090ced4d 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx @@ -21,7 +21,6 @@ import { cn } from '@/lib/core/utils/cn' import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inline-rename-input' import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text' import { ResourceHeader } from '@/app/workspace/[workspaceId]/components/resource/components/resource-header' -import type { SortConfig } from '@/app/workspace/[workspaceId]/components/resource/components/resource-options' import { ResourceOptions } from '@/app/workspace/[workspaceId]/components/resource/components/resource-options' export interface ResourceColumn { @@ -127,26 +126,12 @@ function ResourceRoot({ children, onContextMenu }: ResourceProps) { interface ResourceTableProps { columns: ResourceColumn[] rows: ResourceRow[] - /** - * Declares that row ordering is managed externally (by the consumer, surfaced - * through `Resource.Options`'s Sort chip). The table never sorts rows itself — - * `rows` render in the order given — so this prop has no rendering effect; it - * exists to document sort ownership at the callsite. - */ - sort?: SortConfig selectedRowId?: string | null selectable?: SelectableConfig rowDragDrop?: RowDragDropConfig onRowClick?: (rowId: string) => void onRowHover?: (rowId: string) => void onRowContextMenu?: (e: React.MouseEvent, rowId: string) => void - /** - * Reserved. Loading no longer alters rendering — the column headers always - * paint and an empty `rows` array renders an empty body, so navigations never - * flash placeholder chrome. Kept for API stability and future semantics (e.g. - * aria-busy) so consumers can keep threading their query state. - */ - isLoading?: boolean onLoadMore?: () => void hasMore?: boolean isLoadingMore?: boolean diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index aecfe509aeb..a2ff125cbbe 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -190,8 +190,7 @@ export function Files() { }, [permissionConfig.hideFilesTab, router, workspaceId]) const { data: files = EMPTY_WORKSPACE_FILES, isLoading, error } = useWorkspaceFiles(workspaceId) - const { data: folders = EMPTY_WORKSPACE_FILE_FOLDERS, isLoading: foldersLoading } = - useWorkspaceFileFolders(workspaceId) + const { data: folders = EMPTY_WORKSPACE_FILE_FOLDERS } = useWorkspaceFileFolders(workspaceId) const { data: members } = useWorkspaceMembersQuery(workspaceId) const uploadFile = useUploadWorkspaceFile() const deleteFile = useDeleteWorkspaceFile() @@ -1891,12 +1890,10 @@ export function Files() { {children}
-} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/thinking-block/thinking-block.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/thinking-block/thinking-block.tsx index 0e5bcd3c98d..208a358b975 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/thinking-block/thinking-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/thinking-block/thinking-block.tsx @@ -85,7 +85,7 @@ export function ThinkingBlock({
- Mothership + Sim {children}
-} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx index c607ee8497a..4bc61d8891a 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -20,8 +20,11 @@ import type { SelectableConfig, SortConfig, } from '@/app/workspace/[workspaceId]/components' -import { EMPTY_CELL_PLACEHOLDER, Resource } from '@/app/workspace/[workspaceId]/components' -import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text' +import { + EMPTY_CELL_PLACEHOLDER, + FloatingOverflowText, + Resource, +} from '@/app/workspace/[workspaceId]/components' import { ChunkContextMenu, ChunkEditor, @@ -123,11 +126,7 @@ export function Document({ const userPermissions = useUserPermissionsContext() const { knowledgeBase } = useKnowledgeBase(knowledgeBaseId) - const { - document: documentData, - isLoading: isLoadingDocument, - error: documentError, - } = useDocument(knowledgeBaseId, documentId) + const { document: documentData, error: documentError } = useDocument(knowledgeBaseId, documentId) const [showTagsModal, setShowTagsModal] = useState(false) @@ -151,8 +150,6 @@ export function Document({ goToPage: initialGoToPage, error: initialError, updateChunk: initialUpdateChunk, - isLoading: isLoadingChunks, - isFetching: isFetchingChunks, } = useDocumentChunks( knowledgeBaseId, documentId, @@ -811,17 +808,6 @@ export function Document({ setContextMenuChunk(null) }, [closeContextMenu]) - const prevDocumentIdRef = useRef(documentId) - const isNavigatingToNewDoc = prevDocumentIdRef.current !== documentId - - useEffect(() => { - if (documentData && documentData.id === documentId) { - prevDocumentIdRef.current = documentId - } - }, [documentData, documentId]) - - const isFetchingNewDoc = isNavigatingToNewDoc && isFetchingChunks - const selectableConfig: SelectableConfig | undefined = isCompleted ? { selectedIds: selectedChunks, @@ -1158,11 +1144,9 @@ export function Document({ diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index 3d04d7efe22..19eab8c037b 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -47,8 +47,7 @@ import type { SelectableConfig, SortConfig, } from '@/app/workspace/[workspaceId]/components' -import { Resource } from '@/app/workspace/[workspaceId]/components' -import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text' +import { FloatingOverflowText, Resource } from '@/app/workspace/[workspaceId]/components' import { ActionBar, AddConnectorModal, @@ -320,7 +319,6 @@ export function KnowledgeBase({ const { knowledgeBase, - isLoading: isLoadingKnowledgeBase, error: knowledgeBaseError, refresh: refreshKnowledgeBase, } = useKnowledgeBase(id) @@ -333,8 +331,6 @@ export function KnowledgeBase({ const { documents, pagination, - isLoading: isLoadingDocuments, - isFetching: isFetchingDocuments, isPlaceholderData: isPlaceholderDocuments, error: documentsError, hasProcessingDocuments, @@ -792,16 +788,6 @@ export function KnowledgeBase({ setContextMenuDocument(null) }, [closeContextMenu]) - const prevKnowledgeBaseIdRef = useRef(id) - const isNavigatingToNewKB = prevKnowledgeBaseIdRef.current !== id - - if (knowledgeBase && knowledgeBase.id === id) { - prevKnowledgeBaseIdRef.current = id - } - - const isInitialLoad = isLoadingKnowledgeBase && !knowledgeBase - const isFetchingNewKB = isNavigatingToNewKB && isFetchingDocuments - const breadcrumbs: BreadcrumbItem[] = [ { label: 'Knowledge Base', @@ -1170,13 +1156,9 @@ export function KnowledgeBase({ diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/layout.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/layout.tsx deleted file mode 100644 index d11d17b1d3c..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/layout.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function KnowledgeLayout({ children }: { children: React.ReactNode }) { - return
{children}
-} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/workflows-list/workflows-list.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/workflows-list/workflows-list.tsx index 4fd236ec378..52b0ac60ec7 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/workflows-list/workflows-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/workflows-list/workflows-list.tsx @@ -2,7 +2,7 @@ import { memo } from 'react' import { Workflow } from '@/components/emcn/icons' import { cn } from '@/lib/core/utils/cn' import { handleKeyboardActivation } from '@/lib/core/utils/keyboard' -import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text' +import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components' import { DELETED_WORKFLOW_LABEL } from '@/app/workspace/[workspaceId]/logs/utils' import { StatusBar, type StatusBarSegment } from '..' diff --git a/apps/sim/app/workspace/[workspaceId]/logs/layout.tsx b/apps/sim/app/workspace/[workspaceId]/logs/layout.tsx deleted file mode 100644 index 03b973fb3e8..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/logs/layout.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function LogsLayout({ children }: { children: React.ReactNode }) { - return
{children}
-} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 13898e56f85..df99b7cb097 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -1133,7 +1133,6 @@ export default function Logs() { onRowClick={handleLogClick} onRowHover={handleLogHover} onRowContextMenu={handleLogContextMenu} - isLoading={!logsQuery.data} onLoadMore={loadMoreLogs} hasMore={logsQuery.hasNextPage ?? false} isLoadingMore={logsQuery.isFetchingNextPage} diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/create-schedule-modal/index.ts b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/create-schedule-modal/index.ts deleted file mode 100644 index a8cfd75124a..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/create-schedule-modal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ScheduleModal } from './schedule-modal' diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/create-schedule-modal/schedule-modal.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/create-schedule-modal/schedule-modal.tsx deleted file mode 100644 index d02c322c6e1..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/create-schedule-modal/schedule-modal.tsx +++ /dev/null @@ -1,528 +0,0 @@ -'use client' - -import { useMemo, useState } from 'react' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { - ButtonGroup, - ButtonGroupItem, - ChipCombobox, - ChipInput, - ChipModal, - ChipModalBody, - ChipModalError, - ChipModalField, - ChipModalFooter, - ChipModalHeader, - DatePicker, - TimePicker, -} from '@/components/emcn' -import type { ScheduleType } from '@/lib/workflows/schedules/utils' -import { - DAY_MAP, - parseCronToHumanReadable, - parseCronToScheduleType, - validateCronExpression, -} from '@/lib/workflows/schedules/utils' -import type { WorkspaceScheduleData } from '@/hooks/queries/schedules' -import { useCreateSchedule, useUpdateSchedule } from '@/hooks/queries/schedules' - -const logger = createLogger('ScheduleModal') - -const DEFAULT_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone - -type SelectOption = { label: string; value: string } - -const SCHEDULE_TYPE_OPTIONS: SelectOption[] = [ - { label: 'Every X Minutes', value: 'minutes' }, - { label: 'Hourly', value: 'hourly' }, - { label: 'Daily', value: 'daily' }, - { label: 'Weekly', value: 'weekly' }, - { label: 'Monthly', value: 'monthly' }, - { label: 'Custom (Cron)', value: 'custom' }, -] - -const WEEKDAY_OPTIONS: SelectOption[] = [ - { label: 'Monday', value: 'MON' }, - { label: 'Tuesday', value: 'TUE' }, - { label: 'Wednesday', value: 'WED' }, - { label: 'Thursday', value: 'THU' }, - { label: 'Friday', value: 'FRI' }, - { label: 'Saturday', value: 'SAT' }, - { label: 'Sunday', value: 'SUN' }, -] - -const TIMEZONE_OPTIONS: SelectOption[] = [ - { label: 'UTC', value: 'UTC' }, - { label: 'US Pacific (UTC-8)', value: 'America/Los_Angeles' }, - { label: 'US Mountain (UTC-7)', value: 'America/Denver' }, - { label: 'US Central (UTC-6)', value: 'America/Chicago' }, - { label: 'US Eastern (UTC-5)', value: 'America/New_York' }, - { label: 'US Alaska (UTC-9)', value: 'America/Anchorage' }, - { label: 'US Hawaii (UTC-10)', value: 'Pacific/Honolulu' }, - { label: 'Canada Toronto (UTC-5)', value: 'America/Toronto' }, - { label: 'Canada Vancouver (UTC-8)', value: 'America/Vancouver' }, - { label: 'Mexico City (UTC-6)', value: 'America/Mexico_City' }, - { label: 'São Paulo (UTC-3)', value: 'America/Sao_Paulo' }, - { label: 'Buenos Aires (UTC-3)', value: 'America/Argentina/Buenos_Aires' }, - { label: 'London (UTC+0)', value: 'Europe/London' }, - { label: 'Paris (UTC+1)', value: 'Europe/Paris' }, - { label: 'Berlin (UTC+1)', value: 'Europe/Berlin' }, - { label: 'Amsterdam (UTC+1)', value: 'Europe/Amsterdam' }, - { label: 'Madrid (UTC+1)', value: 'Europe/Madrid' }, - { label: 'Rome (UTC+1)', value: 'Europe/Rome' }, - { label: 'Moscow (UTC+3)', value: 'Europe/Moscow' }, - { label: 'Dubai (UTC+4)', value: 'Asia/Dubai' }, - { label: 'Tel Aviv (UTC+2)', value: 'Asia/Tel_Aviv' }, - { label: 'Cairo (UTC+2)', value: 'Africa/Cairo' }, - { label: 'Johannesburg (UTC+2)', value: 'Africa/Johannesburg' }, - { label: 'India (UTC+5:30)', value: 'Asia/Kolkata' }, - { label: 'Bangkok (UTC+7)', value: 'Asia/Bangkok' }, - { label: 'Jakarta (UTC+7)', value: 'Asia/Jakarta' }, - { label: 'Singapore (UTC+8)', value: 'Asia/Singapore' }, - { label: 'China (UTC+8)', value: 'Asia/Shanghai' }, - { label: 'Hong Kong (UTC+8)', value: 'Asia/Hong_Kong' }, - { label: 'Seoul (UTC+9)', value: 'Asia/Seoul' }, - { label: 'Tokyo (UTC+9)', value: 'Asia/Tokyo' }, - { label: 'Perth (UTC+8)', value: 'Australia/Perth' }, - { label: 'Sydney (UTC+10)', value: 'Australia/Sydney' }, - { label: 'Melbourne (UTC+10)', value: 'Australia/Melbourne' }, - { label: 'Auckland (UTC+12)', value: 'Pacific/Auckland' }, -] - -interface ScheduleModalProps { - open: boolean - onOpenChange: (open: boolean) => void - workspaceId: string - schedule?: WorkspaceScheduleData -} - -/** - * Builds a cron expression from schedule type and options. - * Returns null if the required fields for the selected type are incomplete. - */ -function buildCronExpression( - scheduleType: ScheduleType, - options: { - minutesInterval: string - hourlyMinute: string - dailyTime: string - weeklyDay: string - weeklyDayTime: string - monthlyDay: string - monthlyTime: string - cronExpression: string - } -): string | null { - switch (scheduleType) { - case 'minutes': { - const interval = Number.parseInt(options.minutesInterval, 10) - if (!interval || interval < 1 || interval > 1440) return null - return `*/${interval} * * * *` - } - case 'hourly': { - const minute = Number.parseInt(options.hourlyMinute, 10) - if (Number.isNaN(minute) || minute < 0 || minute > 59) return null - return `${minute} * * * *` - } - case 'daily': { - if (!options.dailyTime) return null - const [hours, minutes] = options.dailyTime.split(':') - return `${Number.parseInt(minutes, 10)} ${Number.parseInt(hours, 10)} * * *` - } - case 'weekly': { - if (!options.weeklyDay || !options.weeklyDayTime) return null - const day = DAY_MAP[options.weeklyDay] - if (day === undefined) return null - const [hours, minutes] = options.weeklyDayTime.split(':') - return `${Number.parseInt(minutes, 10)} ${Number.parseInt(hours, 10)} * * ${day}` - } - case 'monthly': { - const dayOfMonth = Number.parseInt(options.monthlyDay, 10) - if (!dayOfMonth || dayOfMonth < 1 || dayOfMonth > 31 || !options.monthlyTime) return null - const [hours, minutes] = options.monthlyTime.split(':') - return `${Number.parseInt(minutes, 10)} ${Number.parseInt(hours, 10)} ${dayOfMonth} * *` - } - case 'custom': { - return options.cronExpression.trim() || null - } - default: - return null - } -} - -/** - * Modal for creating and editing scheduled tasks. - * - * All `useState` initializers read from the `schedule` prop at mount time only. - * When editing an existing task, the call-site **must** supply a `key` prop equal to the - * task's ID so React remounts the component when the selected task changes — otherwise - * the form will display stale values from the previously selected task. - */ -export function ScheduleModal({ open, onOpenChange, workspaceId, schedule }: ScheduleModalProps) { - const createScheduleMutation = useCreateSchedule() - const updateScheduleMutation = useUpdateSchedule() - - const isEditing = Boolean(schedule) - - const initialCronState = useMemo( - () => (schedule ? parseCronToScheduleType(schedule.cronExpression) : null), - [schedule] - ) - - const [title, setTitle] = useState(schedule?.jobTitle ?? '') - const [prompt, setPrompt] = useState(schedule?.prompt ?? '') - const [scheduleType, setScheduleType] = useState( - initialCronState?.scheduleType ?? 'daily' - ) - const [minutesInterval, setMinutesInterval] = useState(initialCronState?.minutesInterval ?? '15') - const [hourlyMinute, setHourlyMinute] = useState(initialCronState?.hourlyMinute ?? '0') - const [dailyTime, setDailyTime] = useState(initialCronState?.dailyTime ?? '09:00') - const [weeklyDay, setWeeklyDay] = useState(initialCronState?.weeklyDay ?? 'MON') - const [weeklyDayTime, setWeeklyDayTime] = useState(initialCronState?.weeklyDayTime ?? '09:00') - const [monthlyDay, setMonthlyDay] = useState(initialCronState?.monthlyDay ?? '1') - const [monthlyTime, setMonthlyTime] = useState(initialCronState?.monthlyTime ?? '09:00') - const [cronExpression, setCronExpression] = useState(initialCronState?.cronExpression ?? '') - const [timezone, setTimezone] = useState(schedule?.timezone ?? DEFAULT_TIMEZONE) - const [startDate, setStartDate] = useState('') - const [lifecycle, setLifecycle] = useState<'persistent' | 'until_complete'>( - schedule?.lifecycle === 'until_complete' ? 'until_complete' : 'persistent' - ) - const [maxRuns, setMaxRuns] = useState(schedule?.maxRuns != null ? String(schedule.maxRuns) : '') - const [submitError, setSubmitError] = useState(null) - - const computedCron = useMemo( - () => - buildCronExpression(scheduleType, { - minutesInterval, - hourlyMinute, - dailyTime, - weeklyDay, - weeklyDayTime, - monthlyDay, - monthlyTime, - cronExpression, - }), - [ - scheduleType, - minutesInterval, - hourlyMinute, - dailyTime, - weeklyDay, - weeklyDayTime, - monthlyDay, - monthlyTime, - cronExpression, - ] - ) - - const showTimezone = scheduleType !== 'minutes' && scheduleType !== 'hourly' - - const resolvedTimezone = showTimezone ? timezone : 'UTC' - - const schedulePreview = useMemo(() => { - if (!computedCron) return null - const validation = validateCronExpression(computedCron, resolvedTimezone) - if (!validation.isValid) return { error: validation.error } - return { - humanReadable: parseCronToHumanReadable(computedCron, resolvedTimezone), - nextRun: validation.nextRun, - } - }, [computedCron, resolvedTimezone]) - - const isFormValid = Boolean( - title.trim() && - prompt.trim() && - computedCron && - schedulePreview && - !('error' in schedulePreview) - ) - - const resetForm = () => { - setTitle('') - setPrompt('') - setScheduleType('daily') - setMinutesInterval('15') - setHourlyMinute('0') - setDailyTime('09:00') - setWeeklyDay('MON') - setWeeklyDayTime('09:00') - setMonthlyDay('1') - setMonthlyTime('09:00') - setCronExpression('') - setTimezone(DEFAULT_TIMEZONE) - setStartDate('') - setLifecycle('persistent') - setMaxRuns('') - setSubmitError(null) - } - - /** - * Single close/open handler for every close path (footer Cancel, header X, - * Esc, and overlay click). The create-mode instance stays mounted between - * opens, so any close must also reset the draft to avoid stale values - * reappearing on the next open. - */ - const handleOpenChange = (nextOpen: boolean) => { - onOpenChange(nextOpen) - if (!nextOpen) resetForm() - } - - const handleClose = () => { - handleOpenChange(false) - } - - const handleSubmit = async () => { - if (!computedCron || !isFormValid) return - - setSubmitError(null) - try { - if (isEditing && schedule) { - await updateScheduleMutation.mutateAsync({ - scheduleId: schedule.id, - workspaceId, - title: title.trim(), - prompt: prompt.trim(), - cronExpression: computedCron, - timezone: resolvedTimezone, - lifecycle, - maxRuns: lifecycle === 'until_complete' && maxRuns ? Number.parseInt(maxRuns, 10) : null, - }) - } else { - await createScheduleMutation.mutateAsync({ - workspaceId, - title: title.trim(), - prompt: prompt.trim(), - cronExpression: computedCron, - timezone: resolvedTimezone, - lifecycle, - maxRuns: - lifecycle === 'until_complete' && maxRuns ? Number.parseInt(maxRuns, 10) : undefined, - startDate: startDate || undefined, - }) - } - handleClose() - } catch (error: unknown) { - logger.error('Schedule submission failed:', { error }) - setSubmitError(getErrorMessage(error, 'Failed to save scheduled task. Please try again.')) - } - } - - const modalTitle = isEditing ? 'Edit scheduled task' : 'Create new scheduled task' - - return ( - - {modalTitle} - - { - setTitle(value) - if (submitError) setSubmitError(null) - }} - placeholder='e.g., Daily report generation' - autoComplete='off' - onSubmit={handleSubmit} - /> - - { - setPrompt(value) - if (submitError) setSubmitError(null) - }} - placeholder='Describe what this scheduled task should do...' - minHeight={80} - /> - - - setScheduleType(v as ScheduleType)} - placeholder='Select frequency' - /> - - - {scheduleType === 'minutes' && ( - - setMinutesInterval(e.target.value)} - placeholder='15' - min={1} - max={1440} - /> - - )} - - {scheduleType === 'hourly' && ( - - setHourlyMinute(e.target.value)} - placeholder='0' - min={0} - max={59} - /> - - )} - - {scheduleType === 'daily' && ( - - - - )} - - {scheduleType === 'weekly' && ( -
- - - - - - -
- )} - - {scheduleType === 'monthly' && ( -
- - setMonthlyDay(e.target.value)} - placeholder='1' - min={1} - max={31} - /> - - - - -
- )} - - {scheduleType === 'custom' && ( - - setCronExpression(e.target.value)} - placeholder='0 9 * * *' - inputClassName='font-mono' - autoComplete='off' - /> - - )} - - {showTimezone && ( - - - - )} - - {!isEditing && ( - - Start date - (optional) - - } - > - - - )} - - - setLifecycle(value as 'persistent' | 'until_complete')} - > - Recurring - Number of runs - - - - {lifecycle === 'until_complete' && ( - - Max runs - (optional) - - } - > - setMaxRuns(e.target.value)} - placeholder='No limit' - min={1} - /> - - )} - - {computedCron && schedulePreview && ( -
- {'error' in schedulePreview ? ( -

{schedulePreview.error}

- ) : ( -
-

- {schedulePreview.humanReadable} -

- {schedulePreview.nextRun && ( -

- Next run:{' '} - {schedulePreview.nextRun.toLocaleString(undefined, { - dateStyle: 'medium', - timeStyle: 'short', - })} -

- )} -
- )} -
- )} - - {submitError} -
- - -
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/create-task-modal/create-task-modal.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/create-task-modal/create-task-modal.tsx new file mode 100644 index 00000000000..799e604be53 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/create-task-modal/create-task-modal.tsx @@ -0,0 +1,114 @@ +'use client' + +import { useState } from 'react' +import { createLogger } from '@sim/logger' +import { format } from 'date-fns' +import { + Calendar, + ChipDatePicker, + ChipDropdown, + ChipModal, + ChipModalBody, + ChipModalField, + ChipModalFooter, + ChipModalHeader, +} from '@/components/emcn' +import type { CalendarSlot } from '@/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-calendar' + +const logger = createLogger('CreateTaskModal') + +const DEFAULT_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone +const DEFAULT_TIME = '09:00' + +/** Half-hour launch times across the day (`HH:mm` values, `h:mm a` labels). */ +const TIME_OPTIONS = Array.from({ length: 48 }, (_, index) => { + const hour = Math.floor(index / 2) + const minute = index % 2 === 0 ? 0 : 30 + return { + value: `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`, + label: format(new Date(2000, 0, 1, hour, minute), 'h:mm a'), + } +}) + +/** The data a calendar create captures. Persistence is wired in a later phase. */ +export interface CreateTaskDraft { + prompt: string + launchDate: string + launchTime: string + timezone: string +} + +interface CreateTaskModalProps { + open: boolean + onOpenChange: (open: boolean) => void + /** The clicked slot, or `null` when opened from the header action. */ + slot: CalendarSlot | null + /** Receives the draft on submit. When omitted, the draft is logged (stub). */ + onSubmit?: (draft: CreateTaskDraft) => void +} + +/** + * Lightweight "schedule a task" modal opened from a calendar day or time slot. + * Seeds its launch date/time from `slot`; remount it with a slot-derived `key` + * to re-seed on a new selection. Submit is a UI-only stub this phase — it does + * not persist (the create API requires a recurring cron; one-time launches at an + * exact datetime are not yet supported). + */ +export function CreateTaskModal({ open, onOpenChange, slot, onSubmit }: CreateTaskModalProps) { + const [prompt, setPrompt] = useState('') + const [launchDate, setLaunchDate] = useState(() => format(slot?.date ?? new Date(), 'yyyy-MM-dd')) + const [launchTime, setLaunchTime] = useState(slot?.time ?? DEFAULT_TIME) + + const close = () => onOpenChange(false) + + const handleSubmit = () => { + const draft: CreateTaskDraft = { + prompt: prompt.trim(), + launchDate, + launchTime, + timezone: DEFAULT_TIMEZONE, + } + if (onSubmit) onSubmit(draft) + else logger.info('Scheduled task draft captured (not persisted this phase)', draft) + close() + } + + return ( + + + New scheduled task + + + + +
+
+ +
+
+ +
+
+
+
+ +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/create-task-modal/index.ts b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/create-task-modal/index.ts new file mode 100644 index 00000000000..82602965385 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/create-task-modal/index.ts @@ -0,0 +1 @@ +export { type CreateTaskDraft, CreateTaskModal } from './create-task-modal' diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/calendar-event-chip/calendar-event-chip.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/calendar-event-chip/calendar-event-chip.tsx new file mode 100644 index 00000000000..e2f37bf4b2b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/calendar-event-chip/calendar-event-chip.tsx @@ -0,0 +1,30 @@ +'use client' + +import { format } from 'date-fns' +import { cn } from '@/lib/core/utils/cn' +import type { CalendarEvent } from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events' + +interface CalendarEventChipProps { + event: CalendarEvent +} + +/** + * Compact event pill rendered inside a month day cell or a time-grid slot — the + * one leaf shared by both grids. The calendar feeds it real events once schedule + * injection is enabled. + */ +export function CalendarEventChip({ event }: CalendarEventChipProps) { + return ( + + + {format(event.start, 'h:mm a')} + + {event.title} + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/calendar-event-chip/index.ts b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/calendar-event-chip/index.ts new file mode 100644 index 00000000000..82edbb09366 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/calendar-event-chip/index.ts @@ -0,0 +1 @@ +export { CalendarEventChip } from './calendar-event-chip' 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 new file mode 100644 index 00000000000..12ff74890f9 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/calendar-toolbar/calendar-toolbar.tsx @@ -0,0 +1,72 @@ +'use client' + +import { ChevronLeft, ChevronRight } from 'lucide-react' +import { + Check, + Chip, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/emcn' +import type { CalendarScope } from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/calendar-grid' + +const SCOPE_OPTIONS: { value: CalendarScope; label: string }[] = [ + { value: 'day', label: 'Day' }, + { value: 'week', label: 'Week' }, + { value: 'month', label: 'Month' }, +] + +interface CalendarToolbarProps { + scope: CalendarScope + label: string + onPrev: () => void + onNext: () => void + onToday: () => void + onScopeChange: (scope: CalendarScope) => void +} + +/** + * Calendar ribbon: a "Today" jump and the period label on the left; the prev/next + * chevrons and the scope picker on the right. The controls are bare chips and the + * scope picker is a `DropdownMenu`, matching the Filter/Sort menus on the + * resource options bar. + */ +export function CalendarToolbar({ + scope, + label, + onPrev, + onNext, + onToday, + onScopeChange, +}: CalendarToolbarProps) { + const scopeLabel = SCOPE_OPTIONS.find((option) => option.value === scope)?.label ?? 'Week' + + return ( +
+
+ Today + {label} +
+
+ + + + + {scopeLabel} + + + {SCOPE_OPTIONS.map((option) => ( + onScopeChange(option.value)}> + {option.label} + {option.value === scope && ( + + )} + + ))} + + +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/calendar-toolbar/index.ts b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/calendar-toolbar/index.ts new file mode 100644 index 00000000000..aab36b40942 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/calendar-toolbar/index.ts @@ -0,0 +1 @@ +export { CalendarToolbar } from './calendar-toolbar' diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/index.ts b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/index.ts new file mode 100644 index 00000000000..dd145b4ae79 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/index.ts @@ -0,0 +1,4 @@ +export { CalendarEventChip } from './calendar-event-chip' +export { CalendarToolbar } from './calendar-toolbar' +export { MonthGrid } from './month-grid' +export { TimeGrid } from './time-grid' diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/month-grid/index.ts b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/month-grid/index.ts new file mode 100644 index 00000000000..40e4f18f3f2 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/month-grid/index.ts @@ -0,0 +1 @@ +export { MonthGrid } from './month-grid' diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/month-grid/month-grid.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/month-grid/month-grid.tsx new file mode 100644 index 00000000000..bbf01ccdd76 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/month-grid/month-grid.tsx @@ -0,0 +1,108 @@ +'use client' + +import { format } from 'date-fns' +import { cn } from '@/lib/core/utils/cn' +import { CalendarEventChip } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/calendar-event-chip' +import { + type CalendarDayCell, + type MonthGrid as MonthGridData, + WEEKDAY_LABELS, +} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/calendar-grid' +import { + type CalendarEvent, + dayKey, +} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events' + +interface MonthGridProps { + grid: MonthGridData + onSelectDay: (date: Date) => void + eventsByDay?: Map +} + +/** One day in the month grid. Clicking anywhere opens the create modal. */ +function DayCell({ + cell, + events, + colIndex, + onSelect, +}: { + cell: CalendarDayCell + events: CalendarEvent[] + colIndex: number + onSelect: (date: Date) => void +}) { + return ( + + ) +} + +/** + * Month scope: a sticky weekday header over a 7-column grid of day cells that + * fills the body height. All seven tracks are equal, so the border-to-border + * column rhythm is even; the edge cells span clear to the panel edges (the page + * gutter stays hoverable and clickable) and inset their own content via + * `pl-6`/`pr-6`. Events flow in via `eventsByDay` — the single injection point + * the container fills once schedule injection is wired. + */ +export function MonthGrid({ grid, onSelectDay, eventsByDay }: MonthGridProps) { + return ( +
+
+ {WEEKDAY_LABELS.map((label, index) => ( +
+ {label} +
+ ))} +
+
+ {grid.weeks.map((week) => + week.map((cell, colIndex) => ( + + )) + )} +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/time-grid/index.ts b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/time-grid/index.ts new file mode 100644 index 00000000000..9156c99899c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/time-grid/index.ts @@ -0,0 +1 @@ +export { TimeGrid } from './time-grid' diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/time-grid/time-grid.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/time-grid/time-grid.tsx new file mode 100644 index 00000000000..601157e0872 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/time-grid/time-grid.tsx @@ -0,0 +1,164 @@ +'use client' + +import { useEffect, useState } from 'react' +import { format } from 'date-fns' +import { cn } from '@/lib/core/utils/cn' +import { CalendarEventChip } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components/calendar-event-chip' +import { + type CalendarDayCell, + formatHourLabel, + formatSlotTime, + TIME_SLOT_HEIGHT, + timeToOffset, +} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/calendar-grid' +import { + type CalendarEvent, + hourKey, +} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events' + +const GUTTER_WIDTH = 56 + +/** Re-render cadence for the current-time indicator. */ +const TICK_MS = 60_000 + +interface TimeGridProps { + /** One column per day: 7 for week scope, 1 for day scope. */ + days: CalendarDayCell[] + hours: number[] + onSelectSlot: (date: Date, time: string) => void + eventsByHour?: Map +} + +/** + * Live now-line drawn over today's column — a chip-primary dot at the left edge + * and a hairline across the column, positioned by {@link timeToOffset}. Renders + * nothing until mounted (keeps SSR output stable, avoiding a hydration mismatch + * on the time-dependent offset), then ticks once a minute so the line advances. + * The parent column is `relative`; this is `absolute`. + */ +function CurrentTimeIndicator() { + const [now, setNow] = useState(null) + + useEffect(() => { + setNow(new Date()) + const interval = setInterval(() => setNow(new Date()), TICK_MS) + return () => clearInterval(interval) + }, []) + + if (!now) return null + + return ( +
+
+
+
+ ) +} + +/** One hour cell in a day column. Clicking opens the create modal. */ +function TimeSlot({ + date, + hour, + events, + isLastColumn, + onSelect, +}: { + date: Date + hour: number + events: CalendarEvent[] + isLastColumn: boolean + onSelect: (date: Date, time: string) => void +}) { + return ( + + ) +} + +/** + * Shared time-based grid for the week (7 columns) and day (1 column) scopes: a + * sticky day header, a fixed hour gutter, and a stack of hour slots per day. + * Column widths come from a CSS grid template shared by the header and body so + * they stay aligned. The sticky header paints chrome on the day cells only — + * its gutter spacer is transparent and border-free, so the hour labels scroll + * clear to the top of the viewport. Today's column is `relative` and hosts the + * {@link CurrentTimeIndicator}. Events flow in via `eventsByHour` — the single + * injection point the container fills. + */ +export function TimeGrid({ days, hours, onSelectSlot, eventsByHour }: TimeGridProps) { + const columnsStyle = { + gridTemplateColumns: `${GUTTER_WIDTH}px repeat(${days.length}, minmax(0, 1fr))`, + } + + return ( +
+
+
+ {days.map((day, dayIndex) => ( +
+ {format(day.date, 'EEE')} + + {format(day.date, 'd')} + +
+ ))} +
+ +
+
+ {hours.map((hour) => ( +
+ + {formatHourLabel(hour)} + +
+ ))} +
+ + {days.map((day, dayIndex) => ( +
+ {day.isToday && } + {hours.map((hour) => ( + + ))} +
+ ))} +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/index.ts b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/index.ts new file mode 100644 index 00000000000..f8405c4a8cf --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/index.ts @@ -0,0 +1 @@ +export { ScheduleCalendar } from './schedule-calendar' 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 new file mode 100644 index 00000000000..6d04efc19de --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/schedule-calendar.tsx @@ -0,0 +1,111 @@ +'use client' + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + CalendarToolbar, + MonthGrid, + TimeGrid, +} from '@/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar/components' +import { + buildCalendarGrid, + type CalendarScope, + formatScopeLabel, + timeToOffset, +} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/calendar-grid' +import type { CalendarEvent } from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events' + +interface ScheduleCalendarProps { + scope: CalendarScope + anchor: Date + today: Date + onScopeChange: (scope: CalendarScope) => void + onPrev: () => void + onNext: () => void + onToday: () => void + onSelectSlot: (date: Date, time?: string) => void + /** Day-bucketed events for the month grid. Empty until injection is wired. */ + eventsByDay?: Map + /** Hour-bucketed events for the time grid. Empty until injection is wired. */ + eventsByHour?: Map +} + +/** + * 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. + * + * Scroll behavior: entering week/day scope, and "Today" presses (signaled via an + * internal `scrollSignal`), center the current time in the viewport; month scope + * resets to the top. Plain prev/next navigation never re-centers. Centering is + * computed from the time-grid header height plus {@link timeToOffset} rather than + * the now-line element, so it works even on first paint before the line mounts. + * + * Event injection is the single integration point — `eventsByDay`/`eventsByHour` + * are threaded straight into the two grids, which forward them to their cells. + */ +export function ScheduleCalendar({ + scope, + anchor, + today, + onScopeChange, + onPrev, + onNext, + onToday, + onSelectSlot, + eventsByDay, + eventsByHour, +}: ScheduleCalendarProps) { + const scrollRef = useRef(null) + const [scrollSignal, setScrollSignal] = useState(0) + + const grid = useMemo(() => buildCalendarGrid(scope, anchor, today), [scope, anchor, today]) + const label = useMemo(() => formatScopeLabel(scope, anchor), [scope, anchor]) + + const handleToday = useCallback(() => { + onToday() + setScrollSignal((signal) => signal + 1) + }, [onToday]) + + useEffect(() => { + const region = scrollRef.current + if (!region) return + if (scope === 'month') { + region.scrollTo({ top: 0 }) + return + } + const header = region.querySelector('[data-time-grid-header]') + const headerHeight = header ? header.getBoundingClientRect().height : 0 + const target = headerHeight + timeToOffset(new Date()) - region.clientHeight / 2 + region.scrollTo({ top: Math.max(0, target) }) + }, [scope, scrollSignal]) + + return ( +
+ +
+ {grid.kind === 'month' ? ( + onSelectSlot(date)} + eventsByDay={eventsByDay} + /> + ) : ( + onSelectSlot(date, time)} + eventsByHour={eventsByHour} + /> + )} +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-context-menu/index.ts b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-context-menu/index.ts deleted file mode 100644 index 3d48948f6fc..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-context-menu/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ScheduleContextMenu } from './schedule-context-menu' diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-context-menu/schedule-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-context-menu/schedule-context-menu.tsx deleted file mode 100644 index 0a88ce836bc..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-context-menu/schedule-context-menu.tsx +++ /dev/null @@ -1,84 +0,0 @@ -'use client' - -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@/components/emcn' -import { Pause, Pencil, Play, Trash } from '@/components/emcn/icons' - -interface ScheduleContextMenuProps { - isOpen: boolean - position: { x: number; y: number } - onClose: () => void - isActive: boolean - onEdit?: () => void - onPause?: () => void - onResume?: () => void - onDelete?: () => void -} - -export function ScheduleContextMenu({ - isOpen, - position, - onClose, - isActive, - onEdit, - onPause, - onResume, - onDelete, -}: ScheduleContextMenuProps) { - return ( - !open && onClose()} modal={false}> - -
- - e.preventDefault()} - > - {onEdit && ( - - - Edit - - )} - {onEdit && } - {isActive && onPause && ( - - - Pause - - )} - {!isActive && onResume && ( - - - Resume - - )} - {(onPause || onResume) && onDelete && } - {onDelete && ( - - - Delete - - )} - - - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-calendar.ts b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-calendar.ts new file mode 100644 index 00000000000..d0433e893c5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-calendar.ts @@ -0,0 +1,78 @@ +'use client' + +import { useCallback, useMemo, useState } from 'react' +import { + advanceAnchor, + type CalendarScope, +} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/calendar-grid' + +/** A clicked calendar position: a day, optionally narrowed to an hour slot. */ +export interface CalendarSlot { + date: Date + /** `HH:mm` when a time slot was clicked; absent for a whole-day click. */ + time?: string +} + +export interface UseCalendarReturn { + scope: CalendarScope + /** The focused day; week/month ranges derive from it. */ + anchor: Date + /** Stable "now" for the calendar's lifetime, shared by every view. */ + today: Date + selectedSlot: CalendarSlot | null + isCreateOpen: boolean + setScope: (scope: CalendarScope) => void + next: () => void + prev: () => void + goToday: () => void + selectSlot: (date: Date, time?: string) => void + openCreate: () => void + closeCreate: () => void +} + +/** + * Owns the calendar's ephemeral view state (scope, anchor, selected slot, and + * create-modal open state). Pure UI state — `useState`, not a store. All + * mutations are event-driven; there are no effects. Opens on the `week` scope. + */ +export function useCalendar(): UseCalendarReturn { + const today = useMemo(() => new Date(), []) + const [scope, setScope] = useState('week') + const [anchor, setAnchor] = useState(() => new Date()) + const [selectedSlot, setSelectedSlot] = useState(null) + const [isCreateOpen, setIsCreateOpen] = useState(false) + + const next = useCallback(() => setAnchor((current) => advanceAnchor(current, scope, 1)), [scope]) + const prev = useCallback(() => setAnchor((current) => advanceAnchor(current, scope, -1)), [scope]) + const goToday = useCallback(() => setAnchor(new Date()), []) + + const selectSlot = useCallback((date: Date, time?: string) => { + setSelectedSlot({ date, time }) + setIsCreateOpen(true) + }, []) + + const openCreate = useCallback(() => { + setSelectedSlot(null) + setIsCreateOpen(true) + }, []) + + const closeCreate = useCallback(() => { + setIsCreateOpen(false) + setSelectedSlot(null) + }, []) + + return { + scope, + anchor, + today, + selectedSlot, + isCreateOpen, + setScope, + next, + prev, + goToday, + selectSlot, + openCreate, + closeCreate, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/layout.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/layout.tsx deleted file mode 100644 index a3bff8960d4..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/layout.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function ScheduledTasksLayout({ children }: { children: React.ReactNode }) { - return
{children}
-} diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/scheduled-tasks.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/scheduled-tasks.tsx index 65180ff5b4e..836d379b036 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/scheduled-tasks.tsx +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/scheduled-tasks.tsx @@ -1,74 +1,18 @@ 'use client' -import { useCallback, useMemo, useState } from 'react' -import { createLogger } from '@sim/logger' -import { formatAbsoluteDate } from '@sim/utils/formatting' -import { useParams } from 'next/navigation' -import { Calendar, ChipCombobox, ChipConfirmModal, Plus } from '@/components/emcn' -import { parseCronToHumanReadable } from '@/lib/workflows/schedules/utils' -import type { - FilterTag, - ResourceAction, - ResourceColumn, - ResourceRow, - SortConfig, -} from '@/app/workspace/[workspaceId]/components' -import { - EMPTY_CELL_PLACEHOLDER, - Resource, - timeCell, -} from '@/app/workspace/[workspaceId]/components' -import { ScheduleModal } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/create-schedule-modal' -import { ScheduleContextMenu } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-context-menu' +import { useCallback, useMemo } from 'react' +import { format } from 'date-fns' +import { Calendar, Plus } from '@/components/emcn' +import type { ResourceAction } from '@/app/workspace/[workspaceId]/components' +import { Resource } from '@/app/workspace/[workspaceId]/components' +import { CreateTaskModal } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/create-task-modal' +import { ScheduleCalendar } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar' import { ScheduleListContextMenu } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-list-context-menu' +import { useCalendar } from '@/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-calendar' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' -import type { WorkspaceScheduleData } from '@/hooks/queries/schedules' -import { - useDeleteSchedule, - useDisableSchedule, - useReactivateSchedule, - useWorkspaceSchedules, -} from '@/hooks/queries/schedules' -import { useDebounce } from '@/hooks/use-debounce' - -const logger = createLogger('ScheduledTasks') - -function getScheduleDescription(s: WorkspaceScheduleData) { - if (!s.cronExpression && s.nextRunAt) return `Once, at ${formatAbsoluteDate(s.nextRunAt)}` - if (s.cronExpression) { - const timing = parseCronToHumanReadable(s.cronExpression, s.timezone) - return `Recurring, ${timing.charAt(0).toLowerCase()}${timing.slice(1)}` - } - return EMPTY_CELL_PLACEHOLDER -} - -const COLUMNS: ResourceColumn[] = [ - { id: 'task', header: 'Task' }, - { id: 'schedule', header: 'Schedule' }, - { id: 'nextRun', header: 'Next Run' }, - { id: 'lastRun', header: 'Last Run' }, -] export function ScheduledTasks() { - const params = useParams() - const workspaceId = params.workspaceId as string - - const { data: allItems = [], isLoading, error } = useWorkspaceSchedules(workspaceId) - const deleteSchedule = useDeleteSchedule() - const disableSchedule = useDisableSchedule() - const reactivateSchedule = useReactivateSchedule() - - if (error) { - logger.error('Failed to load scheduled tasks:', error) - } - - const { - isOpen: isRowContextMenuOpen, - position: rowContextMenuPosition, - menuRef: rowMenuRef, - handleContextMenu: handleRowCtxMenu, - closeMenu: closeRowContextMenu, - } = useContextMenu() + const calendar = useCalendar() const { isOpen: isListContextMenuOpen, @@ -77,116 +21,6 @@ export function ScheduledTasks() { closeMenu: closeListContextMenu, } = useContextMenu() - const [isCreateModalOpen, setIsCreateModalOpen] = useState(false) - const [isEditModalOpen, setIsEditModalOpen] = useState(false) - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) - const [activeTask, setActiveTask] = useState(null) - const [searchQuery, setSearchQuery] = useState('') - const debouncedSearchQuery = useDebounce(searchQuery, 300) - const [activeSort, setActiveSort] = useState<{ - column: string - direction: 'asc' | 'desc' - } | null>(null) - const [scheduleTypeFilter, setScheduleTypeFilter] = useState([]) - const [statusFilter, setStatusFilter] = useState([]) - const [healthFilter, setHealthFilter] = useState([]) - - const visibleItems = useMemo( - () => allItems.filter((item) => item.sourceType === 'job' && item.status !== 'completed'), - [allItems] - ) - - const filteredItems = useMemo(() => { - let result = debouncedSearchQuery - ? visibleItems.filter((item) => { - const task = item.prompt || '' - return ( - task.toLowerCase().includes(debouncedSearchQuery.toLowerCase()) || - getScheduleDescription(item).toLowerCase().includes(debouncedSearchQuery.toLowerCase()) - ) - }) - : visibleItems - - if (scheduleTypeFilter.length > 0) { - result = result.filter((item) => { - if (scheduleTypeFilter.includes('recurring') && Boolean(item.cronExpression)) return true - if (scheduleTypeFilter.includes('once') && !item.cronExpression) return true - return false - }) - } - - if (statusFilter.length > 0) { - result = result.filter((item) => { - if (statusFilter.includes('active') && item.status === 'active') return true - if (statusFilter.includes('paused') && item.status === 'disabled') return true - return false - }) - } - - if (healthFilter.includes('has-failures')) { - result = result.filter((item) => (item.failedCount ?? 0) > 0) - } - - const col = activeSort?.column ?? 'nextRun' - const dir = activeSort?.direction ?? 'desc' - return [...result].sort((a, b) => { - let cmp = 0 - switch (col) { - case 'task': - cmp = (a.prompt || '').localeCompare(b.prompt || '') - break - case 'nextRun': - cmp = - (a.nextRunAt ? new Date(a.nextRunAt).getTime() : 0) - - (b.nextRunAt ? new Date(b.nextRunAt).getTime() : 0) - break - case 'lastRun': - cmp = - (a.lastRanAt ? new Date(a.lastRanAt).getTime() : 0) - - (b.lastRanAt ? new Date(b.lastRanAt).getTime() : 0) - break - case 'schedule': - cmp = getScheduleDescription(a).localeCompare(getScheduleDescription(b)) - break - } - return dir === 'asc' ? cmp : -cmp - }) - }, [ - visibleItems, - debouncedSearchQuery, - scheduleTypeFilter, - statusFilter, - healthFilter, - activeSort, - ]) - - const rows: ResourceRow[] = useMemo( - () => - filteredItems.map((item) => ({ - id: item.id, - cells: { - task: { - icon: , - label: item.prompt, - }, - schedule: { label: getScheduleDescription(item) }, - nextRun: timeCell(item.nextRunAt), - lastRun: timeCell(item.lastRanAt), - }, - })), - [filteredItems] - ) - - const itemById = useMemo(() => new Map(filteredItems.map((i) => [i.id, i])), [filteredItems]) - - const handleRowContextMenu = useCallback( - (e: React.MouseEvent, rowId: string) => { - setActiveTask(itemById.get(rowId) ?? null) - handleRowCtxMenu(e) - }, - [itemById, handleRowCtxMenu] - ) - const handleContentContextMenu = useCallback( (e: React.MouseEvent) => { const target = e.target as HTMLElement @@ -201,219 +35,35 @@ export function ScheduledTasks() { [handleListContextMenu] ) - const handleDelete = async () => { - if (!activeTask) return - try { - await deleteSchedule.mutateAsync({ scheduleId: activeTask.id, workspaceId }) - setIsDeleteDialogOpen(false) - setActiveTask(null) - } catch (err) { - logger.error('Failed to delete scheduled task:', err) - } - } - - const handleDeleteDialogOpenChange = (open: boolean) => { - setIsDeleteDialogOpen(open) - if (!open) setActiveTask(null) - } - - const handlePause = async () => { - if (!activeTask) return - try { - await disableSchedule.mutateAsync({ scheduleId: activeTask.id, workspaceId }) - } catch (err) { - logger.error('Failed to pause scheduled task:', err) - } - } - - const handleResume = async () => { - if (!activeTask) return - try { - await reactivateSchedule.mutateAsync({ - scheduleId: activeTask.id, - workflowId: activeTask.workflowId || '', - blockId: '', - workspaceId, - }) - } catch (err) { - logger.error('Failed to resume scheduled task:', err) - } - } - - const sortConfig: SortConfig = useMemo( - () => ({ - options: [ - { id: 'task', label: 'Task' }, - { id: 'schedule', label: 'Schedule' }, - { id: 'nextRun', label: 'Next Run' }, - { id: 'lastRun', label: 'Last Run' }, - ], - active: activeSort, - onSort: (column, direction) => setActiveSort({ column, direction }), - onClear: () => setActiveSort(null), - }), - [activeSort] - ) - - const scheduleTypeDisplayLabel = useMemo(() => { - if (scheduleTypeFilter.length === 0) return 'All' - if (scheduleTypeFilter.length === 1) - return scheduleTypeFilter[0] === 'recurring' ? 'Recurring' : 'One-time' - return `${scheduleTypeFilter.length} selected` - }, [scheduleTypeFilter]) - - const statusDisplayLabel = useMemo(() => { - if (statusFilter.length === 0) return 'All' - if (statusFilter.length === 1) return statusFilter[0] === 'active' ? 'Active' : 'Paused' - return `${statusFilter.length} selected` - }, [statusFilter]) - - const healthDisplayLabel = useMemo(() => { - if (healthFilter.length === 0) return 'All' - return 'Has failures' - }, [healthFilter]) - - const hasActiveFilters = - scheduleTypeFilter.length > 0 || statusFilter.length > 0 || healthFilter.length > 0 - - const filterContent = useMemo( - () => ( -
-
- - Schedule Type - - - {scheduleTypeDisplayLabel} - - } - showAllOption - allOptionLabel='All' - className='w-full' - /> -
-
- Status - {statusDisplayLabel} - } - showAllOption - allOptionLabel='All' - className='w-full' - /> -
-
- Health - {healthDisplayLabel} - } - showAllOption - allOptionLabel='All' - className='w-full' - /> -
- {hasActiveFilters && ( - - )} -
- ), - [ - scheduleTypeFilter, - statusFilter, - healthFilter, - scheduleTypeDisplayLabel, - statusDisplayLabel, - healthDisplayLabel, - hasActiveFilters, - ] - ) - const headerActions: ResourceAction[] = useMemo( () => [ { text: 'New scheduled task', icon: Plus, - onSelect: () => setIsCreateModalOpen(true), + onSelect: calendar.openCreate, variant: 'primary', }, ], - [] + [calendar.openCreate] ) - const filterTags: FilterTag[] = useMemo(() => { - const tags: FilterTag[] = [] - if (scheduleTypeFilter.length > 0) { - const label = - scheduleTypeFilter.length === 1 - ? `Type: ${scheduleTypeFilter[0] === 'recurring' ? 'Recurring' : 'One-time'}` - : `Type: ${scheduleTypeFilter.length} selected` - tags.push({ label, onRemove: () => setScheduleTypeFilter([]) }) - } - if (statusFilter.length > 0) { - const label = - statusFilter.length === 1 - ? `Status: ${statusFilter[0] === 'active' ? 'Active' : 'Paused'}` - : `Status: ${statusFilter.length} selected` - tags.push({ label, onRemove: () => setStatusFilter([]) }) - } - if (healthFilter.length > 0) { - tags.push({ label: 'Health: Has failures', onRemove: () => setHealthFilter([]) }) - } - return tags - }, [scheduleTypeFilter, statusFilter, healthFilter]) + const slotKey = calendar.selectedSlot + ? `${format(calendar.selectedSlot.date, 'yyyy-MM-dd')}T${calendar.selectedSlot.time ?? ''}` + : 'none' return ( <> - - @@ -421,57 +71,16 @@ export function ScheduledTasks() { isOpen={isListContextMenuOpen} position={listContextMenuPosition} onClose={closeListContextMenu} - onCreateSchedule={() => setIsCreateModalOpen(true)} - /> - - setIsEditModalOpen(true)} - onPause={handlePause} - onResume={handleResume} - onDelete={() => setIsDeleteDialogOpen(true)} + onCreateSchedule={calendar.openCreate} /> - - - { - setIsEditModalOpen(open) - if (!open) setActiveTask(null) - }} - workspaceId={workspaceId} - schedule={activeTask ?? undefined} - /> - - - Are you sure you want to delete{' '} - - {activeTask?.jobTitle || 'this task'} - - ? This action cannot be undone. - - } - confirm={{ - label: 'Delete', - onClick: handleDelete, - pending: deleteSchedule.isPending, - pendingLabel: 'Deleting...', + if (!open) calendar.closeCreate() }} + slot={calendar.selectedSlot} /> ) diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/calendar-grid.test.ts b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/calendar-grid.test.ts new file mode 100644 index 00000000000..3c0a680f317 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/calendar-grid.test.ts @@ -0,0 +1,96 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + advanceAnchor, + buildCalendarGrid, + formatHourLabel, + formatScopeLabel, + formatSlotTime, + HOURS, + TIME_SLOT_HEIGHT, + timeToOffset, + WEEKDAY_LABELS, +} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/calendar-grid' + +// June 10, 2026 is a Wednesday. June 1, 2026 is a Monday. +const ANCHOR = new Date(2026, 5, 10) +const TODAY = new Date(2026, 5, 10) + +describe('buildCalendarGrid', () => { + it('builds a Sunday-first month grid with spillover days', () => { + const grid = buildCalendarGrid('month', ANCHOR, TODAY) + if (grid.kind !== 'month') throw new Error('expected month grid') + + // May 31 (Sun) → Jul 4 (Sat) = 5 full weeks. + expect(grid.weeks).toHaveLength(5) + expect(grid.weeks.every((week) => week.length === 7)).toBe(true) + + const first = grid.weeks[0][0] + expect(first.date).toEqual(new Date(2026, 4, 31)) + expect(first.isCurrentMonth).toBe(false) + + const flat = grid.weeks.flat() + const tenth = flat.find((cell) => cell.date.getDate() === 10 && cell.isCurrentMonth) + expect(tenth?.isToday).toBe(true) + expect(flat.filter((cell) => cell.isToday)).toHaveLength(1) + }) + + it('builds a 7-day week grid starting Sunday with 24 hours', () => { + const grid = buildCalendarGrid('week', ANCHOR, TODAY) + if (grid.kind !== 'week') throw new Error('expected week grid') + + expect(grid.days).toHaveLength(7) + expect(grid.days[0].date).toEqual(new Date(2026, 5, 7)) // Sunday + expect(grid.hours).toEqual(HOURS) + }) + + it('builds a single-day grid for the anchor', () => { + const grid = buildCalendarGrid('day', ANCHOR, TODAY) + if (grid.kind !== 'day') throw new Error('expected day grid') + + expect(grid.day.date).toEqual(ANCHOR) + expect(grid.day.isToday).toBe(true) + expect(grid.hours).toHaveLength(24) + }) +}) + +describe('advanceAnchor', () => { + it('advances by the unit of the scope', () => { + expect(advanceAnchor(ANCHOR, 'month', 1)).toEqual(new Date(2026, 6, 10)) + expect(advanceAnchor(ANCHOR, 'week', 1)).toEqual(new Date(2026, 5, 17)) + expect(advanceAnchor(ANCHOR, 'day', -1)).toEqual(new Date(2026, 5, 9)) + }) +}) + +describe('formatScopeLabel', () => { + it('formats per scope', () => { + expect(formatScopeLabel('month', ANCHOR)).toBe('June 2026') + expect(formatScopeLabel('week', ANCHOR)).toBe('Jun 7 – 13, 2026') + expect(formatScopeLabel('day', ANCHOR)).toBe('Wednesday, June 10, 2026') + }) +}) + +describe('hour helpers', () => { + it('formats 24h slot times and 12h gutter labels', () => { + expect(formatSlotTime(7)).toBe('07:00') + expect(formatSlotTime(0)).toBe('00:00') + expect(formatHourLabel(0)).toBe('12 AM') + expect(formatHourLabel(13)).toBe('1 PM') + }) + + it('rotates weekday labels to Sunday-first', () => { + expect(WEEKDAY_LABELS[0]).toBe('Sun') + expect(WEEKDAY_LABELS).toHaveLength(7) + }) +}) + +describe('timeToOffset', () => { + it('maps a moment in the day to a pixel offset from the slots top', () => { + expect(timeToOffset(new Date(2026, 5, 10, 0, 0))).toBe(0) + expect(timeToOffset(new Date(2026, 5, 10, 1, 0))).toBe(TIME_SLOT_HEIGHT) + expect(timeToOffset(new Date(2026, 5, 10, 6, 30))).toBe(6.5 * TIME_SLOT_HEIGHT) + expect(timeToOffset(new Date(2026, 5, 10, 23, 0))).toBe(23 * TIME_SLOT_HEIGHT) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/calendar-grid.ts b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/calendar-grid.ts new file mode 100644 index 00000000000..27956432d5d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/calendar-grid.ts @@ -0,0 +1,155 @@ +import { + addDays, + addMonths, + addWeeks, + eachDayOfInterval, + endOfMonth, + endOfWeek, + format, + isSameDay, + isSameMonth, + startOfMonth, + startOfWeek, +} from 'date-fns' + +/** The granularity the calendar is currently rendering. */ +export type CalendarScope = 'day' | 'week' | 'month' + +/** A single day rendered in any view (month cell, week/day column header). */ +export interface CalendarDayCell { + date: Date + isToday: boolean + /** `false` for leading/trailing spillover days outside the focused month. */ + isCurrentMonth: boolean +} + +export interface MonthGrid { + kind: 'month' + /** Calendar rows (4–6) of 7 day cells each, including spillover days. */ + weeks: CalendarDayCell[][] +} + +export interface WeekGrid { + kind: 'week' + days: CalendarDayCell[] + hours: number[] +} + +export interface DayGrid { + kind: 'day' + day: CalendarDayCell + hours: number[] +} + +export type CalendarGrid = MonthGrid | WeekGrid | DayGrid + +/** Sunday-first, matching the emcn `Calendar` picker. */ +export const WEEK_STARTS_ON = 0 as const + +/** Hours of the day rendered as rows in the week/day time grid. */ +export const HOURS: number[] = Array.from({ length: 24 }, (_, hour) => hour) + +/** Fixed pixel height of one hour row in the time grid. */ +export const TIME_SLOT_HEIGHT = 48 + +/** + * Horizontal inset of the calendar's edge content, matching the page's content + * gutter (header `px-4` + the title chip's `px-2`). Edge grid tracks are widened + * by this amount so every column keeps an equal content width. + */ +export const EDGE_GUTTER = 24 + +const BASE_WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const + +/** Weekday header labels rotated to honor {@link WEEK_STARTS_ON}. */ +export const WEEKDAY_LABELS: string[] = [ + ...BASE_WEEKDAYS.slice(WEEK_STARTS_ON), + ...BASE_WEEKDAYS.slice(0, WEEK_STARTS_ON), +] + +function toCell(date: Date, today: Date, anchor?: Date): CalendarDayCell { + return { + date, + isToday: isSameDay(date, today), + isCurrentMonth: anchor ? isSameMonth(date, anchor) : true, + } +} + +/** Move the anchor date forward (`delta > 0`) or back by one unit of `scope`. */ +export function advanceAnchor(anchor: Date, scope: CalendarScope, delta: number): Date { + switch (scope) { + case 'month': + return addMonths(anchor, delta) + case 'week': + return addWeeks(anchor, delta) + case 'day': + return addDays(anchor, delta) + } +} + +function buildMonthGrid(anchor: Date, today: Date): MonthGrid { + const start = startOfWeek(startOfMonth(anchor), { weekStartsOn: WEEK_STARTS_ON }) + const end = endOfWeek(endOfMonth(anchor), { weekStartsOn: WEEK_STARTS_ON }) + const days = eachDayOfInterval({ start, end }) + const weeks: CalendarDayCell[][] = [] + for (let i = 0; i < days.length; i += 7) { + weeks.push(days.slice(i, i + 7).map((date) => toCell(date, today, anchor))) + } + return { kind: 'month', weeks } +} + +function buildWeekGrid(anchor: Date, today: Date): WeekGrid { + const start = startOfWeek(anchor, { weekStartsOn: WEEK_STARTS_ON }) + const end = endOfWeek(anchor, { weekStartsOn: WEEK_STARTS_ON }) + const days = eachDayOfInterval({ start, end }).map((date) => toCell(date, today)) + return { kind: 'week', days, hours: HOURS } +} + +function buildDayGrid(anchor: Date, today: Date): DayGrid { + return { kind: 'day', day: toCell(anchor, today), hours: HOURS } +} + +/** + * Pure, React-free derivation of the renderable grid for a given scope and + * anchor. `today` is passed in (never read from the clock here) so the result + * is fully deterministic and unit-testable. + */ +export function buildCalendarGrid(scope: CalendarScope, anchor: Date, today: Date): CalendarGrid { + switch (scope) { + case 'month': + return buildMonthGrid(anchor, today) + case 'week': + return buildWeekGrid(anchor, today) + case 'day': + return buildDayGrid(anchor, today) + } +} + +/** Toolbar period label, e.g. `June 2026`, `Jun 7 – 13, 2026`, `Wednesday, June 10, 2026`. */ +export function formatScopeLabel(scope: CalendarScope, anchor: Date): string { + if (scope === 'month') return format(anchor, 'MMMM yyyy') + if (scope === 'day') return format(anchor, 'EEEE, MMMM d, yyyy') + const start = startOfWeek(anchor, { weekStartsOn: WEEK_STARTS_ON }) + const end = endOfWeek(anchor, { weekStartsOn: WEEK_STARTS_ON }) + if (isSameMonth(start, end)) return `${format(start, 'MMM d')} – ${format(end, 'd, yyyy')}` + return `${format(start, 'MMM d')} – ${format(end, 'MMM d, yyyy')}` +} + +/** Display label for an hour-of-day gutter row, e.g. `7 AM`, `12 PM`. */ +export function formatHourLabel(hour: number): string { + return format(new Date(2000, 0, 1, hour), 'h a') +} + +/** + * Vertical pixel offset of a moment within the day, measured from the top of the + * time grid's slot stack (the `00:00` row). Positions the current-time + * indicator. Pure and clock-free — `date` is passed in so callers control "now". + */ +export function timeToOffset(date: Date): number { + return (date.getHours() + date.getMinutes() / 60) * TIME_SLOT_HEIGHT +} + +/** Wire-format time string for an hour slot, e.g. `07:00`. */ +export function formatSlotTime(hour: number): string { + return `${hour.toString().padStart(2, '0')}:00` +} diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events.test.ts b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events.test.ts new file mode 100644 index 00000000000..2cfd8084f44 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events.test.ts @@ -0,0 +1,77 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + bucketEventsByDay, + bucketEventsByHour, + dayKey, + hourKey, + toCalendarEvent, +} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events' +import type { WorkspaceScheduleData } from '@/hooks/queries/schedules' + +function makeSchedule(overrides: Partial): WorkspaceScheduleData { + // Test-only partial — the adapter reads just a handful of fields. + return { + id: 's1', + nextRunAt: null, + cronExpression: null, + jobTitle: null, + prompt: null, + ...overrides, + } as unknown as WorkspaceScheduleData +} + +describe('toCalendarEvent', () => { + it('returns null when nextRunAt is missing or unparseable', () => { + expect(toCalendarEvent(makeSchedule({ nextRunAt: null }))).toBeNull() + expect(toCalendarEvent(makeSchedule({ nextRunAt: 'not-a-date' }))).toBeNull() + }) + + it('maps a valid schedule to a positioned event', () => { + const event = toCalendarEvent( + makeSchedule({ + id: 'abc', + nextRunAt: '2026-06-10T14:30:00.000Z', + jobTitle: 'Daily report', + cronExpression: '0 14 * * *', + }) + ) + expect(event?.id).toBe('abc') + expect(event?.title).toBe('Daily report') + expect(event?.isRecurring).toBe(true) + expect(event?.start.getTime()).toBe(new Date('2026-06-10T14:30:00.000Z').getTime()) + }) + + it('falls back through jobTitle → prompt snippet → default title', () => { + const longPrompt = 'x'.repeat(120) + const fromPrompt = toCalendarEvent( + makeSchedule({ nextRunAt: '2026-06-10T14:30:00.000Z', prompt: longPrompt }) + ) + expect(fromPrompt?.title.length).toBeLessThan(longPrompt.length) + expect(fromPrompt?.isRecurring).toBe(false) + + const fallback = toCalendarEvent(makeSchedule({ nextRunAt: '2026-06-10T14:30:00.000Z' })) + expect(fallback?.title).toBe('Scheduled task') + }) +}) + +describe('bucketing', () => { + it('groups events by day and by hour', () => { + const events = [ + toCalendarEvent(makeSchedule({ id: 'a', nextRunAt: '2026-06-10T14:30:00.000Z' })), + toCalendarEvent(makeSchedule({ id: 'b', nextRunAt: '2026-06-10T14:45:00.000Z' })), + toCalendarEvent(makeSchedule({ id: 'c', nextRunAt: '2026-06-11T09:00:00.000Z' })), + ].filter((event): event is NonNullable => event !== null) + + const byDay = bucketEventsByDay(events) + const firstDay = byDay.get(dayKey(events[0].start)) + expect(firstDay).toHaveLength(2) + + const byHour = bucketEventsByHour(events) + const firstHour = byHour.get(hourKey(events[0].start, events[0].start.getHours())) + expect(firstHour).toHaveLength(2) + expect(byHour.size).toBe(2) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events.ts b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events.ts new file mode 100644 index 00000000000..22d84469e58 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events.ts @@ -0,0 +1,73 @@ +import { truncate } from '@sim/utils/string' +import { format, getHours } from 'date-fns' +import type { WorkspaceScheduleData } from '@/hooks/queries/schedules' + +/** + * A scheduled task positioned on the calendar. Derived from a + * {@link WorkspaceScheduleData} row via {@link toCalendarEvent}; keeps the raw + * `source` row for click-through once schedule interaction is wired. + */ +export interface CalendarEvent { + id: string + start: Date + title: string + isRecurring: boolean + source: WorkspaceScheduleData +} + +/** Bucket key for a day cell (`yyyy-MM-dd`). */ +export function dayKey(date: Date): string { + return format(date, 'yyyy-MM-dd') +} + +/** Bucket key for an hour slot (`yyyy-MM-dd-HH`). */ +export function hourKey(date: Date, hour: number): string { + return `${dayKey(date)}-${hour.toString().padStart(2, '0')}` +} + +/** + * Adapts a schedule row into a positioned calendar event. Returns `null` when + * the row has no parseable `nextRunAt` so callers can `.filter(Boolean)` it out. + * This is the single coupling point between the schedule data model and the + * calendar view. + */ +export function toCalendarEvent(schedule: WorkspaceScheduleData): CalendarEvent | null { + if (!schedule.nextRunAt) return null + const start = new Date(schedule.nextRunAt) + if (Number.isNaN(start.getTime())) return null + const title = + schedule.jobTitle?.trim() || + (schedule.prompt ? truncate(schedule.prompt, 60) : '') || + 'Scheduled task' + return { + id: schedule.id, + start, + title, + isRecurring: schedule.cronExpression != null, + source: schedule, + } +} + +function bucketBy( + events: CalendarEvent[], + keyOf: (event: CalendarEvent) => string +): Map { + const map = new Map() + for (const event of events) { + const key = keyOf(event) + const bucket = map.get(key) + if (bucket) bucket.push(event) + else map.set(key, [event]) + } + return map +} + +/** Groups events by calendar day for month-view cell lookup. */ +export function bucketEventsByDay(events: CalendarEvent[]): Map { + return bucketBy(events, (event) => dayKey(event.start)) +} + +/** Groups events by hour slot for week/day-view slot lookup. */ +export function bucketEventsByHour(events: CalendarEvent[]): Map { + return bucketBy(events, (event) => hourKey(event.start, getHours(event.start))) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx index f04463bfd91..650391cf5d1 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx @@ -31,7 +31,7 @@ const SECTION_TITLES: Record = { team: 'Team', sso: 'Single Sign-On', whitelabeling: 'Whitelabeling', - copilot: 'Copilot Keys', + copilot: 'Chat Keys', mcp: 'MCP Tools', 'custom-tools': 'Custom Tools', 'workflow-mcp-servers': 'MCP Servers', diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx index 8a0d0f7ef4c..1d18c0755ab 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/copilot/copilot.tsx @@ -95,7 +95,7 @@ export function Copilot() { const isDuplicate = keys.some((k) => k.name === trimmedName) if (isDuplicate) { setCreateError( - `A Copilot API key named "${trimmedName}" already exists. Please choose a different name.` + `A Chat API key named "${trimmedName}" already exists. Please choose a different name.` ) return } @@ -260,8 +260,8 @@ export function Copilot() {

- This key will allow access to Copilot features. Make sure to copy it after creation as - you won't be able to see it again. + This key will allow access to Chat features. Make sure to copy it after creation as you + won't be able to see it again.

{children}
-} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/layout.tsx b/apps/sim/app/workspace/[workspaceId]/tables/layout.tsx deleted file mode 100644 index d16ed04290a..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/tables/layout.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function TablesLayout({ children }: { children: React.ReactNode }) { - return
{children}
-} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index 676fd3b6724..23179e5cb13 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -67,7 +67,7 @@ export function Tables() { const userPermissions = useUserPermissionsContext() - const { data: tables = [], isLoading, error } = useTablesList(workspaceId) + const { data: tables = [], error } = useTablesList(workspaceId) const { data: members } = useWorkspaceMembersQuery(workspaceId) if (error) { @@ -580,7 +580,6 @@ export function Tables() { rows={rows} onRowClick={handleRowClick} onRowContextMenu={handleRowContextMenu} - isLoading={isLoading} /> diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 13df4e2e6b9..44866b76f9a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -757,7 +757,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel onClick={() => handleTabClick('copilot')} data-tab-button='copilot' > - Copilot + Chat )}
From 2e52a5da455eaebd9bf4e60d9c33c23622ad9145 Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Thu, 11 Jun 2026 14:15:07 -0700 Subject: [PATCH 4/4] =?UTF-8?q?fix(scheduled-tasks):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20midnight=20rollover,=20stub=20feedback,=20smooth=20?= =?UTF-8?q?Today=20scroll?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useCalendar: today was frozen at mount, so after midnight the isToday column highlight and the current-time indicator stayed on the previous day. today is now state refreshed by a sleep-resilient minute poll that only re-renders when the calendar day actually changes - CreateTaskModal: the stub submit closed silently, reading as false success; it now shows an info toast that the task was not created - ScheduleCalendar: Today presses scroll smoothly as an orientation cue; mount and scope switches keep instant positioning --- .../create-task-modal/create-task-modal.tsx | 9 +++++-- .../schedule-calendar/schedule-calendar.tsx | 12 +++++++--- .../scheduled-tasks/hooks/use-calendar.ts | 24 +++++++++++++++---- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/create-task-modal/create-task-modal.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/create-task-modal/create-task-modal.tsx index 50774daecb2..6a439694c15 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/create-task-modal/create-task-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/components/create-task-modal/create-task-modal.tsx @@ -12,6 +12,7 @@ import { ChipModalField, ChipModalFooter, ChipModalHeader, + toast, } from '@/components/emcn' import type { CalendarSlot } from '@/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-calendar' @@ -68,8 +69,12 @@ export function CreateTaskModal({ open, onOpenChange, slot, onSubmit }: CreateTa launchTime, timezone: DEFAULT_TIMEZONE, } - if (onSubmit) onSubmit(draft) - else logger.info('Scheduled task draft captured (not persisted this phase)', draft) + if (onSubmit) { + onSubmit(draft) + } else { + logger.info('Scheduled task draft captured (not persisted this phase)', draft) + toast.info('Scheduling is not available yet — this task was not created') + } close() } 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 6d04efc19de..3d29048d220 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 @@ -37,7 +37,9 @@ interface ScheduleCalendarProps { * * Scroll behavior: entering week/day scope, and "Today" presses (signaled via an * internal `scrollSignal`), center the current time in the viewport; month scope - * resets to the top. Plain prev/next navigation never re-centers. Centering is + * resets to the top. Plain prev/next navigation never re-centers. Today presses + * scroll smoothly as an orientation cue; mount and scope switches position + * instantly (animating initial placement would read as a glitch). Centering is * computed from the time-grid header height plus {@link timeToOffset} rather than * the now-line element, so it works even on first paint before the line mounts. * @@ -57,6 +59,7 @@ export function ScheduleCalendar({ eventsByHour, }: ScheduleCalendarProps) { const scrollRef = useRef(null) + const lastScrollSignalRef = useRef(0) const [scrollSignal, setScrollSignal] = useState(0) const grid = useMemo(() => buildCalendarGrid(scope, anchor, today), [scope, anchor, today]) @@ -70,14 +73,17 @@ export function ScheduleCalendar({ useEffect(() => { const region = scrollRef.current if (!region) return + const behavior: ScrollBehavior = + scrollSignal !== lastScrollSignalRef.current ? 'smooth' : 'auto' + lastScrollSignalRef.current = scrollSignal if (scope === 'month') { - region.scrollTo({ top: 0 }) + region.scrollTo({ top: 0, behavior }) return } const header = region.querySelector('[data-time-grid-header]') const headerHeight = header ? header.getBoundingClientRect().height : 0 const target = headerHeight + timeToOffset(new Date()) - region.clientHeight / 2 - region.scrollTo({ top: Math.max(0, target) }) + region.scrollTo({ top: Math.max(0, target), behavior }) }, [scope, scrollSignal]) return ( diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-calendar.ts b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-calendar.ts index d0433e893c5..8bd0acd68c3 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-calendar.ts +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-calendar.ts @@ -1,11 +1,15 @@ 'use client' -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' +import { isSameDay } from 'date-fns' import { advanceAnchor, type CalendarScope, } from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/calendar-grid' +/** How often to check whether the calendar day has rolled over. */ +const DAY_ROLLOVER_POLL_MS = 60_000 + /** A clicked calendar position: a day, optionally narrowed to an hour slot. */ export interface CalendarSlot { date: Date @@ -17,7 +21,7 @@ export interface UseCalendarReturn { scope: CalendarScope /** The focused day; week/month ranges derive from it. */ anchor: Date - /** Stable "now" for the calendar's lifetime, shared by every view. */ + /** The current calendar day, shared by every view; refreshes at midnight. */ today: Date selectedSlot: CalendarSlot | null isCreateOpen: boolean @@ -32,16 +36,26 @@ export interface UseCalendarReturn { /** * Owns the calendar's ephemeral view state (scope, anchor, selected slot, and - * create-modal open state). Pure UI state — `useState`, not a store. All - * mutations are event-driven; there are no effects. Opens on the `week` scope. + * create-modal open state). Pure UI state — `useState`, not a store. Opens on + * the `week` scope. `today` is polled so the today highlight and current-time + * column survive midnight without a remount; the poll only re-renders when the + * day actually changes (the interval is resilient to device sleep, unlike a + * one-shot timeout aimed at midnight). */ export function useCalendar(): UseCalendarReturn { - const today = useMemo(() => new Date(), []) + const [today, setToday] = useState(() => new Date()) const [scope, setScope] = useState('week') const [anchor, setAnchor] = useState(() => new Date()) const [selectedSlot, setSelectedSlot] = useState(null) const [isCreateOpen, setIsCreateOpen] = useState(false) + useEffect(() => { + const id = setInterval(() => { + setToday((current) => (isSameDay(current, new Date()) ? current : new Date())) + }, DAY_ROLLOVER_POLL_MS) + return () => clearInterval(id) + }, []) + const next = useCallback(() => setAnchor((current) => advanceAnchor(current, scope, 1)), [scope]) const prev = useCallback(() => setAnchor((current) => advanceAnchor(current, scope, -1)), [scope]) const goToday = useCallback(() => setAnchor(new Date()), [])
- {col.header} - - - + {col.header} +