Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions apps/sim/app/api/copilot/chat/resources/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
createUnauthorizedResponse,
} from '@/lib/copilot/request/http'
import type { ChatResource, ResourceType } from '@/lib/copilot/resources/persistence'
import { GENERIC_RESOURCE_TITLES } from '@/lib/copilot/resources/types'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'

const logger = createLogger('CopilotChatResourcesAPI')
Expand All @@ -27,10 +28,10 @@ const VALID_RESOURCE_TYPES = new Set<ResourceType>([
'workflow',
'knowledgebase',
'folder',
'scheduledtask',
'log',
'integration',
])
const GENERIC_TITLES = new Set(['Table', 'File', 'Workflow', 'Knowledge Base', 'Folder', 'Log'])

export const POST = withRouteHandler(async (req: NextRequest) => {
try {
Expand Down Expand Up @@ -76,7 +77,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {

let merged: ChatResource[]
if (prev) {
if (GENERIC_TITLES.has(prev.title) && !GENERIC_TITLES.has(resource.title)) {
if (GENERIC_RESOURCE_TITLES.has(prev.title) && !GENERIC_RESOURCE_TITLES.has(resource.title)) {
merged = existing.map((r) =>
`${r.type}:${r.id}` === key ? { ...r, title: resource.title } : r
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ReactNode } from 'react'
import {
Calendar,
Database,
Folder as FolderIcon,
Library,
Expand Down Expand Up @@ -79,6 +80,10 @@ export const CHAT_CONTEXT_KIND_REGISTRY: Record<ChatContextKind, ChatContextKind
label: 'File folder',
renderIcon: ({ className }) => <FolderIcon className={className} />,
},
scheduledtask: {
label: 'Scheduled task',
renderIcon: ({ className }) => <Calendar className={className} />,
},
past_chat: {
label: 'Past chat',
renderIcon: ({ className }) => <Task className={className} />,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const TOOL_ICONS: Record<string, IconComponent> = {
knowledge: Database,
knowledge_base: Database,
table: TableIcon,
scheduled_task: Calendar,
job: Calendar,
agent: AgentIcon,
custom_tool: Wrench,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client'

import { useMemo, useState } from 'react'
import { truncate } from '@sim/utils/string'
import {
Button,
DropdownMenu,
Expand Down Expand Up @@ -30,6 +31,7 @@ import { useFolders } from '@/hooks/queries/folders'
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
import { useLogsList } from '@/hooks/queries/logs'
import { useMothershipChats } from '@/hooks/queries/mothership-chats'
import { useWorkspaceSchedules } from '@/hooks/queries/schedules'
import { useTablesList } from '@/hooks/queries/tables'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useWorkspaceFileFolders } from '@/hooks/queries/workspace-file-folders'
Expand Down Expand Up @@ -77,6 +79,7 @@ export function useAvailableResources(
const { data: folders = [] } = useFolders(workspaceId)
const { data: fileFolders = [] } = useWorkspaceFileFolders(workspaceId)
const { data: tasks = [] } = useMothershipChats(workspaceId)
const { data: schedules = [] } = useWorkspaceSchedules(workspaceId)
const { data: logsData } = useLogsList(workspaceId, LOG_DROPDOWN_FILTERS)
const logs = useMemo(() => (logsData?.pages ?? []).flatMap((page) => page.logs), [logsData])

Expand Down Expand Up @@ -155,6 +158,16 @@ export function useAvailableResources(
isOpen: existingKeys.has(`task:${t.id}`),
})),
},
{
type: 'scheduledtask' as const,
items: schedules
.filter((s) => s.sourceType === 'job')
.map((s) => ({
id: s.id,
name: s.jobTitle || truncate(s.prompt ?? '', 40) || 'Scheduled Task',
isOpen: existingKeys.has(`scheduledtask:${s.id}`),
})),
},
{
type: 'log' as const,
items: logs.map((log) => {
Expand All @@ -179,6 +192,7 @@ export function useAvailableResources(
files,
knowledgeBases,
tasks,
schedules,
logs,
existingKeys,
excludeTypes,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

import { lazy, memo, Suspense, useEffect, useMemo, useRef } from 'react'
import { createLogger } from '@sim/logger'
import { format } from 'date-fns'
import { useRouter } from 'next/navigation'
import { Button, PlayOutline, Skeleton, Tooltip } from '@/components/emcn'
import {
Calendar,
Download,
FileX,
Folder as FolderIcon,
Expand All @@ -24,6 +26,7 @@ import {
import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils'
import { triggerFileDownload } from '@/lib/uploads/client/download'
import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils'
import { parseCronToHumanReadable } from '@/lib/workflows/schedules/utils'
import {
FileViewer,
type PreviewMode,
Expand All @@ -50,6 +53,7 @@ import { useUsageLimits } from '@/app/workspace/[workspaceId]/w/[workflowId]/com
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
import { useFolders } from '@/hooks/queries/folders'
import { useLogDetail } from '@/hooks/queries/logs'
import { useWorkspaceSchedules } from '@/hooks/queries/schedules'
import { downloadTableExport } from '@/hooks/queries/tables'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
Expand Down Expand Up @@ -182,6 +186,15 @@ export const ResourceContent = memo(function ResourceContent({
case 'folder':
return <EmbeddedFolder key={resource.id} workspaceId={workspaceId} folderId={resource.id} />

case 'scheduledtask':
return (
<EmbeddedScheduledTask
key={resource.id}
workspaceId={workspaceId}
scheduleId={resource.id}
/>
)

case 'log':
return (
<EmbeddedLog
Expand Down Expand Up @@ -233,6 +246,8 @@ export function ResourceActions({ workspaceId, resource }: ResourceActionsProps)
)
case 'log':
return <EmbeddedLogActions workspaceId={workspaceId} logId={resource.id} />
case 'scheduledtask':
return <EmbeddedScheduledTaskActions workspaceId={workspaceId} />
case 'folder':
case 'generic':
return null
Expand Down Expand Up @@ -647,6 +662,141 @@ function EmbeddedFolder({ workspaceId, folderId }: EmbeddedFolderProps) {
)
}

const SCHEDULE_STATUS_LABEL: Record<string, string> = {
active: 'Active',
disabled: 'Paused',
completed: 'Completed',
}

function formatScheduleInstant(iso: string | null): string {
if (!iso) return '—'
const date = new Date(iso)
return Number.isNaN(date.getTime()) ? '—' : format(date, "EEE, MMM d 'at' h:mm a")
}

interface ScheduledTaskFieldProps {
title: string
value: string
}

function ScheduledTaskField({ title, value }: ScheduledTaskFieldProps) {
return (
<div className='flex flex-col gap-1'>
<span className='text-[var(--text-muted)] text-caption'>{title}</span>
<span className='text-[var(--text-body)] text-small'>{value}</span>
</div>
)
}

interface EmbeddedScheduledTaskProps {
workspaceId: string
scheduleId: string
}

function EmbeddedScheduledTask({ workspaceId, scheduleId }: EmbeddedScheduledTaskProps) {
const { data: schedules = [], isLoading, isError } = useWorkspaceSchedules(workspaceId)
const schedule = useMemo(
() => schedules.find((s) => s.id === scheduleId),
[schedules, scheduleId]
)

if (isLoading && !schedule) return LOADING_SKELETON

if (!schedule) {
const heading = isError ? "Couldn't load scheduled task" : 'Scheduled task not found'
const detail = isError
? 'Something went wrong loading this scheduled task. Try again.'
: 'This scheduled task may have been deleted'
return (
<div className='flex h-full flex-col items-center justify-center gap-3'>
<Calendar className='size-[32px] text-[var(--text-icon)]' />
<div className='flex flex-col items-center gap-1'>
<h2 className='font-medium text-[20px] text-[var(--text-primary)]'>{heading}</h2>
<p className='text-[var(--text-body)] text-small'>{detail}</p>
</div>
</div>
)
}

const title = schedule.jobTitle || schedule.prompt || 'Scheduled task'
const timing = schedule.cronExpression
? parseCronToHumanReadable(schedule.cronExpression, schedule.timezone)
: 'Runs once'
const status = SCHEDULE_STATUS_LABEL[schedule.status] ?? schedule.status

return (
<div className='flex h-full flex-col gap-6 overflow-y-auto p-6'>
<div className='flex items-center gap-2'>
<Calendar className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<h2 className='truncate font-medium text-[16px] text-[var(--text-primary)]'>{title}</h2>
</div>

<div className='grid grid-cols-2 gap-4'>
<ScheduledTaskField title='Status' value={status} />
<ScheduledTaskField title='Schedule' value={timing} />
<ScheduledTaskField title='Next run' value={formatScheduleInstant(schedule.nextRunAt)} />
<ScheduledTaskField title='Last run' value={formatScheduleInstant(schedule.lastRanAt)} />
</div>

<div className='flex flex-col gap-1'>
<span className='text-[var(--text-muted)] text-caption'>Prompt</span>
<p className='whitespace-pre-wrap text-[var(--text-body)] text-small'>
{schedule.prompt || '—'}
</p>
</div>

{schedule.jobHistory && schedule.jobHistory.length > 0 && (
<div className='flex flex-col gap-2'>
<span className='text-[var(--text-muted)] text-caption'>Recent runs</span>
<div className='flex flex-col gap-2'>
{schedule.jobHistory.slice(0, 5).map((run, index) => (
<div
key={`${run.timestamp}-${index}`}
className='flex flex-col gap-1 rounded-[6px] bg-[var(--surface-4)] px-3 py-2'
>
<span className='text-[var(--text-tertiary)] text-caption'>
{formatScheduleInstant(run.timestamp)}
</span>
<span className='text-[var(--text-body)] text-small'>{run.summary}</span>
</div>
))}
</div>
</div>
)}
</div>
)
}

interface EmbeddedScheduledTaskActionsProps {
workspaceId: string
}

function EmbeddedScheduledTaskActions({ workspaceId }: EmbeddedScheduledTaskActionsProps) {
const router = useRouter()

const handleOpenScheduledTasks = () => {
router.push(`/workspace/${workspaceId}/scheduled-tasks`)
}

return (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='subtle'
onClick={handleOpenScheduledTasks}
className={RESOURCE_TAB_ICON_BUTTON_CLASS}
aria-label='Open in scheduled tasks'
>
<SquareArrowUpRight className={RESOURCE_TAB_ICON_CLASS} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>
<p>Open in scheduled tasks</p>
</Tooltip.Content>
</Tooltip.Root>
)
}

interface EmbeddedLogProps {
workspaceId: string
logId: string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import type { ElementType, ReactNode } from 'react'
import type { QueryClient } from '@tanstack/react-query'
import {
Calendar,
Connections,
Database,
File as FileIcon,
Expand All @@ -23,6 +24,7 @@ import { getBareIconStyle, type StyleableIcon } from '@/blocks/icon-color'
import { knowledgeKeys } from '@/hooks/queries/kb/knowledge'
import { logKeys } from '@/hooks/queries/logs'
import { mothershipChatKeys } from '@/hooks/queries/mothership-chats'
import { scheduleKeys } from '@/hooks/queries/schedules'
import { tableKeys } from '@/hooks/queries/tables'
import { folderKeys } from '@/hooks/queries/utils/folder-keys'
import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists'
Expand Down Expand Up @@ -183,6 +185,15 @@ export const RESOURCE_REGISTRY: Record<MothershipResourceType, ResourceTypeConfi
),
renderDropdownItem: (props) => <DefaultDropdownItem {...props} />,
},
scheduledtask: {
type: 'scheduledtask',
label: 'Scheduled Tasks',
icon: Calendar,
renderTabIcon: (_resource, className) => (
<Calendar className={cn(className, 'text-[var(--text-icon)]')} />
),
renderDropdownItem: (props) => <IconDropdownItem {...props} icon={Calendar} />,
},
log: {
type: 'log',
label: 'Logs',
Expand Down Expand Up @@ -241,6 +252,9 @@ const RESOURCE_INVALIDATORS: Record<
task: (qc, wId) => {
qc.invalidateQueries({ queryKey: mothershipChatKeys.list(wId) })
},
scheduledtask: (qc, wId) => {
qc.invalidateQueries({ queryKey: scheduleKeys.list(wId) })
},
log: (qc, _wId, id) => {
qc.invalidateQueries({ queryKey: logKeys.details() })
qc.invalidateQueries({ queryKey: logKeys.detail(id) })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const PORTABLE_KIND_TO_ID_FIELD = {
file: 'fileId',
folder: 'folderId',
filefolder: 'fileFolderId',
scheduledtask: 'scheduleId',
knowledge: 'knowledgeId',
past_chat: 'chatId',
workflow: 'workflowId',
Expand Down Expand Up @@ -207,6 +208,8 @@ export function chipLinkToContext(link: ParsedChipLink): ChatContext {
return { kind: 'folder', folderId: link.id, label: link.label }
case 'filefolder':
return { kind: 'filefolder', fileFolderId: link.id, label: link.label }
case 'scheduledtask':
return { kind: 'scheduledtask', scheduleId: link.id, label: link.label }
case 'knowledge':
return { kind: 'knowledge', knowledgeId: link.id, label: link.label }
case 'past_chat':
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ const RESOURCE_TO_CONTEXT: Record<
task: (r) => ({ kind: 'past_chat', chatId: r.id, label: r.title }),
log: (r) => ({ kind: 'logs', executionId: r.id, label: r.title }),
integration: (r) => ({ kind: 'integration', blockType: r.id, label: r.title }),
scheduledtask: (r) => ({ kind: 'scheduledtask', scheduleId: r.id, label: r.title }),
generic: (r) => ({ kind: 'docs', label: r.title }),
}

Expand Down
Loading
Loading