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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import type { ReactNode } from 'react'
import {
Calendar,
Database,
Folder as FolderIcon,
Library,
Expand DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
'use client'

import { useMemo, useState } from 'react'
import { truncate } from '@sim/utils/string'
import {
Button,
DropdownMenu,
Expand DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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