diff --git a/frontend/src/components/agent/FilterBadge.tsx b/frontend/src/components/agent/FilterBadge.tsx new file mode 100644 index 000000000..ea351bf6a --- /dev/null +++ b/frontend/src/components/agent/FilterBadge.tsx @@ -0,0 +1,45 @@ +import React from 'react' +import { X as LucideX } from 'lucide-react' + +interface FilterBadgeProps { + filters: string[] + onRemoveFilter: (index: number) => void +} + +export const FilterBadge: React.FC = ({ + filters, + onRemoveFilter, +}) => { + if (!filters || filters.length === 0) { + return ( + + ) + } + + return ( + <> + {filters.map((part, idx) => ( +
+ {part} + +
+ ))} + + ) +} diff --git a/frontend/src/components/agent/GmailPeopleFilter.tsx b/frontend/src/components/agent/GmailPeopleFilter.tsx new file mode 100644 index 000000000..724d3b29e --- /dev/null +++ b/frontend/src/components/agent/GmailPeopleFilter.tsx @@ -0,0 +1,174 @@ +import React, { useState, useEffect } from 'react' +import { Plus, X as LucideX } from 'lucide-react' + +interface GmailPeopleFields { + from: string[] + to: string[] + cc: string[] + bcc: string[] +} + +interface GmailPeopleFilterProps { + filterValue?: string + onFilterChange: (value: string) => void +} + +export const GmailPeopleFilter: React.FC = ({ + filterValue, + onFilterChange, +}) => { + const [peopleFields, setPeopleFields] = useState({ + from: [], + to: [], + cc: [], + bcc: [], + }) + + const [peopleInputs, setPeopleInputs] = useState<{ + from: string + to: string + cc: string + bcc: string + }>({ + from: '', + to: '', + cc: '', + bcc: '', + }) + + // Parse existing filter values on mount or when filterValue changes + useEffect(() => { + if (!filterValue) return + + const filters = filterValue.split(', ').filter(f => f.trim()) + const newFields: GmailPeopleFields = { + from: [], + to: [], + cc: [], + bcc: [], + } + + filters.forEach(filter => { + if (filter.startsWith('from:')) { + newFields.from.push(filter.substring(5)) + } else if (filter.startsWith('to:')) { + newFields.to.push(filter.substring(3)) + } else if (filter.startsWith('cc:')) { + newFields.cc.push(filter.substring(3)) + } else if (filter.startsWith('bcc:')) { + newFields.bcc.push(filter.substring(4)) + } + }) + + setPeopleFields(newFields) + }, [filterValue]) + + const buildFilterString = (fields: GmailPeopleFields) => { + const filterParts: string[] = [] + if (fields.from.length > 0) filterParts.push(...fields.from.map(e => `from:${e}`)) + if (fields.to.length > 0) filterParts.push(...fields.to.map(e => `to:${e}`)) + if (fields.cc.length > 0) filterParts.push(...fields.cc.map(e => `cc:${e}`)) + if (fields.bcc.length > 0) filterParts.push(...fields.bcc.map(e => `bcc:${e}`)) + + // Preserve existing timeline filters from the current filterValue + const currentFilters = filterValue?.split(', ').filter(f => f.trim()) || [] + const existingTimelineFilters = currentFilters.filter(f => f.startsWith('~')) + const combinedFilters = [...filterParts, ...existingTimelineFilters] + + return combinedFilters.join(', ') + } + + const addEmail = (field: keyof GmailPeopleFields) => { + const email = peopleInputs[field].trim() + if (!email) return + + const newFields = { + ...peopleFields, + [field]: [...peopleFields[field], email], + } + setPeopleFields(newFields) + setPeopleInputs(prev => ({ + ...prev, + [field]: '', + })) + + onFilterChange(buildFilterString(newFields)) + } + + const removeEmail = (field: keyof GmailPeopleFields, idx: number) => { + const newEmails = peopleFields[field].filter((_, i) => i !== idx) + const newFields = { + ...peopleFields, + [field]: newEmails, + } + setPeopleFields(newFields) + onFilterChange(buildFilterString(newFields)) + } + + const handleKeyDown = (field: keyof GmailPeopleFields, e: React.KeyboardEvent) => { + e.stopPropagation() + if (e.key === 'Enter' && peopleInputs[field].trim()) { + addEmail(field) + } + } + + return ( +
+ {(['from', 'to', 'cc', 'bcc'] as const).map((field) => ( +
+ +
+ { + setPeopleInputs(prev => ({ + ...prev, + [field]: e.target.value, + })) + }} + onKeyDown={(e) => handleKeyDown(field, e)} + onClick={(e) => e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + className="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200" + /> + +
+ {/* Display added emails as pills */} + {peopleFields[field].length > 0 && ( +
+ {peopleFields[field].map((email, idx) => ( +
+ {email} + +
+ ))} +
+ )} +
+ ))} +
+ ) +} diff --git a/frontend/src/components/agent/SlackChannelFilter.tsx b/frontend/src/components/agent/SlackChannelFilter.tsx new file mode 100644 index 000000000..43dd727de --- /dev/null +++ b/frontend/src/components/agent/SlackChannelFilter.tsx @@ -0,0 +1,123 @@ +import React, { useState, useEffect } from 'react' +import { DropdownMenuItem } from '@/components/ui/dropdown-menu' +import { useSlackData } from '@/hooks/useSlackData' +import { SlackEntity } from 'shared/types' + +interface SlackChannelFilterProps { + filterValue?: string + onFilterChange: (value: string) => void +} + +export const SlackChannelFilter: React.FC = ({ + filterValue, + onFilterChange, +}) => { + const [selectedChannels, setSelectedChannels] = useState>(new Set()) + + const { + items: slackChannels, + searchQuery, + isLoading, + containerRef, + handleSearch, + handleScroll, + fetchItems, + } = useSlackData({ entity: SlackEntity.Channel }) + + // Load initial channels on mount + useEffect(() => { + fetchItems('', 0, false) + }, [fetchItems]) + + // Parse existing filter values to set selected channels + useEffect(() => { + if (!filterValue) return + + const filters = filterValue.split(', ').filter(f => f.trim()) + const channelIds = filters.filter(f => f.startsWith('#')).map(f => f.substring(1)) + + setSelectedChannels(new Set(channelIds)) + }, [filterValue]) + + const handleChannelSelect = (channel: { id: string; name: string }) => { + const updatedChannels = new Set(selectedChannels) + if (updatedChannels.has(channel.id)) { + updatedChannels.delete(channel.id) + } else { + updatedChannels.add(channel.id) + } + setSelectedChannels(updatedChannels) + + // Build filter string from selected channels + const selectedChannelIds = Array.from(updatedChannels).map(id => `#${id}`) + + // Preserve existing filters from current filterValue that aren't channel filters + const currentFilters = filterValue?.split(', ').filter(f => f.trim()) || [] + const existingNonChannelFilters = currentFilters.filter(f => !f.startsWith('#')) + + // Combine new channel filters with existing non-channel filters + const combinedFilters = [...selectedChannelIds, ...existingNonChannelFilters] + + onFilterChange(combinedFilters.join(', ')) + } + + return ( + <> +
+
+ handleSearch(e.target.value)} + onClick={(e) => e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200" + /> +
+
+
+ {isLoading && slackChannels.length === 0 ? ( +
+ Loading channels... +
+ ) : slackChannels.length === 0 ? ( +
+ No channels found +
+ ) : ( + <> + {slackChannels.map((channel: { id: string; name: string }) => ( + { + e.preventDefault() + handleChannelSelect(channel) + }} + className="flex items-center cursor-pointer text-sm py-2 px-2 hover:!bg-gray-100 dark:hover:!bg-gray-700 focus:!bg-gray-100 dark:focus:!bg-gray-700 data-[highlighted]:!bg-gray-100 dark:data-[highlighted]:!bg-gray-700 rounded" + > + {}} + /> + {channel.name} + + ))} + {isLoading && ( +
+ Loading more... +
+ )} + + )} +
+ + ) +} diff --git a/frontend/src/components/agent/SlackPeopleFilter.tsx b/frontend/src/components/agent/SlackPeopleFilter.tsx new file mode 100644 index 000000000..e703622db --- /dev/null +++ b/frontend/src/components/agent/SlackPeopleFilter.tsx @@ -0,0 +1,123 @@ +import React, { useState, useEffect } from 'react' +import { DropdownMenuItem } from '@/components/ui/dropdown-menu' +import { useSlackData } from '@/hooks/useSlackData' +import { SlackEntity } from 'shared/types' + +interface SlackPeopleFilterProps { + filterValue?: string + onFilterChange: (value: string) => void +} + +export const SlackPeopleFilter: React.FC = ({ + filterValue, + onFilterChange, +}) => { + const [selectedPeople, setSelectedPeople] = useState>(new Set()) + + const { + items: slackUsers, + searchQuery, + isLoading, + containerRef, + handleSearch, + handleScroll, + fetchItems, + } = useSlackData({ entity: SlackEntity.User }) + + // Load initial users on mount + useEffect(() => { + fetchItems('', 0, false) + }, [fetchItems]) + + // Parse existing filter values to set selected people + useEffect(() => { + if (!filterValue) return + + const filters = filterValue.split(', ').filter(f => f.trim()) + const peopleIds = filters.filter(f => f.startsWith('@')).map(f => f.substring(1)) + + setSelectedPeople(new Set(peopleIds)) + }, [filterValue]) + + const handlePersonSelect = (person: { id: string; name: string }) => { + const updatedPeople = new Set(selectedPeople) + if (updatedPeople.has(person.id)) { + updatedPeople.delete(person.id) + } else { + updatedPeople.add(person.id) + } + setSelectedPeople(updatedPeople) + + // Build filter string from selected people using docIds (same as channels) + const selectedPeopleIds = Array.from(updatedPeople).map(id => `@${id}`) + + // Preserve existing filters from current filterValue that aren't people filters + const currentFilters = filterValue?.split(', ').filter(f => f.trim()) || [] + const existingNonPeopleFilters = currentFilters.filter(f => !f.startsWith('@')) + + // Combine new people filters with existing non-people filters + const combinedFilters = [...selectedPeopleIds, ...existingNonPeopleFilters] + + onFilterChange(combinedFilters.join(', ')) + } + + return ( + <> +
+
+ handleSearch(e.target.value)} + onClick={(e) => e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200" + /> +
+
+
+ {isLoading && slackUsers.length === 0 ? ( +
+ Loading users... +
+ ) : slackUsers.length === 0 ? ( +
+ No users found +
+ ) : ( + <> + {slackUsers.map((person: { id: string; name: string }) => ( + { + e.preventDefault() + handlePersonSelect(person) + }} + className="flex items-center cursor-pointer text-sm py-2 px-2 hover:!bg-gray-100 dark:hover:!bg-gray-700 focus:!bg-gray-100 dark:focus:!bg-gray-700 data-[highlighted]:!bg-gray-100 dark:data-[highlighted]:!bg-gray-700 rounded" + > + {}} + /> + {person.name} + + ))} + {isLoading && ( +
+ Loading more... +
+ )} + + )} +
+ + ) +} diff --git a/frontend/src/components/agent/TimelineFilter.tsx b/frontend/src/components/agent/TimelineFilter.tsx new file mode 100644 index 000000000..4ec9b9b72 --- /dev/null +++ b/frontend/src/components/agent/TimelineFilter.tsx @@ -0,0 +1,280 @@ +import React, { useState, useEffect } from 'react' +import { DropdownMenuItem } from '@/components/ui/dropdown-menu' +import { ChevronLeft, ChevronRight } from 'lucide-react' + +interface TimelineFilterProps { + filterValue?: string + onFilterChange: (value: string) => void + slackUsers?: Array<{ id: string; name: string }> + selectedPeople?: Set + selectedChannels?: Set +} + +interface DateRangePickerProps { + dateRange: { start: Date | null; end: Date | null } + setDateRange: React.Dispatch> + currentMonth: Date + setCurrentMonth: React.Dispatch> + onApply: () => void + onCancel: () => void +} + +const DateRangePicker: React.FC = ({ + dateRange, + setDateRange, + currentMonth, + setCurrentMonth, + onApply, + onCancel, +}) => { + const daysInMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 0).getDate() + const firstDayOfMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth(), 1).getDay() + + const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'] + const dayNames = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'] + + const handleDateClick = (day: number) => { + const clickedDate = new Date(currentMonth.getFullYear(), currentMonth.getMonth(), day) + + if (!dateRange.start || (dateRange.start && dateRange.end)) { + setDateRange({ start: clickedDate, end: null }) + } else { + if (clickedDate < dateRange.start) { + setDateRange({ start: clickedDate, end: dateRange.start }) + } else { + setDateRange({ start: dateRange.start, end: clickedDate }) + } + } + } + + const isDateInRange = (day: number) => { + if (!dateRange.start) return false + const date = new Date(currentMonth.getFullYear(), currentMonth.getMonth(), day) + if (dateRange.end) { + return date >= dateRange.start && date <= dateRange.end + } + return date.getTime() === dateRange.start.getTime() + } + + const isDateSelected = (day: number) => { + const date = new Date(currentMonth.getFullYear(), currentMonth.getMonth(), day) + return (dateRange.start && date.getTime() === dateRange.start.getTime()) || + (dateRange.end && date.getTime() === dateRange.end.getTime()) + } + + const previousMonth = () => { + setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1)) + } + + const nextMonth = () => { + setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1)) + } + + return ( +
+
+

+ {monthNames[currentMonth.getMonth()]} {currentMonth.getFullYear()} +

+
+ + +
+
+ +
+ {dayNames.map(day => ( +
+ {day} +
+ ))} +
+ +
+ {Array.from({ length: firstDayOfMonth }).map((_, i) => ( +
+ ))} + + {Array.from({ length: daysInMonth }).map((_, i) => { + const day = i + 1 + const inRange = isDateInRange(day) + const selected = isDateSelected(day) + + return ( + + ) + })} +
+ +
+ + +
+
+ ) +} + +export const TimelineFilter: React.FC = ({ + filterValue, + onFilterChange, + slackUsers = [], + selectedPeople = new Set(), + selectedChannels = new Set(), +}) => { + const [selectedTimelines, setSelectedTimelines] = useState>(new Set()) + const [showDatePicker, setShowDatePicker] = useState(false) + const [dateRange, setDateRange] = useState<{ start: Date | null; end: Date | null }>({ + start: null, + end: null, + }) + const [currentMonth, setCurrentMonth] = useState(new Date()) + + // Parse existing timeline filters + useEffect(() => { + if (!filterValue) return + + const filters = filterValue.split(', ').filter(f => f.trim()) + const timelineFilters = filters.filter(f => f.startsWith('~')).map(f => f.substring(1)) + setSelectedTimelines(new Set(timelineFilters)) + }, [filterValue]) + + const handleTimelineSelect = (timelineOption: { label: string; value: string }) => { + if (timelineOption.label === 'Custom date') { + setShowDatePicker(true) + return + } + + const updatedTimelines = new Set(selectedTimelines) + if (updatedTimelines.has(timelineOption.label)) { + updatedTimelines.delete(timelineOption.label) + } else { + updatedTimelines.add(timelineOption.label) + } + setSelectedTimelines(updatedTimelines) + + // Build filter string + const selectedTimelineNames = Array.from(updatedTimelines).map(t => `~${t}`) + + // Preserve existing non-timeline filters + const currentFilters = filterValue?.split(', ').filter(f => f.trim()) || [] + const existingNonTimelineFilters = currentFilters.filter(f => !f.startsWith('~')) + + const combinedFilters = [...selectedTimelineNames, ...existingNonTimelineFilters] + onFilterChange(combinedFilters.join(', ')) + } + + const handleDateRangeApply = () => { + if (dateRange.start && dateRange.end) { + const formatDate = (date: Date) => { + const day = String(date.getDate()).padStart(2, '0') + const month = String(date.getMonth() + 1).padStart(2, '0') + const year = date.getFullYear() + return `${day}/${month}/${year}` + } + + const dateRangeString = `${formatDate(dateRange.start)} → ${formatDate(dateRange.end)}` + + const updatedTimelines = new Set(selectedTimelines) + updatedTimelines.add(dateRangeString) + setSelectedTimelines(updatedTimelines) + + // Build filter string + const selectedPeopleIds = Array.from(selectedPeople ?? new Set()).map(id => `@${id}`) + + const selectedChannelIds = Array.from(selectedChannels).map(id => `#${id}`) + const selectedTimelineNames = Array.from(updatedTimelines).map(t => `~${t}`) + + // Preserve existing Gmail people filters + const currentFilters = filterValue?.split(', ').filter(f => f.trim()) || [] + const existingGmailFilters = currentFilters.filter(f => + f.startsWith('from:') || f.startsWith('to:') || f.startsWith('cc:') || f.startsWith('bcc:') + ) + + const combinedNames = [...existingGmailFilters, ...selectedPeopleIds, ...selectedChannelIds, ...selectedTimelineNames].join(', ') + onFilterChange(combinedNames) + + setShowDatePicker(false) + setDateRange({ start: null, end: null }) + } + } + + return ( + <> + {!showDatePicker ? ( +
+ {[ + { label: 'Last week', value: 'last_week' }, + { label: 'Last month', value: 'last_month' }, + { label: 'Last 7 days', value: 'last_7_days' }, + { label: 'Last 14 days', value: 'last_14_days' }, + { label: 'Custom date', value: 'custom_date' } + ].map((timelineOption) => ( + { + e.preventDefault() + handleTimelineSelect(timelineOption) + }} + className="flex items-center cursor-pointer text-sm py-2 px-2 hover:!bg-gray-100 dark:hover:!bg-gray-700 focus:!bg-gray-100 dark:focus:!bg-gray-700 data-[highlighted]:!bg-gray-100 dark:data-[highlighted]:!bg-gray-700 rounded" + > + {}} + /> + {timelineOption.label} + + ))} +
+ ) : ( +
+ { + setShowDatePicker(false) + setDateRange({ start: null, end: null }) + }} + /> +
+ )} + + ) +} diff --git a/frontend/src/hooks/useSlackData.ts b/frontend/src/hooks/useSlackData.ts new file mode 100644 index 000000000..255075771 --- /dev/null +++ b/frontend/src/hooks/useSlackData.ts @@ -0,0 +1,106 @@ +import { useState, useCallback, useRef } from 'react' +import { api } from '@/api' +import { useToast } from '@/hooks/use-toast' +import { SlackEntity } from 'shared/types' + +export interface SlackItem { + id: string + name: string +} + +interface UseSlackDataOptions { + entity: SlackEntity + enabled?: boolean +} + +export const useSlackData = ({ entity, enabled = true }: UseSlackDataOptions) => { + const { toast } = useToast() + const [items, setItems] = useState([]) + const [searchQuery, setSearchQuery] = useState('') + const [offset, setOffset] = useState(0) + const [hasMore, setHasMore] = useState(true) + const [isLoading, setIsLoading] = useState(false) + const containerRef = useRef(null) + + const fetchItems = useCallback(async (query: string = '', currentOffset: number = 0, append: boolean = false) => { + if (!enabled) return + + setIsLoading(true) + try { + const limit = currentOffset + 50 + const queryParams: any = { + entity, + limit: limit.toString(), + offset: currentOffset.toString(), + } + + if (query && query.trim()) { + queryParams.query = query.trim() + } + + const response = await api.slack.entities.$get({ + query: queryParams + }) + + if (response.ok) { + const data = await response.json() + const fetchedItems = data.results?.root?.children?.map((child: any) => ({ + id: child.fields?.docId || child.id, + name: child.fields?.name || `Unknown ${entity}`, + })) || [] + + if (append) { + setItems(prev => [...prev, ...fetchedItems]) + } else { + setItems(fetchedItems) + } + + setHasMore(fetchedItems.length === 50) + } else { + toast.error({ + title: 'Error', + description: `Failed to fetch Slack ${entity}s`, + }) + } + } catch (error) { + console.error(`Error fetching Slack ${entity}s:`, error) + toast.error({ + title: 'Error', + description: `An error occurred while fetching Slack ${entity}s`, + }) + } finally { + setIsLoading(false) + } + }, [entity, enabled, toast]) + + const handleSearch = useCallback((query: string) => { + setSearchQuery(query) + setOffset(0) + fetchItems(query, 0, false) + }, [fetchItems]) + + const handleScroll = useCallback(() => { + const container = containerRef.current + if (!container || isLoading || !hasMore) return + + const { scrollTop, scrollHeight, clientHeight } = container + const scrollThreshold = 50 + + if (scrollHeight - scrollTop - clientHeight < scrollThreshold) { + const newOffset = offset + 50 + setOffset(newOffset) + fetchItems(searchQuery, newOffset, true) + } + }, [offset, searchQuery, isLoading, hasMore, fetchItems]) + + return { + items, + searchQuery, + isLoading, + hasMore, + containerRef, + handleSearch, + handleScroll, + fetchItems, + } +} diff --git a/frontend/src/routes/_authenticated/agent.tsx b/frontend/src/routes/_authenticated/agent.tsx index d093c0e81..0311f2819 100644 --- a/frontend/src/routes/_authenticated/agent.tsx +++ b/frontend/src/routes/_authenticated/agent.tsx @@ -28,7 +28,6 @@ import { type Citation, type SelectPublicAgent, type AttachmentMetadata, - SlackEntity, AgentPromptPayload, DEFAULT_TEST_AGENT_ID, } from "shared/types" @@ -38,7 +37,6 @@ import { X as LucideX, RotateCcw, RefreshCw, - PlusCircle, Plus, Copy, ArrowLeft, @@ -48,11 +46,14 @@ import { UserPlus, Star, Users, + User, Sparkles, ChevronLeft, ChevronRight, BookOpen, Eye, + SlidersHorizontal, + CalendarDays } from "lucide-react" import React, { useState, useMemo, useEffect, useRef, useCallback } from "react" import { useTheme } from "@/components/ThemeContext" @@ -79,6 +80,11 @@ import { GoogleDriveNavigation } from "@/components/GoogleDriveNavigation" import { CollectionNavigation } from "@/components/CollectionNavigation" import ViewAgent from "@/components/ViewAgent" import agentEmptyStateIcon from "@/assets/emptystateIcons/agent.png" +import { GmailPeopleFilter } from "@/components/agent/GmailPeopleFilter" +import { SlackPeopleFilter } from "@/components/agent/SlackPeopleFilter" +import { SlackChannelFilter } from "@/components/agent/SlackChannelFilter" +import { TimelineFilter } from "@/components/agent/TimelineFilter" +import { FilterBadge } from "@/components/agent/FilterBadge" type CurrentResp = { resp: string @@ -101,6 +107,10 @@ interface CustomBadgeProps { text: string onRemove: () => void icon?: React.ReactNode + appId?: string + filterValue?: string + onFilterChange?: (value: string) => void + filterIndex?: number } interface FetchedDataSource { @@ -110,18 +120,263 @@ interface FetchedDataSource { entity: string } -const CustomBadge: React.FC = ({ text, onRemove, icon }) => { +const CustomBadge: React.FC = ({ + text, + onRemove, + icon, + appId, + filterValue, + onFilterChange, +}) => { + // Only show filter input for Gmail and Slack + const showFilterInput = appId === Apps.Gmail || appId === Apps.Slack + + // State for filter dropdown + const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false) + const [filterNavigationPath, setFilterNavigationPath] = useState>([]) + + // State for tracking selected items (needed for Timeline filter) + const [selectedPeople, setSelectedPeople] = useState>(new Set()) + const [selectedChannels, setSelectedChannels] = useState>(new Set()) + + // Parse selected people and channels from filterValue + useEffect(() => { + if (!filterValue) return + + const filters = filterValue.split(', ').filter(f => f.trim()) + + if (appId === Apps.Slack) { + // Parse people names (convert to IDs when needed) + const peopleNames = filters.filter(f => f.startsWith('@')).map(f => f.substring(1)) + setSelectedPeople(new Set(peopleNames)) + + // Parse channel IDs + const channelIds = filters.filter(f => f.startsWith('#')).map(f => f.substring(1)) + setSelectedChannels(new Set(channelIds)) + } + }, [filterValue, appId]) + + // Define filter options based on app + const getFilterOptions = () => { + if (appId === Apps.Slack) { + return [ + { label: 'People', value: '@people' }, + { label: 'Channels', value: '#channel' }, + { label: 'Timeline', value: '~timeline' } + ] + } else if (appId === Apps.Gmail) { + return [ + { label: 'People', value: '@people' }, + { label: 'Timeline', value: '~timeline' } + ] + } + return [] + } + + // Get icon for filter option + const getFilterIcon = (label: string) => { + switch (label) { + case 'People': + return + case 'Channels': + return # + case 'Timeline': + return + default: + return null + } + } + + const handleFilterOptionSelect = (option: { label: string; value: string }) => { + if (option.label === 'People') { + setFilterNavigationPath([ + { id: 'people', name: 'People', type: 'people' } + ]) + } else if (option.label === 'Channels') { + setFilterNavigationPath([ + { id: 'channels', name: 'Channels', type: 'channels' } + ]) + } else if (option.label === 'Timeline') { + setFilterNavigationPath([ + { id: 'timeline', name: 'Timeline', type: 'timeline' } + ]) + } + } + + const handleRemoveFilter = (index: number) => { + const parts = filterValue?.split(', ').filter(p => p.trim()) || [] + const part = parts[index] + const newParts = parts.filter((_, i) => i !== index) + onFilterChange?.(newParts.join(', ')) + + // Update state based on filter type + if (part?.startsWith('#')) { + const channelId = part.substring(1) + setSelectedChannels(prev => { + const newSet = new Set(prev) + newSet.delete(channelId) + return newSet + }) + } else if (part?.startsWith('@')) { + const personId = part.substring(1) + setSelectedPeople(prev => { + const newSet = new Set(prev) + newSet.delete(personId) + return newSet + }) + } + } + return ( -
- {icon && {icon}} - {text} - { - e.stopPropagation() - onRemove() - }} - /> +
+ {/* Fixed width section for app icon, name, and trash */} +
+ {icon && {icon}} + {text} + { + e.stopPropagation() + onRemove() + }} + /> +
+ {/* Filter input takes remaining space */} + {showFilterInput && ( +
+
+ { + setIsFilterDropdownOpen(open) + if (!open) { + setFilterNavigationPath([]) + } + }} + > + + + + +
+
+
+ {filterNavigationPath.length > 0 && ( + + )} + {filterNavigationPath.length > 0 ? ( +
+ { + setFilterNavigationPath([]) + }} + > + FILTERS + + {filterNavigationPath.map((item, index) => ( + + / + + {item.name} + + + ))} +
+ ) : ( + + FILTERS + + )} +
+
+
+
+ {filterNavigationPath.length === 0 ? ( + // Main filter menu + <> + {getFilterOptions().map((option) => ( + { + e.preventDefault() + handleFilterOptionSelect(option) + }} + className="flex items-center justify-between cursor-pointer text-sm py-2.5 px-4 hover:!bg-transparent focus:!bg-transparent data-[highlighted]:!bg-transparent" + > +
+ + {getFilterIcon(option.label)} + + + {option.label} + +
+ +
+ ))} + + ) : filterNavigationPath[filterNavigationPath.length - 1]?.type === 'people' ? ( + // People selection view - different for Gmail vs Slack + appId === 'gmail' ? ( + {})} + /> + ) : ( + {})} + /> + ) + ) : filterNavigationPath[filterNavigationPath.length - 1]?.type === 'channels' ? ( + {})} + /> + ) : filterNavigationPath[filterNavigationPath.length - 1]?.type === 'timeline' ? ( + {})} + selectedPeople={selectedPeople} + selectedChannels={selectedChannels} + /> + ) : null} +
+
+
+
+ f.trim()) || []} + onRemoveFilter={handleRemoveFilter} + /> +
+
+
+ )}
) } @@ -190,11 +445,6 @@ export const availableIntegrationsList: IntegrationSource[] = [ }, ] -const AGENT_ENTITY_SEARCH_EXCLUSIONS: { app: string; entity: string }[] = [ - { app: Apps.Slack, entity: SlackEntity.Message }, - { app: Apps.Slack, entity: SlackEntity.User }, -] - interface User { id: number name: string @@ -383,15 +633,9 @@ function AgentComponent() { const [selectedIntegrations, setSelectedIntegrations] = useState< Record >({}) + // State for managing multiple filters per app (Gmail and Slack) + const [appFilters, setAppFilters] = useState>({}) const [isIntegrationMenuOpen, setIsIntegrationMenuOpen] = useState(false) - const [selectedEntities, setSelectedEntities] = useState( - [], - ) - const [entitySearchQuery, setEntitySearchQuery] = useState("") - const [entitySearchResults, setEntitySearchResults] = useState< - FetchedDataSource[] - >([]) - const [showEntitySearchResults, setShowEntitySearchResults] = useState(false) const [selectedItemsInCollection, setSelectedItemsInCollection] = useState< Record> >({}) @@ -647,57 +891,6 @@ function AgentComponent() { const { user, agentWhiteList } = matches[matches.length - 1].context const { toast } = useToast() - useEffect(() => { - if (entitySearchQuery.trim() === "") { - setEntitySearchResults([]) - setShowEntitySearchResults(false) - return - } - - const searchEntities = async () => { - try { - const response = await api.search.$get({ - query: { - query: entitySearchQuery, - app: Apps.Slack, - isAgentIntegSearch: true, - }, - }) - - if (response.ok) { - const data = await response.json() - // @ts-ignore - const results = (data.results || []) as FetchedDataSource[] - - const selectedEntityIds = new Set( - selectedEntities.map((entity) => entity.docId), - ) - - const filteredResults = results.filter((r) => { - const isAlreadySelected = selectedEntityIds.has(r.docId) - - const isExcluded = AGENT_ENTITY_SEARCH_EXCLUSIONS.some( - (exclusion) => - exclusion.app === r.app && exclusion.entity === r.entity, - ) - - return !isAlreadySelected && !isExcluded - }) - setEntitySearchResults(filteredResults) - setShowEntitySearchResults(true) - } - } catch (error) { - console.error("Failed to search entities", error) - } - } - - const debounceSearch = setTimeout(() => { - searchEntities() - }, 300) - - return () => clearTimeout(debounceSearch) - }, [entitySearchQuery, selectedEntities]) - const [users, setUsers] = useState([]) const [searchQuery, setSearchQuery] = useState("") const [filteredUsers, setFilteredUsers] = useState([]) @@ -1212,7 +1405,7 @@ function AgentComponent() { setIsGeneratingPrompt(false) setShouldHighlightPrompt(false) cleanupPromptGenerationEventSource() - setSelectedEntities([]) + setAppFilters({}) } const handleCreateNewAgent = () => { @@ -1682,12 +1875,6 @@ function AgentComponent() { } }, [editingAgent, viewMode, allAvailableIntegrations]) - useEffect(() => { - if (editingAgent && (viewMode === "create" || viewMode === "edit")) { - setSelectedEntities(editingAgent.docIds || []) - } - }, [editingAgent, viewMode]) - useEffect(() => { if (editingAgent && (viewMode === "create" || viewMode === "edit")) { // Load existing user permissions only for private agents @@ -1716,6 +1903,107 @@ function AgentComponent() { } else if (editingAgent.isPublic) { setSelectedUsers([]) // Clear users for public agents } + + // Load existing filters from appIntegrations + if (editingAgent.appIntegrations && typeof editingAgent.appIntegrations === 'object') { + const appIntegrations = editingAgent.appIntegrations as Record + const loadedFilters: Record = {} + + // Check for Gmail filters + if (appIntegrations.gmail?.filters && Array.isArray(appIntegrations.gmail.filters)) { + const gmailFilterStrings: string[] = [] + + for (const filter of appIntegrations.gmail.filters) { + const filterParts: string[] = [] + + // Add from emails + if (filter.from && Array.isArray(filter.from)) { + filterParts.push(...filter.from.map((email: string) => `from:${email}`)) + } + + // Add to emails + if (filter.to && Array.isArray(filter.to)) { + filterParts.push(...filter.to.map((email: string) => `to:${email}`)) + } + + // Add cc emails + if (filter.cc && Array.isArray(filter.cc)) { + filterParts.push(...filter.cc.map((email: string) => `cc:${email}`)) + } + + // Add bcc emails + if (filter.bcc && Array.isArray(filter.bcc)) { + filterParts.push(...filter.bcc.map((email: string) => `bcc:${email}`)) + } + + // Add time range + if (filter.timeRange) { + const { startDate, endDate } = filter.timeRange + // Convert timestamps to readable format + const start = new Date(startDate * 1000) + const end = new Date(endDate * 1000) + const formatDate = (date: Date) => { + const day = String(date.getDate()).padStart(2, '0') + const month = String(date.getMonth() + 1).padStart(2, '0') + const year = date.getFullYear() + return `${day}/${month}/${year}` + } + filterParts.push(`~${formatDate(start)} → ${formatDate(end)}`) + } + + if (filterParts.length > 0) { + gmailFilterStrings.push(filterParts.join(', ')) + } + } + + if (gmailFilterStrings.length > 0) { + loadedFilters.gmail = gmailFilterStrings + } + } + + // Check for Slack filters + if (appIntegrations.slack?.filters && Array.isArray(appIntegrations.slack.filters)) { + const slackFilterStrings: string[] = [] + + for (const filter of appIntegrations.slack.filters) { + const filterParts: string[] = [] + + // Add sender IDs (would need to be converted to names via API) + if (filter.senderId && Array.isArray(filter.senderId)) { + filterParts.push(...filter.senderId.map((id: string) => `@${id}`)) + } + + // Add channel IDs (docIds) + if (filter.channelId && Array.isArray(filter.channelId)) { + filterParts.push(...filter.channelId.map((id: string) => `#${id}`)) + } + + // Add time range + if (filter.timeRange) { + const { startDate, endDate } = filter.timeRange + const start = new Date(startDate * 1000) + const end = new Date(endDate * 1000) + const formatDate = (date: Date) => { + const day = String(date.getDate()).padStart(2, '0') + const month = String(date.getMonth() + 1).padStart(2, '0') + const year = date.getFullYear() + return `${day}/${month}/${year}` + } + filterParts.push(`~${formatDate(start)} → ${formatDate(end)}`) + } + + if (filterParts.length > 0) { + slackFilterStrings.push(filterParts.join(', ')) + } + } + + if (slackFilterStrings.length > 0) { + loadedFilters.slack = slackFilterStrings + } + } + + setAppFilters(loadedFilters) + } } }, [editingAgent, viewMode, users]) @@ -1761,12 +2049,138 @@ function AgentComponent() { } const handleSaveAgent = async () => { + // Helper function to parse timeline value into time range + const parseTimelineValue = (timelineValue: string): { startDate: number; endDate: number } | null => { + const now = Date.now() + const dayInMs = 24 * 60 * 60 * 1000 + + if (timelineValue === 'Last week') { + return { + startDate: Math.floor((now - 7 * dayInMs) / 1000), + endDate: Math.floor(now / 1000) + } + } else if (timelineValue === 'Last month') { + return { + startDate: Math.floor((now - 30 * dayInMs) / 1000), + endDate: Math.floor(now / 1000) + } + } else if (timelineValue === 'Last 7 days') { + return { + startDate: Math.floor((now - 7 * dayInMs) / 1000), + endDate: Math.floor(now / 1000) + } + } else if (timelineValue === 'Last 14 days') { + return { + startDate: Math.floor((now - 14 * dayInMs) / 1000), + endDate: Math.floor(now / 1000) + } + } else if (timelineValue.includes('→')) { + // Custom date range format: "DD/MM/YYYY → DD/MM/YYYY" + const [startStr, endStr] = timelineValue.split('→').map(s => s.trim()) + const parseDate = (dateStr: string) => { + const [day, month, year] = dateStr.split('/').map(Number) + return Math.floor(new Date(year, month - 1, day).getTime() / 1000) + } + return { + startDate: parseDate(startStr), + endDate: parseDate(endStr) + } + } + + return null + } + + // Helper function to parse filter strings into structured filter objects + const parseFilters = (filterStrings: string[], appId: string) => { + const filters: any[] = [] + let filterId = 1 + + // Collect all time ranges first to merge them later + const allTimeRanges: Array<{ startDate: number; endDate: number }> = [] + + for (const filterString of filterStrings) { + if (!filterString || !filterString.trim()) continue + + const filterParts = filterString.split(', ').filter(p => p.trim()) + const filter: any = { id: filterId++ } + + // Parse Gmail people filters (from:, to:, cc:, bcc:) + const fromEmails: string[] = [] + const toEmails: string[] = [] + const ccEmails: string[] = [] + const bccEmails: string[] = [] + + // Parse Slack filters (people and channels) - store docIds + const senderIds: string[] = [] + const channelIds: string[] = [] + + for (const part of filterParts) { + if (part.startsWith('from:')) { + fromEmails.push(part.substring(5)) + } else if (part.startsWith('to:')) { + toEmails.push(part.substring(3)) + } else if (part.startsWith('cc:')) { + ccEmails.push(part.substring(3)) + } else if (part.startsWith('bcc:')) { + bccEmails.push(part.substring(4)) + } else if (part.startsWith('@')) { + const personId = part.substring(1) + senderIds.push(personId) + } else if (part.startsWith('#')) { + const channelId = part.substring(1) + channelIds.push(channelId) + } else if (part.startsWith('~')) { + // Parse timeline filters and collect them + const timelineValue = part.substring(1) + const timeRange = parseTimelineValue(timelineValue) + if (timeRange) { + allTimeRanges.push(timeRange) + } + } + } + + // Add parsed fields to filter object (excluding timeRange for now) + if (fromEmails.length > 0) filter.from = fromEmails + if (toEmails.length > 0) filter.to = toEmails + if (ccEmails.length > 0) filter.cc = ccEmails + if (bccEmails.length > 0) filter.bcc = bccEmails + if (senderIds.length > 0) filter.senderId = senderIds + if (channelIds.length > 0) filter.channelId = channelIds + + // Only add filter if it has at least one non-timeline field + if (Object.keys(filter).length > 1) { + filters.push(filter) + } + } + + // Merge all time ranges into the longest one (earliest start, latest end) + if (allTimeRanges.length > 0) { + const mergedTimeRange = { + startDate: Math.min(...allTimeRanges.map(r => r.startDate)), + endDate: Math.max(...allTimeRanges.map(r => r.endDate)) + } + + // Add merged time range to the first filter, or create a new one if no filters exist + if (filters.length > 0) { + filters[0].timeRange = mergedTimeRange + } else { + filters.push({ + id: 1, + timeRange: mergedTimeRange + }) + } + } + + return filters.length > 0 ? filters : undefined + } + // Build the new simplified appIntegrations structure const appIntegrationsObject: Record< string, { itemIds: string[] selectedAll: boolean + filters?: any[] } > = {} @@ -1778,11 +2192,6 @@ function AgentComponent() { const dataSourceIds: string[] = [] let hasDataSourceSelections = false - // Check for Slack channels in selected entities - const slackChannels = selectedEntities.filter( - (entity) => - entity.app === Apps.Slack && entity.entity === SlackEntity.Channel, - ) // Process each selected integration for (const [integrationId, isSelected] of Object.entries( selectedIntegrations, @@ -1854,22 +2263,28 @@ function AgentComponent() { } // For other integrations, use the integration ID as key else { - appIntegrationsObject[integrationId] = { + const integrationConfig: { + itemIds: string[] + selectedAll: boolean + filters?: any[] + } = { itemIds: [], selectedAll: true, } + + // Add filters if they exist for this integration (Gmail or Slack) + if (appFilters[integrationId] && appFilters[integrationId].length > 0) { + const parsedFilters = parseFilters(appFilters[integrationId], integrationId) + if (parsedFilters) { + integrationConfig.filters = parsedFilters + } + } + + appIntegrationsObject[integrationId] = integrationConfig } } } - // Handle Slack channels from selected entities - if (slackChannels.length > 0) { - appIntegrationsObject["slack"] = { - itemIds: slackChannels.map((channel) => channel.docId), - selectedAll: false, - } - } - // Add collection selections if any exist if (hasCollectionSelections) { appIntegrationsObject["knowledge_base"] = { @@ -1894,11 +2309,13 @@ function AgentComponent() { isPublic: isPublic, isRagOn: isRagOn, appIntegrations: appIntegrationsObject, - docIds: selectedEntities, // Only include userEmails for private agents userEmails: isPublic ? [] : selectedUsers.map((user) => user.email), } + console.log('Agent Payload:', agentPayload) + + try { let response if (editingAgent && editingAgent.externalId) { @@ -2105,12 +2522,18 @@ function AgentComponent() { id: string name: string icon: React.ReactNode - type?: "file" | "folder" | "integration" | "cl" + type?: "file" | "folder" | "integration" | "cl" | "grouped-parent" clId?: string clName?: string + children?: Array<{ + id: string + name: string + icon: React.ReactNode + type?: "file" | "folder" + }> }> = [] - // Add regular integrations (excluding Google Drive which is handled separately) + // Add regular integrations (excluding Google Drive and Collections which are handled separately) for (const integration of allAvailableIntegrations) { if ( selectedIntegrations[integration.id] && @@ -2124,44 +2547,59 @@ function AgentComponent() { } } - // Handle Google Drive items + // Handle Google Drive items - grouped display if (selectedIntegrations["googledrive"]) { - if (selectedItemsInGoogleDrive.size === 0) { - const googleDriveIntegration = allAvailableIntegrations.find( - (int) => int.id === "googledrive", - ) - if (googleDriveIntegration) { + const googleDriveIntegration = allAvailableIntegrations.find( + (int) => int.id === "googledrive", + ) + + if (googleDriveIntegration) { + if (selectedItemsInGoogleDrive.size === 0) { + // No specific items selected, show just Google Drive result.push({ ...googleDriveIntegration, type: "integration", }) - } - } else { - // If specific Google Drive items are selected, show individual file/folder pills - for (const itemId of selectedItemsInGoogleDrive) { - const item = selectedItemDetailsInGoogleDrive[itemId] - if (item) { - // Handle both search results and direct navigation results - const itemTitle = - item.fields?.title || - item.fields?.name || - item.title || - item.name || - "Untitled" - const itemEntity = item.fields?.entity || item.entity - const isFolder = itemEntity === DriveEntity.Folder - - result.push({ - id: `googledrive_${itemId}`, - name: itemTitle, - icon: getDriveEntityIcon(itemEntity), - type: isFolder ? "folder" : "file", - }) + } else { + // Specific items selected, show grouped display + const children: Array<{ + id: string + name: string + icon: React.ReactNode + type?: "file" | "folder" + }> = [] + + for (const itemId of selectedItemsInGoogleDrive) { + const item = selectedItemDetailsInGoogleDrive[itemId] + if (item) { + const itemTitle = + item.fields?.title || + item.fields?.name || + item.title || + item.name || + "Untitled" + const itemEntity = item.fields?.entity || item.entity + const isFolder = itemEntity === DriveEntity.Folder + + children.push({ + id: `googledrive_${itemId}`, + name: itemTitle, + icon: getDriveEntityIcon(itemEntity), + type: isFolder ? "folder" : "file", + }) + } } + + result.push({ + ...googleDriveIntegration, + type: "grouped-parent", + children, + }) } } } + // Handle Collections - grouped display allAvailableIntegrations.forEach((integration) => { if ( integration.id.startsWith("cl_") && @@ -2171,33 +2609,43 @@ function AgentComponent() { const selectedItems = selectedItemsInCollection[clId] || new Set() if (selectedItems.size === 0) { + // No specific items selected, show just the collection result.push({ ...integration, type: "cl", }) } else { + // Specific items selected, show grouped display const itemDetails = selectedItemDetailsInCollection[clId] || {} + const children: Array<{ + id: string + name: string + icon: React.ReactNode + type?: "file" | "folder" + }> = [] selectedItems.forEach((itemId) => { const item = itemDetails[itemId] if (item) { - // Use the name from the mapping if available, otherwise use the item name const displayName = integrationIdToNameMap[itemId]?.name || item.name - - // Determine the icon based on the type from the mapping or the item type const itemType = integrationIdToNameMap[itemId]?.type || item.type const itemIcon = getItemIcon(itemType) - result.push({ + + children.push({ id: `${clId}_${itemId}`, name: displayName, icon: itemIcon, type: item.type, - clId: clId, - clName: integration.name, }) } }) + + result.push({ + ...integration, + type: "grouped-parent", + children, + }) } } }) @@ -2222,7 +2670,6 @@ function AgentComponent() { }) return newSelections }) - setSelectedEntities([]) } // Also update the test agent's RAG status when the form's RAG changes, // but only if we are testing the current form config. @@ -2414,7 +2861,6 @@ function AgentComponent() { isPublic: isPublic, isRagOn: isRagOn, appIntegrations: appIntegrationsObject, - docIds: selectedEntities, userEmails: isPublic ? [] : selectedUsers.map((user) => user.email), allowWebSearch: false, // Not supported in form config } @@ -3159,42 +3605,32 @@ function AgentComponent() {
-
*/}
-
- {currentSelectedIntegrationObjects.length === 0 && ( - - Add integrations.. - - )} - {currentSelectedIntegrationObjects.map((integration) => ( - - handleRemoveSelectedIntegration(integration.id) - } - /> - ))} - { - setIsIntegrationMenuOpen(open) - if (!open) { - setNavigationPath([]) - setCurrentItems([]) - setDropdownSearchQuery("") // Clear search when closing dropdown + {currentSelectedIntegrationObjects.length > 0 && ( +
+ {currentSelectedIntegrationObjects.map((integration) => { + // Check if this is a grouped parent (Collections or Google Drive with children) + if (integration.type === "grouped-parent" && integration.children && integration.children.length > 0) { + return ( +
+ {/* Parent header - fixed width section */} +
+
+ {integration.icon && {integration.icon}} + {integration.name} + { + e.stopPropagation() + handleRemoveSelectedIntegration(integration.id) + }} + /> +
+
+ {/* Children items - aligned with parent, one per row */} +
+ {integration.children.map((child) => ( +
+ {child.icon && {child.icon}} + {child.name} + { + e.stopPropagation() + handleRemoveSelectedIntegration(child.id) + }} + /> +
+ ))} +
+
+ ) } - }} - > - - - - + {filters.map((filter, index) => ( + { + setAppFilters(prev => { + const newFilters = [...(prev[integration.id] || [''])] + newFilters[index] = value + return { + ...prev, + [integration.id]: newFilters + } + }) + }} + onRemove={() => { + if (index === 0 && filters.length === 1) { + // Remove the entire integration + handleRemoveSelectedIntegration(integration.id) + } else { + // Remove just this filter + setAppFilters(prev => { + const newFilters = [...(prev[integration.id] || [''])] + newFilters.splice(index, 1) + return { + ...prev, + [integration.id]: newFilters.length > 0 ? newFilters : [''] + } + }) + } + }} + /> + ))} + {/* Add Filter button - shown once per app after all filters */} + {showFilterInput && ( +
+ +
+ )} +
+ ) + })} +
+ )} + { + setIsIntegrationMenuOpen(open) + if (!open) { + setNavigationPath([]) + setCurrentItems([]) + setDropdownSearchQuery("") // Clear search when closing dropdown + } + }} + > + + + + @@ -4281,81 +4807,8 @@ function AgentComponent() { - -

- Collections appear in the submenu when selecting - integrations. -

- {isRagOn && ( -
- -

- Search for and select specific entities for your agent to - use. -

-
- {selectedEntities.length > 0 ? ( - selectedEntities.map((entity) => ( - - setSelectedEntities((prev) => - prev.filter((c) => c.docId !== entity.docId), - ) - } - /> - )) - ) : ( - - Selected entities will be shown here - - )} -
-
- setEntitySearchQuery(e.target.value)} - className="bg-white dark:bg-slate-700 border border-gray-300 dark:border-slate-600 rounded-lg w-full dark:text-gray-100" - /> - {showEntitySearchResults && ( - - - {entitySearchResults.length > 0 ? ( - entitySearchResults.map((entity) => ( -
{ - setSelectedEntities((prev) => [ - ...prev, - entity, - ]) - setEntitySearchQuery("") - }} - > -

- {entity.name} -

-
- )) - ) : ( -
- No entities found. -
- )} -
-
- )} -
-
- )} - {!isPublic && (