Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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,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,143 @@ 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
Comment thread
waleedlatif1 marked this conversation as resolved.

if (!schedule) {
// A failed list query also yields no schedule; keep that distinct from a
// genuinely missing task so we don't tell the user it was deleted.
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
1 change: 1 addition & 0 deletions apps/sim/lib/api/contracts/copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ const copilotResourceTypeSchema = z.enum([
'workflow',
'knowledgebase',
'folder',
'scheduledtask',
'log',
])

Expand Down
8 changes: 5 additions & 3 deletions apps/sim/lib/copilot/resources/persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { copilotChats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { eq, sql } from 'drizzle-orm'
import type { MothershipResource } from './types'
import { GENERIC_RESOURCE_TITLES, type MothershipResource } from './types'

export {
extractDeletedResourcesFromToolResult,
Expand Down Expand Up @@ -42,7 +42,6 @@ export async function persistChatResources(

const existing = Array.isArray(chat.resources) ? (chat.resources as ChatResource[]) : []
const map = new Map<string, ChatResource>()
const GENERIC = new Set(['Table', 'File', 'Workflow', 'Knowledge Base', 'Folder', 'Log'])

for (const r of existing) {
map.set(`${r.type}:${r.id}`, r)
Expand All @@ -51,7 +50,10 @@ export async function persistChatResources(
for (const r of toMerge) {
const key = `${r.type}:${r.id}`
const prev = map.get(key)
if (!prev || (GENERIC.has(prev.title) && !GENERIC.has(r.title))) {
if (
!prev ||
(GENERIC_RESOURCE_TITLES.has(prev.title) && !GENERIC_RESOURCE_TITLES.has(r.title))
) {
map.set(key, r)
}
}
Expand Down
17 changes: 17 additions & 0 deletions apps/sim/lib/copilot/resources/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const MothershipResourceType = {
folder: 'folder',
filefolder: 'filefolder',
task: 'task',
scheduledtask: 'scheduledtask',
log: 'log',
integration: 'integration',
generic: 'generic',
Expand All @@ -24,6 +25,22 @@ export function isEphemeralResource(resource: MothershipResource): boolean {
return resource.type === 'generic' || resource.id === 'streaming-file'
}

/**
* Placeholder resource titles emitted before a specific name is known. A more
* specific title may overwrite one of these during dedup; a specific title is
* never downgraded back to a placeholder. Shared by the chat-resource route and
* the server-side persistence merge so the two stay in lockstep.
*/
export const GENERIC_RESOURCE_TITLES = new Set<string>([
'Table',
'File',
'Workflow',
'Knowledge Base',
'Folder',
'Scheduled Task',
'Log',
])

export const VFS_DIR_TO_RESOURCE: Record<string, MothershipResourceType> = {
tables: 'table',
files: 'file',
Expand Down
Loading