From d9431cce48489884c77e534253ac0197d3e878d3 Mon Sep 17 00:00:00 2001 From: naSim087 Date: Tue, 14 Oct 2025 18:34:54 +0530 Subject: [PATCH 1/6] feat(agentFilter): Add support for gmail and slack app based filter --- server/api/chat/chat.ts | 23 +++++++-- server/api/chat/tools.ts | 17 ++++-- server/api/chat/utils.ts | 103 ++++++++++++++++++++++++++++++++++++- server/db/schema/agents.ts | 24 +++++++++ server/search/vespa.ts | 1 + 5 files changed, 159 insertions(+), 9 deletions(-) diff --git a/server/api/chat/chat.ts b/server/api/chat/chat.ts index fe7252959..73fb5c148 100644 --- a/server/api/chat/chat.ts +++ b/server/api/chat/chat.ts @@ -1169,6 +1169,7 @@ async function* generateIterativeTimeFilterAndQueryRewrite( }> = [] let channelIds: string[] = [] let selectedItem: Partial> = {} + let agentAppFilters: any = {} if (agentPrompt) { let agentPromptData: { appIntegrations?: string[] } = {} try { @@ -1251,11 +1252,12 @@ async function* generateIterativeTimeFilterAndQueryRewrite( // parsing for the new type of integration which we are going to save if (isAppSelectionMap(agentPromptData.appIntegrations)) { - const { selectedApps, selectedItems } = parseAppSelections( + const { selectedApps, selectedItems, appFilters } = parseAppSelections( agentPromptData.appIntegrations, ) // Use selectedApps and selectedItems selectedItem = selectedItems + agentAppFilters = appFilters || {} // agentAppEnums = selectedApps.filter(isValidApp); agentAppEnums = [...new Set(selectedApps)] @@ -1392,6 +1394,7 @@ async function* generateIterativeTimeFilterAndQueryRewrite( channelIds: channelIds, collectionSelections: agentSpecificCollectionSelections, selectedItem: selectedItem, + appFilters: agentAppFilters, }, ) } @@ -1455,6 +1458,7 @@ async function* generateIterativeTimeFilterAndQueryRewrite( channelIds: channelIds, collectionSelections: agentSpecificCollectionSelections, selectedItem: selectedItem, + appFilters: agentAppFilters, }, ) } @@ -1524,6 +1528,7 @@ async function* generateIterativeTimeFilterAndQueryRewrite( channelIds: channelIds, collectionSelections: agentSpecificCollectionSelections, selectedItem: selectedItem, + appFilters: agentAppFilters, })) // Expand email threads in the results @@ -1586,6 +1591,7 @@ async function* generateIterativeTimeFilterAndQueryRewrite( channelIds, collectionSelections: agentSpecificCollectionSelections, selectedItem: selectedItem, + appFilters: agentAppFilters, }, ) } @@ -1710,6 +1716,7 @@ async function* generateIterativeTimeFilterAndQueryRewrite( collectionSelections: agentSpecificCollectionSelections, channelIds: channelIds, selectedItem: selectedItem, + appFilters: agentAppFilters, }, ) } @@ -1767,6 +1774,7 @@ async function* generateIterativeTimeFilterAndQueryRewrite( collectionSelections: agentSpecificCollectionSelections, channelIds: channelIds, selectedItem: selectedItem, + appFilters: agentAppFilters, }, ) } @@ -2396,6 +2404,7 @@ async function* generatePointQueryTimeExpansion( collectionFileIds?: string[] }> = [] let selectedItem: Partial> = {} + let agentAppFilters: any = {} if (agentPrompt) { let agentPromptData: { appIntegrations?: string[] } = {} try { @@ -2475,11 +2484,12 @@ async function* generatePointQueryTimeExpansion( // parsing for the new type of integration which we are going to save if (isAppSelectionMap(agentPromptData.appIntegrations)) { - const { selectedApps, selectedItems } = parseAppSelections( + const { selectedApps, selectedItems, appFilters } = parseAppSelections( agentPromptData.appIntegrations, ) // Use selectedApps and selectedItems selectedItem = selectedItems + agentAppFilters = appFilters || {} // agentAppEnums = selectedApps.filter(isValidApp); agentAppEnums = [...new Set(selectedApps)] @@ -2671,6 +2681,7 @@ async function* generatePointQueryTimeExpansion( dataSourceIds: agentSpecificDataSourceIds, channelIds: channelIds, selectedItem: selectedItem, + appFilters: agentAppFilters, }, ), searchVespaAgent(message, email, null, null, agentAppEnums, { @@ -2682,6 +2693,7 @@ async function* generatePointQueryTimeExpansion( dataSourceIds: agentSpecificDataSourceIds, channelIds: channelIds, selectedItem: selectedItem, + appFilters: agentAppFilters, }), ]) results.root.children = [ @@ -2992,6 +3004,7 @@ async function* generateMetadataQueryAnswer( collectionFileIds?: string[] }> = [] let selectedItem = {} + let agentAppFilters: any = {} if (agentPrompt) { let agentPromptData: { appIntegrations?: string[] } = {} try { @@ -3073,10 +3086,12 @@ async function* generateMetadataQueryAnswer( ) } // parsing for the new type of integration which we are going to save + if (isAppSelectionMap(agentPromptData.appIntegrations)) { - const { selectedApps, selectedItems } = parseAppSelections( + const { selectedApps, selectedItems, appFilters } = parseAppSelections( agentPromptData.appIntegrations, ) + agentAppFilters = appFilters // Use selectedApps and selectedItems selectedItem = selectedItems // agentAppEnums = selectedApps.filter(isValidApp); @@ -3250,6 +3265,7 @@ async function* generateMetadataQueryAnswer( channelIds: channelIds, selectedItem: selectedItem, collectionSelections: agentSpecificCollectionSelections, + appFilters: agentAppFilters, }, ) } @@ -3587,6 +3603,7 @@ async function* generateMetadataQueryAnswer( dataSourceIds: agentSpecificDataSourceIds, channelIds: channelIds, selectedItem: selectedItem, + appFilters: agentAppFilters, }, ) } diff --git a/server/api/chat/tools.ts b/server/api/chat/tools.ts index 8ff09441c..4d97919bb 100644 --- a/server/api/chat/tools.ts +++ b/server/api/chat/tools.ts @@ -79,6 +79,7 @@ export function parseAgentAppIntegrations(agentPrompt?: string): { agentSpecificCollectionFolderIds: string[] agentSpecificCollectionFileIds: string[] selectedItems: {} + appFilters: any } { Logger.debug({ agentPrompt }, "Parsing agent prompt for app integrations") let agentAppEnums: Apps[] = [] @@ -87,6 +88,7 @@ export function parseAgentAppIntegrations(agentPrompt?: string): { let agentSpecificCollectionFolderIds: string[] = [] let agentSpecificCollectionFileIds: string[] = [] let selectedItem = {} + let appFilters: any = {} if (!agentPrompt) { return { @@ -96,16 +98,17 @@ export function parseAgentAppIntegrations(agentPrompt?: string): { agentSpecificCollectionFolderIds, agentSpecificCollectionFileIds, selectedItems: selectedItem, + appFilters, } } - + let agentPromptData: { appIntegrations?: string[] } = {} try { agentPromptData = JSON.parse(agentPrompt) - let selectedItem:any= {} + let selectedItem: any = {} if (isAppSelectionMap(agentPromptData.appIntegrations)) { - const { selectedApps, selectedItems } = parseAppSelections( + const { selectedApps, selectedItems, appFilters } = parseAppSelections( agentPromptData.appIntegrations, ) // agentAppEnums = selectedApps.filter(isValidApp); @@ -145,6 +148,7 @@ export function parseAgentAppIntegrations(agentPrompt?: string): { agentSpecificCollectionFolderIds, agentSpecificCollectionFileIds, selectedItems: selectedItem, + appFilters, } } @@ -165,6 +169,7 @@ export function parseAgentAppIntegrations(agentPrompt?: string): { agentSpecificCollectionFolderIds, agentSpecificCollectionFileIds, selectedItems: selectedItem, + appFilters, } } @@ -231,6 +236,7 @@ export function parseAgentAppIntegrations(agentPrompt?: string): { agentSpecificCollectionFolderIds, agentSpecificCollectionFileIds, selectedItems: selectedItem, + appFilters, } } @@ -257,6 +263,7 @@ interface UnifiedSearchOptions { collectionIds?: string[] collectionFolderIds?: string[] collectionFileIds?: string[] + appFilters?: any } const userMetadata: UserMetadataType = {userTimezone: "Asia/Kolkata", dateForAI: getDateForAI({userTimeZone: "Asia/Kolkata"})} @@ -375,6 +382,7 @@ async function executeVespaSearch(options: UnifiedSearchOptions): Promise<{ ] : undefined, selectedItem: selectedItems, + appFilters: options.appFilters, }, ) } else { @@ -415,7 +423,6 @@ async function executeVespaSearch(options: UnifiedSearchOptions): Promise<{ offset, asc: orderDirection === "asc", excludedIds, - intent: options.intent || null, }) } else { const errorMsg = "No query or schema provided for search." @@ -526,6 +533,7 @@ export const searchTool: AgentTool = { agentSpecificCollectionFolderIds, agentSpecificCollectionFileIds, selectedItems, + appFilters, } = parseAgentAppIntegrations(agentPrompt) const channelIds = agentPrompt ? await getChannelIdsFromAgentPrompt(agentPrompt) @@ -750,6 +758,7 @@ export const metadataRetrievalTool: AgentTool = { agentSpecificCollectionFolderIds, agentSpecificCollectionFileIds, selectedItems, + appFilters, } = parseAgentAppIntegrations(agentPrompt) let resolvedIntent = params.intent || {} diff --git a/server/api/chat/utils.ts b/server/api/chat/utils.ts index d214a2d13..6197e536f 100644 --- a/server/api/chat/utils.ts +++ b/server/api/chat/utils.ts @@ -177,8 +177,35 @@ export const getChannelIdsFromAgentPrompt = (agentPrompt: string) => { } export interface AppSelection { - itemIds: string[] + // Existing required fields (backward compatibility) + itemIds: string[] // For Slack: channelIds, for Gmail: message/thread IDs selectedAll: boolean + + // New optional Gmail filters + from?: string[] // Accept any string values (email addresses) + to?: string[] // Accept any string values (email addresses) + cc?: string[] // Accept any string values (email addresses) + bcc?: string[] // Accept any string values (email addresses) + startDate?: number // Unix timestamp + endDate?: number // Unix timestamp + + // New optional Slack filters + senderId?: string[] // Slack user IDs array (can contain single or multiple values) +} + +export interface AppFilters { + // Gmail filters + from?: string[] + to?: string[] + cc?: string[] + bcc?: string[] + + // Slack filters + senderId?: string[] // Slack user IDs + + // common filters + startDate?: number // Unix timestamp + endDate?: number // Unix timestamp } export interface AppSelectionMap { @@ -188,11 +215,13 @@ export interface AppSelectionMap { export interface ParsedResult { selectedApps: Apps[] selectedItems: Partial> + appFilters?: Partial> } export function parseAppSelections(input: AppSelectionMap): ParsedResult { const selectedApps: Apps[] = [] let selectedItems: Record = {} as Record + let appFilters: Record = {} as Record for (let [appName, selection] of Object.entries(input)) { let app: Apps @@ -217,6 +246,7 @@ export function parseAppSelections(input: AppSelectionMap): ParsedResult { } selectedApps.push(app) + // If selectedAll is true or itemIds is empty, we infer "all selected" // So we don't add anything to selectedItems (empty means all) if ( @@ -233,11 +263,80 @@ export function parseAppSelections(input: AppSelectionMap): ParsedResult { selectedItems[app] = selection.itemIds } } + + // Extract app-specific filters + const filters: AppFilters = {} + + // Gmail filters + if (app === Apps.Gmail) { + if (selection.from && selection.from.length > 0) { + const filteredFrom = selection.from.filter( + (email) => email && email.trim() !== "", + ) + if (filteredFrom.length > 0) { + filters.from = filteredFrom + } + } + if (selection.to && selection.to.length > 0) { + const filteredTo = selection.to.filter( + (email) => email && email.trim() !== "", + ) + if (filteredTo.length > 0) { + filters.to = filteredTo + } + } + if (selection.cc && selection.cc.length > 0) { + const filteredCc = selection.cc.filter( + (email) => email && email.trim() !== "", + ) + if (filteredCc.length > 0) { + filters.cc = filteredCc + } + } + if (selection.bcc && selection.bcc.length > 0) { + const filteredBcc = selection.bcc.filter( + (email) => email && email.trim() !== "", + ) + if (filteredBcc.length > 0) { + filters.bcc = filteredBcc + } + } + if (selection.startDate !== undefined) { + filters.startDate = selection.startDate + } + if (selection.endDate !== undefined) { + filters.endDate = selection.endDate + } + } + + // Slack filters + if (app === Apps.Slack) { + if (selection.senderId && selection.senderId.length > 0) { + const filteredSenderId = selection.senderId.filter( + (id) => id && id.trim() !== "", + ) + if (filteredSenderId.length > 0) { + filters.senderId = filteredSenderId + } + } + } + + // Only add filters if there are any + if (Object.keys(filters).length > 0) { + appFilters[app] = filters + } } - return { + + const result: ParsedResult = { selectedApps, selectedItems, } + + // Only add appFilters if there are any + if (Object.keys(appFilters).length > 0) { + result.appFilters = appFilters + } + return result } // Interface for email search result fields diff --git a/server/db/schema/agents.ts b/server/db/schema/agents.ts index 461786059..dc20d2e2c 100644 --- a/server/db/schema/agents.ts +++ b/server/db/schema/agents.ts @@ -86,6 +86,18 @@ export const insertAgentSchema = createInsertSchema(agents, { // New AppSelectionMap format itemIds: z.array(z.string()), selectedAll: z.boolean(), + // Gmail specific filters + from: z.array(z.string()).optional(), + to: z.array(z.string()).optional(), + cc: z.array(z.string()).optional(), + bcc: z.array(z.string()).optional(), + // Slack specific filters + senderId: z.array(z.string()).optional(), + // Common time range filter (Unix timestamps) + timeRange: z.object({ + startDate: z.number(), + endDate: z.number(), + }).optional(), }), ), ]) @@ -111,6 +123,18 @@ export const selectAgentSchema = createSelectSchema(agents, { // New AppSelectionMap format itemIds: z.array(z.string()), selectedAll: z.boolean(), + // Gmail specific filters + from: z.array(z.string()).optional(), + to: z.array(z.string()).optional(), + cc: z.array(z.string()).optional(), + bcc: z.array(z.string()).optional(), + // Slack specific filters + senderId: z.array(z.string()).optional(), + // Common time range filter (Unix timestamps) + timeRange: z.object({ + startDate: z.number(), + endDate: z.number(), + }).optional(), }), ), ]) diff --git a/server/search/vespa.ts b/server/search/vespa.ts index 60f1ebdd8..22037c087 100644 --- a/server/search/vespa.ts +++ b/server/search/vespa.ts @@ -109,6 +109,7 @@ export const searchVespaAgent = async ( ...options, driveIds, processedCollectionSelections, + appFilters: options.appFilters, // Explicitly pass appFilters recencyDecayRate: options.recencyDecayRate || config.defaultRecencyDecayRate, }, From dca99361b9cf8436e146423009f5956b0b9b993b Mon Sep 17 00:00:00 2001 From: naSim087 Date: Wed, 15 Oct 2025 18:14:50 +0530 Subject: [PATCH 2/6] feat(agentFilter): add endpoint to fetch slack entites --- server/api/slack.ts | 161 +++++++++++++++++++++++++++++++++++++++++ server/search/vespa.ts | 1 + server/server.ts | 15 +++- 3 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 server/api/slack.ts diff --git a/server/api/slack.ts b/server/api/slack.ts new file mode 100644 index 000000000..fcb5c6550 --- /dev/null +++ b/server/api/slack.ts @@ -0,0 +1,161 @@ +import type { Context } from "hono" +import { z } from "zod" +import { HTTPException } from "hono/http-exception" +import { getLogger, getLoggerWithChild } from "@/logger" +import { Subsystem } from "@/types" +import { Apps, SlackEntity } from "@xyne/vespa-ts/types" +import { VespaSearchResponseToSearchResult } from "@xyne/vespa-ts/mappers" +import { chunkDocument } from "@/chunks" +import { getErrorMessage } from "@/utils" +import { fetchSlackEntity } from "@/search/vespa" +import config from "@/config" + +const loggerWithChild = getLoggerWithChild(Subsystem.Api) +const { JwtPayloadKey } = config + +// Schema for listing Slack entities (users or channels) +export const slackListSchema = z.object({ + entity: z.enum([SlackEntity.User, SlackEntity.Channel]), + limit: z + .string() + .optional() + .default("50") + .transform((value) => parseInt(value, 10)) + .refine((value) => !isNaN(value) && value > 0 && value <= 100, { + message: "Limit must be a valid number between 1 and 100", + }), + offset: z + .string() + .optional() + .default("0") + .transform((value) => parseInt(value, 10)) + .refine((value) => !isNaN(value) && value >= 0, { + message: "Offset must be a valid number >= 0", + }), +}) + +// Schema for searching Slack entities (users or channels) +export const slackSearchSchema = z.object({ + entity: z.enum([SlackEntity.User, SlackEntity.Channel]), + query: z + .string() + .min(1, "Search query is required") + .max(200, "Query too long"), + limit: z + .string() + .optional() + .default("20") + .transform((value) => parseInt(value, 10)) + .refine((value) => !isNaN(value) && value > 0 && value <= 50, { + message: "Limit must be a valid number between 1 and 50", + }), +}) + +export type SlackListRequest = z.infer +export type SlackSearchRequest = z.infer + +/** + * Combined API endpoint that handles both listing and searching based on query presence + * + * GET /api/slack/entities?entity=user&limit=50&offset=0 (list) + * GET /api/slack/entities?entity=user&query=john&limit=20 (search) + * + * This is a convenience endpoint that automatically determines whether to list or search + * based on whether a query parameter is provided. + */ +export const SlackEntitiesApi = async (c: Context) => { + const { sub } = c.get(JwtPayloadKey) + const email = sub + + try { + // @ts-ignore + const params = c.req.query() + + // Check if query parameter exists and is not empty + const hasQuery = + params.query && + typeof params.query === "string" && + params.query.trim().length > 0 + + if (hasQuery) { + // Validate search parameters + const searchParams = slackSearchSchema.parse(params) + + loggerWithChild({ email }).info( + `Auto-routing to search for Slack ${searchParams.entity}s`, + ) + + // Route to search logic using fetchSlackEntity + const results = await fetchSlackEntity( + searchParams.entity, + searchParams.query.trim(), + email, + Apps.Slack, + searchParams.limit, + 0, + ) + + const transformedResults = VespaSearchResponseToSearchResult( + results, + { chunkDocument }, + email, + ) + + return c.json({ + results: transformedResults.results || [], + query: searchParams.query.trim(), + entity: searchParams.entity, + operation: "search", + resultCount: results.root?.children?.length || 0, + }) + } else { + // Validate list parameters + const listParams = slackListSchema.parse(params) + + loggerWithChild({ email }).info( + `Auto-routing to list for Slack ${listParams.entity}s`, + ) + + // Route to list logic using fetchSlackEntity + const results = await fetchSlackEntity( + listParams.entity, + null, + email, + Apps.Slack, + listParams.limit, + listParams.offset, + ) + + const transformedResults = VespaSearchResponseToSearchResult( + results, + { chunkDocument }, + email, + ) + + return c.json({ + results: transformedResults.results || [], + pagination: { + limit: listParams.limit, + offset: listParams.offset, + total: + results.root?.fields?.totalCount || + results.root?.children?.length || + 0, + hasMore: (results.root?.children?.length || 0) === listParams.limit, + }, + entity: listParams.entity, + operation: "list", + }) + } + } catch (error) { + const errMsg = getErrorMessage(error) + loggerWithChild({ email }).error( + error, + `Error in combined Slack entities API: ${errMsg}`, + ) + + throw new HTTPException(500, { + message: "Failed to process Slack entities request", + }) + } +} diff --git a/server/search/vespa.ts b/server/search/vespa.ts index 22037c087..03c29cbbe 100644 --- a/server/search/vespa.ts +++ b/server/search/vespa.ts @@ -172,6 +172,7 @@ export const checkIfDataSourceFileExistsByNameAndId = // Slack operations export const getSlackUserDetails = vespa.getSlackUserDetails.bind(vespa) +export const fetchSlackEntity = vespa.fetchSlackEntity.bind(vespa) // Utility operations export const getTimestamp = vespa.getTimestamp.bind(vespa) diff --git a/server/server.ts b/server/server.ts index c1e6d9ef9..5114ac8a5 100644 --- a/server/server.ts +++ b/server/server.ts @@ -25,6 +25,11 @@ import { } from "@/api/search" import { callNotificationService } from "@/services/callNotifications" import { HighlightApi, highlightSchema } from "@/api/highlight" +import { + SlackEntitiesApi, + slackListSchema, + slackSearchSchema, +} from "@/api/slack" import { zValidator } from "@hono/zod-validator" import { addApiKeyConnectorSchema, @@ -896,7 +901,11 @@ export const AppRoutes = app .post("/files/upload-attachment", handleAttachmentUpload) .get("/attachments/:fileId", handleAttachmentServe) .get("/attachments/:fileId/thumbnail", handleThumbnailServe) - .post("/files/delete", zValidator("json", handleAttachmentDeleteSchema), handleAttachmentDeleteApi) + .post( + "/files/delete", + zValidator("json", handleAttachmentDeleteSchema), + handleAttachmentDeleteApi, + ) .post("/chat", zValidator("json", chatSchema), GetChatApi) .post( "/chat/generateTitle", @@ -983,6 +992,8 @@ export const AppRoutes = app zValidator("query", searchSchema), SearchSlackChannels, ) + // Slack Entity API routes + .get("/slack/entities", SlackEntitiesApi) .get("/me", GetUserWorkspaceInfo) .get("/users/api-keys", GetUserApiKeys) .post( @@ -1653,7 +1664,7 @@ app.get("/*", AuthRedirect, serveStatic({ path: "./dist/index.html" })) export const init = async () => { // Initialize API server queue (only FileProcessingQueue, no workers) await initApiServerQueue() - + if (isSlackEnabled()) { Logger.info("Slack Web API client initialized and ready.") try { From d03fb2eef9a6030a8de51b5816d7701a7492b715 Mon Sep 17 00:00:00 2001 From: naSim087 Date: Thu, 16 Oct 2025 17:29:30 +0530 Subject: [PATCH 3/6] feat(agent): Add multiple filter support for agent integrations --- server/api/chat/utils.ts | 100 +++++++++++-------------------------- server/db/schema/agents.ts | 76 ++++++++++++++++++---------- 2 files changed, 79 insertions(+), 97 deletions(-) diff --git a/server/api/chat/utils.ts b/server/api/chat/utils.ts index 6197e536f..4a1c6cf1d 100644 --- a/server/api/chat/utils.ts +++ b/server/api/chat/utils.ts @@ -177,35 +177,38 @@ export const getChannelIdsFromAgentPrompt = (agentPrompt: string) => { } export interface AppSelection { - // Existing required fields (backward compatibility) + // Required fields itemIds: string[] // For Slack: channelIds, for Gmail: message/thread IDs selectedAll: boolean - // New optional Gmail filters - from?: string[] // Accept any string values (email addresses) - to?: string[] // Accept any string values (email addresses) - cc?: string[] // Accept any string values (email addresses) - bcc?: string[] // Accept any string values (email addresses) - startDate?: number // Unix timestamp - endDate?: number // Unix timestamp - - // New optional Slack filters - senderId?: string[] // Slack user IDs array (can contain single or multiple values) + // Multiple filters array + filters?: AppFilter[] } -export interface AppFilters { - // Gmail filters +export interface AppFilter { + id: number // Numeric identifier for this filter + // Gmail-specific filters from?: string[] to?: string[] cc?: string[] bcc?: string[] + // Slack-specific filters + senderId?: string[] + channelId?: string[] + // Common filters + timeRange?: { + startDate: number + endDate: number + } +} - // Slack filters - senderId?: string[] // Slack user IDs - - // common filters - startDate?: number // Unix timestamp - endDate?: number // Unix timestamp +export interface AppFilters { + gmail?: { + filters?: AppFilter[] + } + slack?: { + filters?: AppFilter[] + } } export interface AppSelectionMap { @@ -264,60 +267,15 @@ export function parseAppSelections(input: AppSelectionMap): ParsedResult { } } - // Extract app-specific filters + // Extract app-specific filters from new multiple filters format const filters: AppFilters = {} - // Gmail filters - if (app === Apps.Gmail) { - if (selection.from && selection.from.length > 0) { - const filteredFrom = selection.from.filter( - (email) => email && email.trim() !== "", - ) - if (filteredFrom.length > 0) { - filters.from = filteredFrom - } - } - if (selection.to && selection.to.length > 0) { - const filteredTo = selection.to.filter( - (email) => email && email.trim() !== "", - ) - if (filteredTo.length > 0) { - filters.to = filteredTo - } - } - if (selection.cc && selection.cc.length > 0) { - const filteredCc = selection.cc.filter( - (email) => email && email.trim() !== "", - ) - if (filteredCc.length > 0) { - filters.cc = filteredCc - } - } - if (selection.bcc && selection.bcc.length > 0) { - const filteredBcc = selection.bcc.filter( - (email) => email && email.trim() !== "", - ) - if (filteredBcc.length > 0) { - filters.bcc = filteredBcc - } - } - if (selection.startDate !== undefined) { - filters.startDate = selection.startDate - } - if (selection.endDate !== undefined) { - filters.endDate = selection.endDate - } - } - - // Slack filters - if (app === Apps.Slack) { - if (selection.senderId && selection.senderId.length > 0) { - const filteredSenderId = selection.senderId.filter( - (id) => id && id.trim() !== "", - ) - if (filteredSenderId.length > 0) { - filters.senderId = filteredSenderId - } + // Check for multiple filters format + if (selection.filters && selection.filters.length > 0) { + if (app === Apps.Gmail) { + filters.gmail = { filters: selection.filters } + } else if (app === Apps.Slack) { + filters.slack = { filters: selection.filters } } } diff --git a/server/db/schema/agents.ts b/server/db/schema/agents.ts index dc20d2e2c..becfe7f39 100644 --- a/server/db/schema/agents.ts +++ b/server/db/schema/agents.ts @@ -83,21 +83,33 @@ export const insertAgentSchema = createInsertSchema(agents, { z.record( z.string(), z.object({ - // New AppSelectionMap format + // AppSelectionMap format itemIds: z.array(z.string()), selectedAll: z.boolean(), - // Gmail specific filters - from: z.array(z.string()).optional(), - to: z.array(z.string()).optional(), - cc: z.array(z.string()).optional(), - bcc: z.array(z.string()).optional(), - // Slack specific filters - senderId: z.array(z.string()).optional(), - // Common time range filter (Unix timestamps) - timeRange: z.object({ - startDate: z.number(), - endDate: z.number(), - }).optional(), + + // Multiple filter groups + filters: z + .array( + z.object({ + id: z.number(), // Numeric identifier for this filter + // Gmail-specific filters + from: z.array(z.string()).optional(), + to: z.array(z.string()).optional(), + cc: z.array(z.string()).optional(), + bcc: z.array(z.string()).optional(), + // Slack-specific filters + senderId: z.array(z.string()).optional(), + channelId: z.array(z.string()).optional(), + // Common filters + timeRange: z + .object({ + startDate: z.number(), + endDate: z.number(), + }) + .optional(), + }), + ) + .optional(), }), ), ]) @@ -120,21 +132,33 @@ export const selectAgentSchema = createSelectSchema(agents, { z.record( z.string(), z.object({ - // New AppSelectionMap format + // AppSelectionMap format itemIds: z.array(z.string()), selectedAll: z.boolean(), - // Gmail specific filters - from: z.array(z.string()).optional(), - to: z.array(z.string()).optional(), - cc: z.array(z.string()).optional(), - bcc: z.array(z.string()).optional(), - // Slack specific filters - senderId: z.array(z.string()).optional(), - // Common time range filter (Unix timestamps) - timeRange: z.object({ - startDate: z.number(), - endDate: z.number(), - }).optional(), + + // Multiple filter groups + filters: z + .array( + z.object({ + id: z.number(), // Numeric identifier for this filter + // Gmail-specific filters + from: z.array(z.string()).optional(), + to: z.array(z.string()).optional(), + cc: z.array(z.string()).optional(), + bcc: z.array(z.string()).optional(), + // Slack-specific filters + senderId: z.array(z.string()).optional(), + channelId: z.array(z.string()).optional(), + // Common filters + timeRange: z + .object({ + startDate: z.number(), + endDate: z.number(), + }) + .optional(), + }), + ) + .optional(), }), ), ]) From cd87883580ed0d3e898453cf0856181b38152ba9 Mon Sep 17 00:00:00 2001 From: Rahul Kumar Date: Sun, 19 Oct 2025 00:09:32 +0530 Subject: [PATCH 4/6] feat: XYNE-186 provide filters for slack and gmail app in app integratio --- frontend/src/routes/_authenticated/agent.tsx | 1724 ++++++++++++++++-- server/api/agent.ts | 26 +- server/api/slack.ts | 16 +- 3 files changed, 1625 insertions(+), 141 deletions(-) diff --git a/frontend/src/routes/_authenticated/agent.tsx b/frontend/src/routes/_authenticated/agent.tsx index 5e70cf744..07e2384e9 100644 --- a/frontend/src/routes/_authenticated/agent.tsx +++ b/frontend/src/routes/_authenticated/agent.tsx @@ -38,7 +38,6 @@ import { X as LucideX, RotateCcw, RefreshCw, - PlusCircle, Plus, Copy, ArrowLeft, @@ -48,11 +47,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" @@ -100,6 +102,10 @@ interface CustomBadgeProps { text: string onRemove: () => void icon?: React.ReactNode + appId?: string + filterValue?: string + onFilterChange?: (value: string) => void + filterIndex?: number } interface FetchedDataSource { @@ -109,19 +115,1155 @@ interface FetchedDataSource { entity: string } +interface DateRangePickerProps { + dateRange: { start: Date | null; end: Date | null } + setDateRange: React.Dispatch> + currentMonth: Date + setCurrentMonth: React.Dispatch> + onApply: () => void + onCancel: () => void +} -const CustomBadge: React.FC = ({ text, onRemove, icon }) => { +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)) { + // Start new selection + setDateRange({ start: clickedDate, end: null }) + } else { + // Complete the range + 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 ( -
- {icon && {icon}} - {text} - { - e.stopPropagation() - onRemove() - }} - /> +
+ {/* Month/Year Header */} +
+

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

+
+ + +
+
+ + {/* Day Names */} +
+ {dayNames.map(day => ( +
+ {day} +
+ ))} +
+ + {/* Calendar Days */} +
+ {/* Empty cells for days before month starts */} + {Array.from({ length: firstDayOfMonth }).map((_, i) => ( +
+ ))} + + {/* Actual days */} + {Array.from({ length: daysInMonth }).map((_, i) => { + const day = i + 1 + const inRange = isDateInRange(day) + const selected = isDateSelected(day) + + return ( + + ) + })} +
+ + {/* Action Buttons */} +
+ + +
+
+ ) +} + +const CustomBadge: React.FC = ({ + text, + onRemove, + icon, + appId, + filterValue, + onFilterChange, +}) => { + const { toast } = useToast() + + // Only show filter input for Gmail and Slack + const showFilterInput = appId === 'gmail' || appId === 'slack' + + // State for filter dropdown + const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false) + const [filterNavigationPath, setFilterNavigationPath] = useState>([]) + + // Define filter options based on app + const getFilterOptions = () => { + if (appId === 'slack') { + return [ + { label: 'People', value: '@people' }, + { label: 'Channels', value: '#channel' }, + { label: 'Timeline', value: '~timeline' } + ] + } else if (appId === 'gmail') { + return [ + { label: 'People', value: '@people' }, + { label: 'Timeline', value: '~timeline' } + ] + } + return [] + } + + // State for Slack users + const [slackUsers, setSlackUsers] = useState>([]) + const [slackSearchQuery, setSlackSearchQuery] = useState('') + const [slackOffset, setSlackOffset] = useState(0) + const [slackHasMore, setSlackHasMore] = useState(true) + const [isLoadingSlackUsers, setIsLoadingSlackUsers] = useState(false) + const slackUsersContainerRef = useRef(null) + + // State for selected people + const [selectedPeople, setSelectedPeople] = useState>(new Set()) + + // State for Gmail people fields + const [gmailPeopleFields, setGmailPeopleFields] = useState<{ + from: string[] + to: string[] + cc: string[] + bcc: string[] + }>({ + from: [], + to: [], + cc: [], + bcc: [] + }) + + // State for current input values in Gmail people fields + const [gmailPeopleInputs, setGmailPeopleInputs] = useState<{ + from: string + to: string + cc: string + bcc: string + }>({ + from: '', + to: '', + cc: '', + bcc: '' + }) + + // State for Slack channels + const [slackChannels, setSlackChannels] = useState>([]) + const [slackChannelsSearchQuery, setSlackChannelsSearchQuery] = useState('') + const [slackChannelsOffset, setSlackChannelsOffset] = useState(0) + const [slackChannelsHasMore, setSlackChannelsHasMore] = useState(true) + const [isLoadingSlackChannels, setIsLoadingSlackChannels] = useState(false) + const slackChannelsContainerRef = useRef(null) + + // State for selected channels + const [selectedChannels, setSelectedChannels] = useState>(new Set()) + + // Parse existing filter values and initialize state + useEffect(() => { + if (!filterValue || !showFilterInput) return + + const filters = filterValue.split(', ').filter(f => f.trim()) + + if (appId === 'gmail') { + // Parse Gmail filters + const newGmailFields = { + from: [] as string[], + to: [] as string[], + cc: [] as string[], + bcc: [] as string[] + } + + filters.forEach(filter => { + if (filter.startsWith('from:')) { + newGmailFields.from.push(filter.substring(5)) + } else if (filter.startsWith('to:')) { + newGmailFields.to.push(filter.substring(3)) + } else if (filter.startsWith('cc:')) { + newGmailFields.cc.push(filter.substring(3)) + } else if (filter.startsWith('bcc:')) { + newGmailFields.bcc.push(filter.substring(4)) + } + }) + + setGmailPeopleFields(newGmailFields) + + // Parse timeline filters for Gmail + const timelineFilters = filters.filter(f => f.startsWith('~')).map(f => f.substring(1)) + setSelectedTimelines(new Set(timelineFilters)) + + } else if (appId === 'slack') { + // Parse Slack filters - timeline filters can be set immediately + const timelineFilters = filters.filter(f => f.startsWith('~')).map(f => f.substring(1)) + + // For Slack people and channels, we need to wait for the API data to map names to IDs + // This will be handled in separate useEffect hooks below + setSelectedTimelines(new Set(timelineFilters)) + } + }, [filterValue, appId, showFilterInput]) + + // Effect to map Slack user names to IDs when slackUsers data is available + useEffect(() => { + if (appId === 'slack' && filterValue && slackUsers.length > 0) { + const filters = filterValue.split(', ').filter(f => f.trim()) + const peopleNames = filters.filter(f => f.startsWith('@')).map(f => f.substring(1)) + + const newSelectedPeople = new Set() + peopleNames.forEach(name => { + const user = slackUsers.find(u => u.name === name) + if (user) { + newSelectedPeople.add(user.id) + } + }) + + setSelectedPeople(newSelectedPeople) + } + }, [slackUsers, filterValue, appId]) + + // Effect to parse Slack channel IDs from filter when available + useEffect(() => { + if (appId === 'slack' && filterValue) { + 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, appId]) + + // Fetch Slack users from API + const fetchSlackUsers = async (query: string = '', offset: number = 0, append: boolean = false) => { + if (appId !== 'slack') return + + setIsLoadingSlackUsers(true) + try { + const limit = offset + 50 + const queryParams: any = { + entity: SlackEntity.User, + limit: limit.toString(), + offset: offset.toString(), + } + + // Add query parameter if search query exists + 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 users = data.results?.root?.children?.map((child: any) => ({ + id: child.fields?.docId || child.id, + name: child.fields?.name || 'Unknown User', + })) || [] + + if (append) { + setSlackUsers(prev => [...prev, ...users]) + } else { + setSlackUsers(users) + } + + // Check if there are more users to load + const hasMore = users.length === 50 + setSlackHasMore(hasMore) + } else { + toast.error({ + title: 'Error', + description: 'Failed to fetch Slack users', + }) + } + } catch (error) { + console.error('Error fetching Slack users:', error) + toast.error({ + title: 'Error', + description: 'An error occurred while fetching Slack users', + }) + } finally { + setIsLoadingSlackUsers(false) + } + } + + // Fetch Slack channels from API + const fetchSlackChannels = async (query: string = '', offset: number = 0, append: boolean = false) => { + if (appId !== 'slack') return + + setIsLoadingSlackChannels(true) + try { + const limit = offset + 50 + const queryParams: any = { + entity: SlackEntity.Channel, + limit: limit.toString(), + offset: offset.toString(), + } + + // Add query parameter if search query exists + 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 channels = data.results?.root?.children?.map((child: any) => ({ + id: child.fields?.docId || child.id, + name: child.fields?.name || 'Unknown Channel', + })) || [] + + if (append) { + setSlackChannels(prev => [...prev, ...channels]) + } else { + setSlackChannels(channels) + } + + // Check if there are more channels to load + const hasMore = channels.length === 50 + setSlackChannelsHasMore(hasMore) + } else { + toast.error({ + title: 'Error', + description: 'Failed to fetch Slack channels', + }) + } + } catch (error) { + console.error('Error fetching Slack channels:', error) + toast.error({ + title: 'Error', + description: 'An error occurred while fetching Slack channels', + }) + } finally { + setIsLoadingSlackChannels(false) + } + } + + // Handle infinite scroll for Slack users + const handleSlackUsersScroll = useCallback(() => { + const container = slackUsersContainerRef.current + if (!container || isLoadingSlackUsers || !slackHasMore) return + + const { scrollTop, scrollHeight, clientHeight } = container + const scrollThreshold = 50 // pixels from bottom + + if (scrollHeight - scrollTop - clientHeight < scrollThreshold) { + const newOffset = slackOffset + 50 + setSlackOffset(newOffset) + fetchSlackUsers(slackSearchQuery, newOffset, true) + } + }, [slackOffset, slackSearchQuery, isLoadingSlackUsers, slackHasMore, appId]) + + // Handle infinite scroll for Slack channels + const handleSlackChannelsScroll = useCallback(() => { + const container = slackChannelsContainerRef.current + if (!container || isLoadingSlackChannels || !slackChannelsHasMore) return + + const { scrollTop, scrollHeight, clientHeight } = container + const scrollThreshold = 50 // pixels from bottom + + if (scrollHeight - scrollTop - clientHeight < scrollThreshold) { + const newOffset = slackChannelsOffset + 50 + setSlackChannelsOffset(newOffset) + fetchSlackChannels(slackChannelsSearchQuery, newOffset, true) + } + }, [slackChannelsOffset, slackChannelsSearchQuery, isLoadingSlackChannels, slackChannelsHasMore, appId]) + + // 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: any) => { + if (option.label === 'People') { + setFilterNavigationPath([ + { id: 'people', name: 'People', type: 'people' } + ]) + // Load initial Slack users when opening People filter (only for Slack) + if (appId === 'slack' && slackUsers.length === 0) { + fetchSlackUsers('', 0, false) + } + // For Gmail, we don't need to fetch anything - just show the input fields + } else if (option.label === 'Channels') { + setFilterNavigationPath([ + { id: 'channels', name: 'Channels', type: 'channels' } + ]) + // Load initial Slack channels when opening Channels filter + if (appId === 'slack' && slackChannels.length === 0) { + fetchSlackChannels('', 0, false) + } + } else if (option.label === 'Timeline') { + setFilterNavigationPath([ + { id: 'timeline', name: 'Timeline', type: 'timeline' } + ]) + } + } + + // State for selected timelines (changed to Set for multi-selection) + const [selectedTimelines, setSelectedTimelines] = useState>(new Set()) + + // State for custom date range picker + 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()) + + const handleTimelineSelect = (timelineOption: { label: string; value: string }) => { + // If "Custom date" is selected, show date picker instead + if (timelineOption.label === 'Custom date') { + setShowDatePicker(true) + return + } + // Toggle timeline selection in the set + setSelectedTimelines(prev => { + const newSet = new Set(prev) + if (newSet.has(timelineOption.label)) { + newSet.delete(timelineOption.label) + } else { + newSet.add(timelineOption.label) + } + return newSet + }) + + // Update filter value with selected timelines + const updatedTimelines = new Set(selectedTimelines) + if (updatedTimelines.has(timelineOption.label)) { + updatedTimelines.delete(timelineOption.label) + } else { + updatedTimelines.add(timelineOption.label) + } + + // Build filter string from selected timelines + const selectedTimelineNames = Array.from(updatedTimelines).map(t => `~${t}`) + + // Preserve existing filters from current filterValue that aren't timeline filters + const currentFilters = filterValue?.split(', ').filter(f => f.trim()) || [] + const existingNonTimelineFilters = currentFilters.filter(f => !f.startsWith('~')) + + // Combine new timeline filters with existing non-timeline filters + const combinedFilters = [...selectedTimelineNames, ...existingNonTimelineFilters] + + onFilterChange?.(combinedFilters.join(', ')) + // Don't close dropdown - let user select multiple timelines + } + + const handlePersonSelect = (person: any) => { + // person.id is the docId from the API response + setSelectedPeople(prev => { + const newSet = new Set(prev) + if (newSet.has(person.id)) { + newSet.delete(person.id) + } else { + newSet.add(person.id) + } + return newSet + }) + + // Update filter value with selected people names (for display) + const updatedPeople = new Set(selectedPeople) + if (updatedPeople.has(person.id)) { + updatedPeople.delete(person.id) + } else { + updatedPeople.add(person.id) + } + + // Build filter string from selected people (using names for display) + const selectedPeopleNames = slackUsers + .filter(u => updatedPeople.has(u.id)) + .map(u => `@${u.name}`) + + // 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 = [...selectedPeopleNames, ...existingNonPeopleFilters] + + onFilterChange?.(combinedFilters.join(', ')) + // Don't close dropdown - let user select multiple people + } + + const handleChannelSelect = (channel: any) => { + // channel.id is the docId from the API response + setSelectedChannels(prev => { + const newSet = new Set(prev) + if (newSet.has(channel.id)) { + newSet.delete(channel.id) + } else { + newSet.add(channel.id) + } + return newSet + }) + + // Update filter value with selected channel docIds + const updatedChannels = new Set(selectedChannels) + if (updatedChannels.has(channel.id)) { + updatedChannels.delete(channel.id) + } else { + updatedChannels.add(channel.id) + } + + // Build filter string from selected channels (using channel IDs) + 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(', ')) + // Don't close dropdown - let user select multiple channels + } + + return ( +
+ {/* 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' ? ( + // Gmail People fields (From, To, CC, BCC) +
+ {(['from', 'to', 'cc', 'bcc'] as const).map((field) => ( +
+ +
+ { + setGmailPeopleInputs(prev => ({ + ...prev, + [field]: e.target.value + })) + }} + onKeyDown={(e) => { + e.stopPropagation() + if (e.key === 'Enter' && gmailPeopleInputs[field].trim()) { + // Add email on Enter key + const email = gmailPeopleInputs[field].trim() + setGmailPeopleFields(prev => ({ + ...prev, + [field]: [...prev[field], email] + })) + setGmailPeopleInputs(prev => ({ + ...prev, + [field]: '' + })) + + // Update filter value + const allFields = { + ...gmailPeopleFields, + [field]: [...gmailPeopleFields[field], email] + } + const filterParts: string[] = [] + if (allFields.from.length > 0) filterParts.push(...allFields.from.map(e => `from:${e}`)) + if (allFields.to.length > 0) filterParts.push(...allFields.to.map(e => `to:${e}`)) + if (allFields.cc.length > 0) filterParts.push(...allFields.cc.map(e => `cc:${e}`)) + if (allFields.bcc.length > 0) filterParts.push(...allFields.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] + + onFilterChange?.(combinedFilters.join(', ')) + } + }} + 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 */} + {gmailPeopleFields[field].length > 0 && ( +
+ {gmailPeopleFields[field].map((email, idx) => ( +
+ {email} + +
+ ))} +
+ )} +
+ ))} +
+ ) : ( + // Slack People selection (existing code) + <> +
+
+ { + const newQuery = e.target.value + setSlackSearchQuery(newQuery) + // Reset offset and fetch with new query + setSlackOffset(0) + fetchSlackUsers(newQuery, 0, false) + }} + 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" + /> +
+
+
+ {isLoadingSlackUsers && slackUsers.length === 0 ? ( +
+ Loading users... +
+ ) : slackUsers.length === 0 ? ( +
+ No users found +
+ ) : ( + <> + {slackUsers.map((person) => ( + { + 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} + + ))} + {isLoadingSlackUsers && ( +
+ Loading more... +
+ )} + + )} +
+ + ) + ) : filterNavigationPath[filterNavigationPath.length - 1]?.type === 'channels' ? ( + // Channels selection view + <> +
+
+ { + const newQuery = e.target.value + setSlackChannelsSearchQuery(newQuery) + // Reset offset and fetch with new query + setSlackChannelsOffset(0) + fetchSlackChannels(newQuery, 0, false) + }} + 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" + /> +
+
+
+ {isLoadingSlackChannels && slackChannels.length === 0 ? ( +
+ Loading channels... +
+ ) : slackChannels.length === 0 ? ( +
+ No channels found +
+ ) : ( + <> + {slackChannels.map((channel) => ( + { + 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} + + ))} + {isLoadingSlackChannels && ( +
+ Loading more... +
+ )} + + )} +
+ + ) : filterNavigationPath[filterNavigationPath.length - 1]?.type === 'timeline' ? ( + // Timeline selection view + <> + {!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} + + ))} +
+ ) : ( + // Date Range Picker +
+ { + 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)}` + + // Add to selected timelines + setSelectedTimelines(prev => { + const newSet = new Set(prev) + newSet.add(dateRangeString) + return newSet + }) + + // Build filter string + const selectedPeopleNames = slackUsers + .filter(u => selectedPeople.has(u.id)) + .map(u => `@${u.name}`) + + const selectedChannelIds = Array.from(selectedChannels).map(id => `#${id}`) + + const updatedTimelines = new Set(selectedTimelines) + updatedTimelines.add(dateRangeString) + const selectedTimelineNames = Array.from(updatedTimelines).map(t => `~${t}`) + + // Preserve existing Gmail people filters from the current filterValue + 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, ...selectedPeopleNames, ...selectedChannelIds, ...selectedTimelineNames].join(', ') + onFilterChange?.(combinedNames) + + // Reset date picker + setShowDatePicker(false) + setDateRange({ start: null, end: null }) + } + }} + onCancel={() => { + setShowDatePicker(false) + setDateRange({ start: null, end: null }) + }} + /> +
+ )} + + ) : null} +
+
+
+
+ {/* Display selected filters as badges */} + {filterValue && filterValue.split(', ').filter(part => part.trim()).map((part, idx) => ( +
+ {part} + +
+ ))} + {/* Input field for adding more filters */} + {(!filterValue || filterValue.split(', ').filter(n => n.trim()).length === 0) && ( + + )} +
+
+
+ )}
) } @@ -378,6 +1520,8 @@ 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( [], @@ -1156,6 +2300,7 @@ function AgentComponent() { setShouldHighlightPrompt(false) cleanupPromptGenerationEventSource() setSelectedEntities([]) + setAppFilters({}) } const handleCreateNewAgent = () => { @@ -1663,6 +2808,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]) @@ -1708,12 +2954,118 @@ function AgentComponent() { } const handleSaveAgent = async () => { + // Helper function to parse filter strings into structured filter objects + const parseFilters = (filterStrings: string[], appId: string) => { + const filters: any[] = [] + let filterId = 1 + + 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[] = [] + + // Parse timeline filters + let timeRange: { startDate: number; endDate: number } | undefined + + 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('@')) { + // Slack person - the display format is @name, but we need to extract the docId + // Since we're saving, we need to convert the display name back to docId + // The filter string contains display names, but we need to store docIds + // For now, we'll store the name and let the backend handle the conversion + // Or we can extract the docId from the filter value if it was stored there + const personName = part.substring(1) + // Store the name for now - ideally we'd have a mapping + senderIds.push(personName) + } else if (part.startsWith('#')) { + // Slack channel - similar to person, store the name + const channelName = part.substring(1) + channelIds.push(channelName) + } else if (part.startsWith('~')) { + // Parse timeline filters + const timelineValue = part.substring(1) + const now = Date.now() + const dayInMs = 24 * 60 * 60 * 1000 + + if (timelineValue === 'Last week') { + timeRange = { + startDate: Math.floor((now - 7 * dayInMs) / 1000), + endDate: Math.floor(now / 1000) + } + } else if (timelineValue === 'Last month') { + timeRange = { + startDate: Math.floor((now - 30 * dayInMs) / 1000), + endDate: Math.floor(now / 1000) + } + } else if (timelineValue === 'Last 7 days') { + timeRange = { + startDate: Math.floor((now - 7 * dayInMs) / 1000), + endDate: Math.floor(now / 1000) + } + } else if (timelineValue === 'Last 14 days') { + timeRange = { + 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) + } + timeRange = { + startDate: parseDate(startStr), + endDate: parseDate(endStr) + } + } + } + } + + // Add parsed fields to filter object + 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 + if (timeRange) filter.timeRange = timeRange + + // Only add filter if it has at least one field + if (Object.keys(filter).length > 1) { + filters.push(filter) + } + } + + return filters.length > 0 ? filters : undefined + } + // Build the new simplified appIntegrations structure const appIntegrationsObject: Record< string, { itemIds: string[] selectedAll: boolean + filters?: any[] } > = {} @@ -1801,10 +3153,24 @@ 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 } } } @@ -1846,6 +3212,9 @@ function AgentComponent() { userEmails: isPublic ? [] : selectedUsers.map((user) => user.email), } + console.log('Agent Payload:', agentPayload) + + try { let response if (editingAgent && editingAgent.externalId) { @@ -2052,12 +3421,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] && @@ -2071,49 +3446,59 @@ function AgentComponent() { } } - // Handle Google Drive items - if ( - selectedIntegrations["googledrive"] - ) { - if(selectedItemsInGoogleDrive.size === 0){ - const googleDriveIntegration = allAvailableIntegrations.find( - (int) => int.id === "googledrive", - ) - if (googleDriveIntegration) { + // Handle Google Drive items - grouped display + if (selectedIntegrations["googledrive"]) { + 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{ + } else { + // Specific items selected, show grouped display + const children: Array<{ + id: string + name: string + icon: React.ReactNode + type?: "file" | "folder" + }> = [] - - // 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 + 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({ - id: `googledrive_${itemId}`, - name: itemTitle, - icon: getDriveEntityIcon(itemEntity), - type: isFolder ? "folder" : "file", + ...googleDriveIntegration, + type: "grouped-parent", + children, }) } } } - } + // Handle Collections - grouped display allAvailableIntegrations.forEach((integration) => { if ( integration.id.startsWith("cl_") && @@ -2123,33 +3508,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, + }) } } }) @@ -3164,42 +4559,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 + } + }} + > + + + + @@ -4339,11 +5814,6 @@ function AgentComponent() { - -

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

{isRagOn && ( diff --git a/server/api/agent.ts b/server/api/agent.ts index d1daf516d..1710ce6ca 100644 --- a/server/api/agent.ts +++ b/server/api/agent.ts @@ -47,9 +47,33 @@ export const createAgentSchema = z.object({ z.record( z.string(), z.object({ - // New AppSelectionMap format + // AppSelectionMap format itemIds: z.array(z.string()), selectedAll: z.boolean(), + + // Multiple filter groups + filters: z + .array( + z.object({ + id: z.number(), // Numeric identifier for this filter + // Gmail-specific filters + from: z.array(z.string()).optional(), + to: z.array(z.string()).optional(), + cc: z.array(z.string()).optional(), + bcc: z.array(z.string()).optional(), + // Slack-specific filters + senderId: z.array(z.string()).optional(), + channelId: z.array(z.string()).optional(), + // Common filters + timeRange: z + .object({ + startDate: z.number(), + endDate: z.number(), + }) + .optional(), + }), + ) + .optional(), }), ), ]) diff --git a/server/api/slack.ts b/server/api/slack.ts index fcb5c6550..e94afc808 100644 --- a/server/api/slack.ts +++ b/server/api/slack.ts @@ -21,7 +21,7 @@ export const slackListSchema = z.object({ .optional() .default("50") .transform((value) => parseInt(value, 10)) - .refine((value) => !isNaN(value) && value > 0 && value <= 100, { + .refine((value) => !isNaN(value) && value > 0, { message: "Limit must be a valid number between 1 and 100", }), offset: z @@ -95,14 +95,9 @@ export const SlackEntitiesApi = async (c: Context) => { 0, ) - const transformedResults = VespaSearchResponseToSearchResult( - results, - { chunkDocument }, - email, - ) return c.json({ - results: transformedResults.results || [], + results: results || [], query: searchParams.query.trim(), entity: searchParams.entity, operation: "search", @@ -126,14 +121,9 @@ export const SlackEntitiesApi = async (c: Context) => { listParams.offset, ) - const transformedResults = VespaSearchResponseToSearchResult( - results, - { chunkDocument }, - email, - ) return c.json({ - results: transformedResults.results || [], + results: results || [], pagination: { limit: listParams.limit, offset: listParams.offset, From 3f4417e9f1f849ca9b5240d5dff9e875a3a5361a Mon Sep 17 00:00:00 2001 From: Rahul Kumar Date: Tue, 21 Oct 2025 14:27:47 +0530 Subject: [PATCH 5/6] refactor: XYNE-186 resolved frontend comments --- frontend/src/components/agent/FilterBadge.tsx | 45 + .../components/agent/GmailPeopleFilter.tsx | 174 +++ .../components/agent/SlackChannelFilter.tsx | 123 ++ .../components/agent/SlackPeopleFilter.tsx | 123 ++ .../src/components/agent/TimelineFilter.tsx | 282 ++++ frontend/src/hooks/useSlackData.ts | 106 ++ frontend/src/routes/_authenticated/agent.tsx | 1291 ++--------------- 7 files changed, 987 insertions(+), 1157 deletions(-) create mode 100644 frontend/src/components/agent/FilterBadge.tsx create mode 100644 frontend/src/components/agent/GmailPeopleFilter.tsx create mode 100644 frontend/src/components/agent/SlackChannelFilter.tsx create mode 100644 frontend/src/components/agent/SlackPeopleFilter.tsx create mode 100644 frontend/src/components/agent/TimelineFilter.tsx create mode 100644 frontend/src/hooks/useSlackData.ts 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..435158670 --- /dev/null +++ b/frontend/src/components/agent/TimelineFilter.tsx @@ -0,0 +1,282 @@ +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 selectedPeopleNames = slackUsers + .filter((u: { id: string; name: string }) => selectedPeople.has(u.id)) + .map((u: { id: string; name: string }) => `@${u.name}`) + + 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, ...selectedPeopleNames, ...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 e3fbbbc19..ec74bb561 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" @@ -81,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 @@ -116,151 +120,6 @@ interface FetchedDataSource { entity: string } -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)) { - // Start new selection - setDateRange({ start: clickedDate, end: null }) - } else { - // Complete the range - 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 ( -
- {/* Month/Year Header */} -
-

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

-
- - -
-
- - {/* Day Names */} -
- {dayNames.map(day => ( -
- {day} -
- ))} -
- - {/* Calendar Days */} -
- {/* Empty cells for days before month starts */} - {Array.from({ length: firstDayOfMonth }).map((_, i) => ( -
- ))} - - {/* Actual days */} - {Array.from({ length: daysInMonth }).map((_, i) => { - const day = i + 1 - const inRange = isDateInRange(day) - const selected = isDateSelected(day) - - return ( - - ) - })} -
- - {/* Action Buttons */} -
- - -
-
- ) -} - const CustomBadge: React.FC = ({ text, onRemove, @@ -269,11 +128,9 @@ const CustomBadge: React.FC = ({ filterValue, onFilterChange, }) => { - const { toast } = useToast() - // Only show filter input for Gmail and Slack - const showFilterInput = appId === 'gmail' || appId === 'slack' - + const showFilterInput = appId === Apps.Gmail || appId === Apps.Slack + // State for filter dropdown const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false) const [filterNavigationPath, setFilterNavigationPath] = useState = ({ type: "filter-root" | "people" | "channels" | "timeline" }>>([]) - // Define filter options based on app - const getFilterOptions = () => { - if (appId === 'slack') { - return [ - { label: 'People', value: '@people' }, - { label: 'Channels', value: '#channel' }, - { label: 'Timeline', value: '~timeline' } - ] - } else if (appId === 'gmail') { - return [ - { label: 'People', value: '@people' }, - { label: 'Timeline', value: '~timeline' } - ] - } - return [] - } - - // State for Slack users - const [slackUsers, setSlackUsers] = useState>([]) - const [slackSearchQuery, setSlackSearchQuery] = useState('') - const [slackOffset, setSlackOffset] = useState(0) - const [slackHasMore, setSlackHasMore] = useState(true) - const [isLoadingSlackUsers, setIsLoadingSlackUsers] = useState(false) - const slackUsersContainerRef = useRef(null) - - // State for selected people + // State for tracking selected items (needed for Timeline filter) const [selectedPeople, setSelectedPeople] = useState>(new Set()) - - // State for Gmail people fields - const [gmailPeopleFields, setGmailPeopleFields] = useState<{ - from: string[] - to: string[] - cc: string[] - bcc: string[] - }>({ - from: [], - to: [], - cc: [], - bcc: [] - }) - - // State for current input values in Gmail people fields - const [gmailPeopleInputs, setGmailPeopleInputs] = useState<{ - from: string - to: string - cc: string - bcc: string - }>({ - from: '', - to: '', - cc: '', - bcc: '' - }) - - // State for Slack channels - const [slackChannels, setSlackChannels] = useState>([]) - const [slackChannelsSearchQuery, setSlackChannelsSearchQuery] = useState('') - const [slackChannelsOffset, setSlackChannelsOffset] = useState(0) - const [slackChannelsHasMore, setSlackChannelsHasMore] = useState(true) - const [isLoadingSlackChannels, setIsLoadingSlackChannels] = useState(false) - const slackChannelsContainerRef = useRef(null) - - // State for selected channels const [selectedChannels, setSelectedChannels] = useState>(new Set()) - - // Parse existing filter values and initialize state + + // Parse selected people and channels from filterValue useEffect(() => { - if (!filterValue || !showFilterInput) return + if (!filterValue) return const filters = filterValue.split(', ').filter(f => f.trim()) - - if (appId === 'gmail') { - // Parse Gmail filters - const newGmailFields = { - from: [] as string[], - to: [] as string[], - cc: [] as string[], - bcc: [] as string[] - } - - filters.forEach(filter => { - if (filter.startsWith('from:')) { - newGmailFields.from.push(filter.substring(5)) - } else if (filter.startsWith('to:')) { - newGmailFields.to.push(filter.substring(3)) - } else if (filter.startsWith('cc:')) { - newGmailFields.cc.push(filter.substring(3)) - } else if (filter.startsWith('bcc:')) { - newGmailFields.bcc.push(filter.substring(4)) - } - }) - - setGmailPeopleFields(newGmailFields) - - // Parse timeline filters for Gmail - const timelineFilters = filters.filter(f => f.startsWith('~')).map(f => f.substring(1)) - setSelectedTimelines(new Set(timelineFilters)) - - } else if (appId === 'slack') { - // Parse Slack filters - timeline filters can be set immediately - const timelineFilters = filters.filter(f => f.startsWith('~')).map(f => f.substring(1)) - - // For Slack people and channels, we need to wait for the API data to map names to IDs - // This will be handled in separate useEffect hooks below - setSelectedTimelines(new Set(timelineFilters)) - } - }, [filterValue, appId, showFilterInput]) - // Effect to map Slack user names to IDs when slackUsers data is available - useEffect(() => { - if (appId === 'slack' && filterValue && slackUsers.length > 0) { - 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)) - const newSelectedPeople = new Set() - peopleNames.forEach(name => { - const user = slackUsers.find(u => u.name === name) - if (user) { - newSelectedPeople.add(user.id) - } - }) - - setSelectedPeople(newSelectedPeople) - } - }, [slackUsers, filterValue, appId]) - - // Effect to parse Slack channel IDs from filter when available - useEffect(() => { - if (appId === 'slack' && filterValue) { - const filters = filterValue.split(', ').filter(f => f.trim()) + // Parse channel IDs const channelIds = filters.filter(f => f.startsWith('#')).map(f => f.substring(1)) - setSelectedChannels(new Set(channelIds)) } }, [filterValue, appId]) - - // Fetch Slack users from API - const fetchSlackUsers = async (query: string = '', offset: number = 0, append: boolean = false) => { - if (appId !== 'slack') return - - setIsLoadingSlackUsers(true) - try { - const limit = offset + 50 - const queryParams: any = { - entity: SlackEntity.User, - limit: limit.toString(), - offset: offset.toString(), - } - - // Add query parameter if search query exists - 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 users = data.results?.root?.children?.map((child: any) => ({ - id: child.fields?.docId || child.id, - name: child.fields?.name || 'Unknown User', - })) || [] - - if (append) { - setSlackUsers(prev => [...prev, ...users]) - } else { - setSlackUsers(users) - } - - // Check if there are more users to load - const hasMore = users.length === 50 - setSlackHasMore(hasMore) - } else { - toast.error({ - title: 'Error', - description: 'Failed to fetch Slack users', - }) - } - } catch (error) { - console.error('Error fetching Slack users:', error) - toast.error({ - title: 'Error', - description: 'An error occurred while fetching Slack users', - }) - } finally { - setIsLoadingSlackUsers(false) - } - } - - // Fetch Slack channels from API - const fetchSlackChannels = async (query: string = '', offset: number = 0, append: boolean = false) => { - if (appId !== 'slack') return - - setIsLoadingSlackChannels(true) - try { - const limit = offset + 50 - const queryParams: any = { - entity: SlackEntity.Channel, - limit: limit.toString(), - offset: offset.toString(), - } - - // Add query parameter if search query exists - 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 channels = data.results?.root?.children?.map((child: any) => ({ - id: child.fields?.docId || child.id, - name: child.fields?.name || 'Unknown Channel', - })) || [] - - if (append) { - setSlackChannels(prev => [...prev, ...channels]) - } else { - setSlackChannels(channels) - } - - // Check if there are more channels to load - const hasMore = channels.length === 50 - setSlackChannelsHasMore(hasMore) - } else { - toast.error({ - title: 'Error', - description: 'Failed to fetch Slack channels', - }) - } - } catch (error) { - console.error('Error fetching Slack channels:', error) - toast.error({ - title: 'Error', - description: 'An error occurred while fetching Slack channels', - }) - } finally { - setIsLoadingSlackChannels(false) + + // 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 [] } - - // Handle infinite scroll for Slack users - const handleSlackUsersScroll = useCallback(() => { - const container = slackUsersContainerRef.current - if (!container || isLoadingSlackUsers || !slackHasMore) return - - const { scrollTop, scrollHeight, clientHeight } = container - const scrollThreshold = 50 // pixels from bottom - - if (scrollHeight - scrollTop - clientHeight < scrollThreshold) { - const newOffset = slackOffset + 50 - setSlackOffset(newOffset) - fetchSlackUsers(slackSearchQuery, newOffset, true) - } - }, [slackOffset, slackSearchQuery, isLoadingSlackUsers, slackHasMore, appId]) - - // Handle infinite scroll for Slack channels - const handleSlackChannelsScroll = useCallback(() => { - const container = slackChannelsContainerRef.current - if (!container || isLoadingSlackChannels || !slackChannelsHasMore) return - - const { scrollTop, scrollHeight, clientHeight } = container - const scrollThreshold = 50 // pixels from bottom - - if (scrollHeight - scrollTop - clientHeight < scrollThreshold) { - const newOffset = slackChannelsOffset + 50 - setSlackChannelsOffset(newOffset) - fetchSlackChannels(slackChannelsSearchQuery, newOffset, true) - } - }, [slackChannelsOffset, slackChannelsSearchQuery, isLoadingSlackChannels, slackChannelsHasMore, appId]) // Get icon for filter option const getFilterIcon = (label: string) => { @@ -572,24 +191,15 @@ const CustomBadge: React.FC = ({ } } - const handleFilterOptionSelect = (option: any) => { + const handleFilterOptionSelect = (option: { label: string; value: string }) => { if (option.label === 'People') { setFilterNavigationPath([ { id: 'people', name: 'People', type: 'people' } ]) - // Load initial Slack users when opening People filter (only for Slack) - if (appId === 'slack' && slackUsers.length === 0) { - fetchSlackUsers('', 0, false) - } - // For Gmail, we don't need to fetch anything - just show the input fields } else if (option.label === 'Channels') { setFilterNavigationPath([ { id: 'channels', name: 'Channels', type: 'channels' } ]) - // Load initial Slack channels when opening Channels filter - if (appId === 'slack' && slackChannels.length === 0) { - fetchSlackChannels('', 0, false) - } } else if (option.label === 'Timeline') { setFilterNavigationPath([ { id: 'timeline', name: 'Timeline', type: 'timeline' } @@ -597,124 +207,21 @@ const CustomBadge: React.FC = ({ } } - // State for selected timelines (changed to Set for multi-selection) - const [selectedTimelines, setSelectedTimelines] = useState>(new Set()) - - // State for custom date range picker - 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()) - - const handleTimelineSelect = (timelineOption: { label: string; value: string }) => { - // If "Custom date" is selected, show date picker instead - if (timelineOption.label === 'Custom date') { - setShowDatePicker(true) - return - } - // Toggle timeline selection in the set - setSelectedTimelines(prev => { - const newSet = new Set(prev) - if (newSet.has(timelineOption.label)) { - newSet.delete(timelineOption.label) - } else { - newSet.add(timelineOption.label) - } - return newSet - }) + 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 filter value with selected timelines - const updatedTimelines = new Set(selectedTimelines) - if (updatedTimelines.has(timelineOption.label)) { - updatedTimelines.delete(timelineOption.label) - } else { - updatedTimelines.add(timelineOption.label) - } - - // Build filter string from selected timelines - const selectedTimelineNames = Array.from(updatedTimelines).map(t => `~${t}`) - - // Preserve existing filters from current filterValue that aren't timeline filters - const currentFilters = filterValue?.split(', ').filter(f => f.trim()) || [] - const existingNonTimelineFilters = currentFilters.filter(f => !f.startsWith('~')) - - // Combine new timeline filters with existing non-timeline filters - const combinedFilters = [...selectedTimelineNames, ...existingNonTimelineFilters] - - onFilterChange?.(combinedFilters.join(', ')) - // Don't close dropdown - let user select multiple timelines - } - - const handlePersonSelect = (person: any) => { - // person.id is the docId from the API response - setSelectedPeople(prev => { - const newSet = new Set(prev) - if (newSet.has(person.id)) { - newSet.delete(person.id) - } else { - newSet.add(person.id) - } - return newSet - }) - - // Update filter value with selected people names (for display) - const updatedPeople = new Set(selectedPeople) - if (updatedPeople.has(person.id)) { - updatedPeople.delete(person.id) - } else { - updatedPeople.add(person.id) - } - - // Build filter string from selected people (using names for display) - const selectedPeopleNames = slackUsers - .filter(u => updatedPeople.has(u.id)) - .map(u => `@${u.name}`) - - // 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 = [...selectedPeopleNames, ...existingNonPeopleFilters] - - onFilterChange?.(combinedFilters.join(', ')) - // Don't close dropdown - let user select multiple people - } - - const handleChannelSelect = (channel: any) => { - // channel.id is the docId from the API response - setSelectedChannels(prev => { - const newSet = new Set(prev) - if (newSet.has(channel.id)) { - newSet.delete(channel.id) - } else { - newSet.add(channel.id) - } - return newSet - }) - - // Update filter value with selected channel docIds - const updatedChannels = new Set(selectedChannels) - if (updatedChannels.has(channel.id)) { - updatedChannels.delete(channel.id) - } else { - updatedChannels.add(channel.id) + // 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 + }) } - - // Build filter string from selected channels (using channel IDs) - 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(', ')) - // Don't close dropdown - let user select multiple channels } return ( @@ -828,439 +335,37 @@ const CustomBadge: React.FC = ({ ) : filterNavigationPath[filterNavigationPath.length - 1]?.type === 'people' ? ( // People selection view - different for Gmail vs Slack appId === 'gmail' ? ( - // Gmail People fields (From, To, CC, BCC) -
- {(['from', 'to', 'cc', 'bcc'] as const).map((field) => ( -
- -
- { - setGmailPeopleInputs(prev => ({ - ...prev, - [field]: e.target.value - })) - }} - onKeyDown={(e) => { - e.stopPropagation() - if (e.key === 'Enter' && gmailPeopleInputs[field].trim()) { - // Add email on Enter key - const email = gmailPeopleInputs[field].trim() - setGmailPeopleFields(prev => ({ - ...prev, - [field]: [...prev[field], email] - })) - setGmailPeopleInputs(prev => ({ - ...prev, - [field]: '' - })) - - // Update filter value - const allFields = { - ...gmailPeopleFields, - [field]: [...gmailPeopleFields[field], email] - } - const filterParts: string[] = [] - if (allFields.from.length > 0) filterParts.push(...allFields.from.map(e => `from:${e}`)) - if (allFields.to.length > 0) filterParts.push(...allFields.to.map(e => `to:${e}`)) - if (allFields.cc.length > 0) filterParts.push(...allFields.cc.map(e => `cc:${e}`)) - if (allFields.bcc.length > 0) filterParts.push(...allFields.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] - - onFilterChange?.(combinedFilters.join(', ')) - } - }} - 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 */} - {gmailPeopleFields[field].length > 0 && ( -
- {gmailPeopleFields[field].map((email, idx) => ( -
- {email} - -
- ))} -
- )} -
- ))} -
+ {})} + /> ) : ( - // Slack People selection (existing code) - <> -
-
- { - const newQuery = e.target.value - setSlackSearchQuery(newQuery) - // Reset offset and fetch with new query - setSlackOffset(0) - fetchSlackUsers(newQuery, 0, false) - }} - 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" - /> -
-
-
- {isLoadingSlackUsers && slackUsers.length === 0 ? ( -
- Loading users... -
- ) : slackUsers.length === 0 ? ( -
- No users found -
- ) : ( - <> - {slackUsers.map((person) => ( - { - 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} - - ))} - {isLoadingSlackUsers && ( -
- Loading more... -
- )} - - )} -
- + {})} + /> ) ) : filterNavigationPath[filterNavigationPath.length - 1]?.type === 'channels' ? ( - // Channels selection view - <> -
-
- { - const newQuery = e.target.value - setSlackChannelsSearchQuery(newQuery) - // Reset offset and fetch with new query - setSlackChannelsOffset(0) - fetchSlackChannels(newQuery, 0, false) - }} - 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" - /> -
-
-
- {isLoadingSlackChannels && slackChannels.length === 0 ? ( -
- Loading channels... -
- ) : slackChannels.length === 0 ? ( -
- No channels found -
- ) : ( - <> - {slackChannels.map((channel) => ( - { - 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} - - ))} - {isLoadingSlackChannels && ( -
- Loading more... -
- )} - - )} -
- + {})} + /> ) : filterNavigationPath[filterNavigationPath.length - 1]?.type === 'timeline' ? ( - // Timeline selection view - <> - {!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} - - ))} -
- ) : ( - // Date Range Picker -
- { - 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)}` - - // Add to selected timelines - setSelectedTimelines(prev => { - const newSet = new Set(prev) - newSet.add(dateRangeString) - return newSet - }) - - // Build filter string - const selectedPeopleNames = slackUsers - .filter(u => selectedPeople.has(u.id)) - .map(u => `@${u.name}`) - - const selectedChannelIds = Array.from(selectedChannels).map(id => `#${id}`) - - const updatedTimelines = new Set(selectedTimelines) - updatedTimelines.add(dateRangeString) - const selectedTimelineNames = Array.from(updatedTimelines).map(t => `~${t}`) - - // Preserve existing Gmail people filters from the current filterValue - 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, ...selectedPeopleNames, ...selectedChannelIds, ...selectedTimelineNames].join(', ') - onFilterChange?.(combinedNames) - - // Reset date picker - setShowDatePicker(false) - setDateRange({ start: null, end: null }) - } - }} - onCancel={() => { - setShowDatePicker(false) - setDateRange({ start: null, end: null }) - }} - /> -
- )} - + {})} + selectedPeople={selectedPeople} + selectedChannels={selectedChannels} + /> ) : null}
- {/* Display selected filters as badges */} - {filterValue && filterValue.split(', ').filter(part => part.trim()).map((part, idx) => ( -
- {part} - -
- ))} - {/* Input field for adding more filters */} - {(!filterValue || filterValue.split(', ').filter(n => n.trim()).length === 0) && ( - - )} + f.trim()) || []} + onRemoveFilter={handleRemoveFilter} + />
@@ -1333,11 +438,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 @@ -1529,14 +629,6 @@ function AgentComponent() { // 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> >({}) @@ -1792,57 +884,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([]) @@ -2357,7 +1398,6 @@ function AgentComponent() { setIsGeneratingPrompt(false) setShouldHighlightPrompt(false) cleanupPromptGenerationEventSource() - setSelectedEntities([]) setAppFilters({}) } @@ -2828,12 +1868,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 @@ -3008,11 +2042,55 @@ 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 @@ -3029,9 +2107,6 @@ function AgentComponent() { const senderIds: string[] = [] const channelIds: string[] = [] - // Parse timeline filters - let timeRange: { startDate: number; endDate: number } | undefined - for (const part of filterParts) { if (part.startsWith('from:')) { fromEmails.push(part.substring(5)) @@ -3055,61 +2130,47 @@ function AgentComponent() { const channelName = part.substring(1) channelIds.push(channelName) } else if (part.startsWith('~')) { - // Parse timeline filters + // Parse timeline filters and collect them const timelineValue = part.substring(1) - const now = Date.now() - const dayInMs = 24 * 60 * 60 * 1000 - - if (timelineValue === 'Last week') { - timeRange = { - startDate: Math.floor((now - 7 * dayInMs) / 1000), - endDate: Math.floor(now / 1000) - } - } else if (timelineValue === 'Last month') { - timeRange = { - startDate: Math.floor((now - 30 * dayInMs) / 1000), - endDate: Math.floor(now / 1000) - } - } else if (timelineValue === 'Last 7 days') { - timeRange = { - startDate: Math.floor((now - 7 * dayInMs) / 1000), - endDate: Math.floor(now / 1000) - } - } else if (timelineValue === 'Last 14 days') { - timeRange = { - 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) - } - timeRange = { - startDate: parseDate(startStr), - endDate: parseDate(endStr) - } + const timeRange = parseTimelineValue(timelineValue) + if (timeRange) { + allTimeRanges.push(timeRange) } } } - // Add parsed fields to filter object + // 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 - if (timeRange) filter.timeRange = timeRange - // Only add filter if it has at least one field + // 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 } @@ -3131,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, @@ -3229,14 +2285,6 @@ function AgentComponent() { } } - // 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"] = { @@ -3261,7 +2309,6 @@ function AgentComponent() { isPublic: isPublic, isRagOn: isRagOn, appIntegrations: appIntegrationsObject, - docIds: selectedEntities, // Only include userEmails for private agents userEmails: isPublic ? [] : selectedUsers.map((user) => user.email), } @@ -3623,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. @@ -3815,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 } @@ -5764,74 +4809,6 @@ function AgentComponent() { - {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 && (