From e1396af2be4b6486555c49b0590393a2b693c3be Mon Sep 17 00:00:00 2001 From: Baba-2001 Date: Tue, 3 Mar 2026 15:35:50 +0530 Subject: [PATCH 1/2] feat(chat): add Deal option to attachment dropdown - Add deal type to ChatContextManager (entity type, fetch deals, deal items) - Add Deal dialog title/description in ChatContextDialog - Add 'Add deal' menu item in ChatContextMenuItems - Hydrate deal context on chat page (first message and history load) Made-with: Cursor --- .beads/issues.jsonl | 2 +- .gitignore | 1 + apps/web/src/app/(app)/chat/[chatId]/page.tsx | 29 ++++-- .../src/components/Chat/ChatContextDialog.tsx | 10 ++- .../components/Chat/ChatContextManager.tsx | 88 +++++++++++++++---- .../components/Chat/ChatContextMenuItems.tsx | 8 +- 6 files changed, 110 insertions(+), 28 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index f54d772..06ec1ad 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -4,7 +4,7 @@ {"id":"zuko-w0p","title":"Chat context for contacts, deals, and companies (agentic CRM from chat)","description":"Epic: Enable users to add contacts, deals, and companies as context to a chat so all conversations happen in that context. Support autocomplete when adding entities. Send context preferences to backend and use them for responses. Future: allow updating contact/deal/company (e.g. email, phone, status) from natural language in chat without leaving to Contacts/Deals UI. Source: Call transcript with Yuva Kumar.","status":"open","priority":2,"issue_type":"epic","created_at":"2026-03-03T11:05:05.271667+05:30","created_by":"babanitturu","updated_at":"2026-03-03T11:05:05.271667+05:30"} {"id":"zuko-w0p.1","title":"Chat attachment dropdown: add Contact, Deal, Company options","description":"UI change: In the new-chat plus menu (attachment dropdown), make 'Add photos and files' the first option. Add a second row with three options in one line: Add contact, Add deal, Add company. These allow the user to attach CRM entities as context to the chat. Small PR: dropdown component + icons/labels.","acceptance_criteria":"Dropdown shows photos/files first; second row shows Add contact | Add deal | Add company. Clicking each opens the corresponding picker (picker implementation is a separate task).","status":"open","priority":2,"issue_type":"task","created_at":"2026-03-03T11:05:19.94792+05:30","created_by":"babanitturu","updated_at":"2026-03-03T11:16:20.051803+05:30","dependencies":[{"issue_id":"zuko-w0p.1","depends_on_id":"zuko-w0p","type":"parent-child","created_at":"2026-03-03T11:05:19.954587+05:30","created_by":"babanitturu"}]} {"id":"zuko-w0p.2","title":"Contact/Deal/Company picker with autocomplete","description":"When user clicks Add contact (or Add deal / Add company), show a search input. Typing (e.g. 'bill') shows autocomplete suggestions (e.g. Bill Gates). User selects one or more; selected entities are added to chat context. Support multiple entities and combination of types (one contact + one deal + one company). Reuse or integrate with existing contact/deal/company APIs for search. Small PR: picker component + search API usage.","acceptance_criteria":"Type partial name → list filters with autocomplete. Select item → added to context. Can add multiple contacts/deals/companies. Context displayed (e.g. chips) in chat input area.","status":"open","priority":2,"issue_type":"task","created_at":"2026-03-03T11:05:24.879993+05:30","created_by":"babanitturu","updated_at":"2026-03-03T11:05:24.879993+05:30","dependencies":[{"issue_id":"zuko-w0p.2","depends_on_id":"zuko-w0p","type":"parent-child","created_at":"2026-03-03T11:05:24.887111+05:30","created_by":"babanitturu"},{"issue_id":"zuko-w0p.2","depends_on_id":"zuko-w0p.1","type":"blocks","created_at":"2026-03-03T11:06:07.04574+05:30","created_by":"babanitturu"}]} -{"id":"zuko-w0p.3","title":"Context preferences: backend accept/store and frontend send multiple contextEntities; agent responds in context","description":"Single task covering both backend and frontend for chat context.\n\nBackend: Chat API accepts context preferences (e.g. contactIds[], dealIds[], companyIds[] or a unified contextEntities[]). Persist or pass context with the message/conversation so the agent can answer in the context of the selected contact/deal/company. Define DTOs and update chat controller and message storage.\n\nFrontend: Wire the contact/deal/company picker selections to the chat API. When the user sends a message, include the current context (selected entities) in the request. Multiple contextEntities can be sent from the UI (e.g. one contact + one deal + one company). Ensure context is sent with each message in the conversation.\n\nThe agent should respond in the context of the provided entities (e.g. in the context of this contact, deal, and company).","acceptance_criteria":"Backend: POST chat/messages accepts optional context payload (multiple contextEntities). Context is stored or associated with the conversation/message. Frontend: Sending a message includes selected contact/deal/company context. Agent responses are in the context of the provided entities.","status":"open","priority":2,"issue_type":"task","created_at":"2026-03-03T11:05:29.628034+05:30","created_by":"babanitturu","updated_at":"2026-03-03T11:16:28.404006+05:30","dependencies":[{"issue_id":"zuko-w0p.3","depends_on_id":"zuko-w0p","type":"parent-child","created_at":"2026-03-03T11:05:29.634675+05:30","created_by":"babanitturu"}]} +{"id":"zuko-w0p.3","title":"Context preferences: frontend send multiple contextEntities(contact, company, deals); agent should be able to respond with multile context combinations.\n","description":"Single task covering both backend and frontend for chat context.\n\nBackend: Chat API accepts context preferences (e.g. contactIds[], dealIds[], companyIds[] or a unified contextEntities[]). Persist or pass context with the message/conversation so the agent can answer in the context of the selected contact/deal/company. Define DTOs and update chat controller and message storage.\n\nFrontend: Wire the contact/deal/company picker selections to the chat API. When the user sends a message, include the current context (selected entities) in the request. Multiple contextEntities can be sent from the UI (e.g. one contact + one deal + one company). Ensure context is sent with each message in the conversation.\n\nThe agent should respond in the context of the provided entities (e.g. in the context of this contact, deal, and company).","acceptance_criteria":"Backend: POST chat/messages accepts optional context payload (multiple contextEntities). Context is stored or associated with the conversation/message. Frontend: Sending a message includes selected contact/deal/company context. Agent responses are in the context of the provided entities.","status":"open","priority":2,"issue_type":"task","created_at":"2026-03-03T11:05:29.628034+05:30","created_by":"babanitturu","updated_at":"2026-03-03T14:55:54.35405+05:30","dependencies":[{"issue_id":"zuko-w0p.3","depends_on_id":"zuko-w0p","type":"parent-child","created_at":"2026-03-03T11:05:29.634675+05:30","created_by":"babanitturu"}]} {"id":"zuko-w0p.4","title":"Frontend: send context preferences with every chat message","description":"Wire the contact/deal/company picker selections to the chat API. When user sends a message, include the current context (selected contact IDs, deal IDs, company IDs) in the request. Ensure context is sent with each message in the conversation. Small PR: state in chat component + API client payload.","acceptance_criteria":"Sending a message includes context preferences when user has added contacts/deals/companies. Backend receives and can log/use them.","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-03-03T11:05:33.442733+05:30","created_by":"babanitturu","updated_at":"2026-03-03T11:16:29.74942+05:30","dependencies":[{"issue_id":"zuko-w0p.4","depends_on_id":"zuko-w0p","type":"parent-child","created_at":"2026-03-03T11:05:33.44944+05:30","created_by":"babanitturu"}],"deleted_at":"2026-03-03T11:16:29.74942+05:30","deleted_by":"daemon","delete_reason":"delete","original_type":"task"} {"id":"zuko-w0p.5","title":"Backend: use chat context in prompt/retrieval (contact, deal, company data)","description":"When a message has context (contactIds, dealIds, companyIds), fetch the relevant entity data (contact details, deal details, company details) and include them in the system prompt or RAG context so the model answers in the context of that person/deal/company. Small PR: service layer that loads entities and injects into prompt/context.","acceptance_criteria":"With context set, model responses are grounded in the selected contact/deal/company data. Without context, behavior unchanged.","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-03-03T11:05:37.003106+05:30","created_by":"babanitturu","updated_at":"2026-03-03T11:16:15.535151+05:30","dependencies":[{"issue_id":"zuko-w0p.5","depends_on_id":"zuko-w0p","type":"parent-child","created_at":"2026-03-03T11:05:37.012167+05:30","created_by":"babanitturu"},{"issue_id":"zuko-w0p.5","depends_on_id":"zuko-w0p.3","type":"blocks","created_at":"2026-03-03T11:06:07.217888+05:30","created_by":"babanitturu"}],"deleted_at":"2026-03-03T11:16:15.535151+05:30","deleted_by":"daemon","delete_reason":"delete","original_type":"task"} {"id":"zuko-w0p.6","title":"Agentic updates from chat (update contact/deal/company via natural language)","description":"Allow user to say in chat e.g. 'This person'\\''s email changed to X, phone to Y', 'Update deal status to closed'. Agent interprets intent and updates the contact/deal/company (or creates structured actions) so user does not need to go to Contacts/Deals UI to edit.\n","acceptance_criteria":"User can request contact/deal/company updates in natural language in a context-aware chat; backend applies updates (with confirmation or audit).","status":"open","priority":2,"issue_type":"task","created_at":"2026-03-03T11:05:41.718235+05:30","created_by":"babanitturu","updated_at":"2026-03-03T11:09:36.961438+05:30","dependencies":[{"issue_id":"zuko-w0p.6","depends_on_id":"zuko-w0p","type":"parent-child","created_at":"2026-03-03T11:05:41.726704+05:30","created_by":"babanitturu"},{"issue_id":"zuko-w0p.6","depends_on_id":"zuko-w0p.3","type":"blocks","created_at":"2026-03-03T11:16:30.415474+05:30","created_by":"babanitturu"}]} diff --git a/.gitignore b/.gitignore index 3e037c1..4c71680 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,7 @@ test-output /.codex/config.toml /.codex/config.toml.bak /.codex/skills +.cursor/commands/ /.opencode/skill /.skillz /.vibe/skills diff --git a/apps/web/src/app/(app)/chat/[chatId]/page.tsx b/apps/web/src/app/(app)/chat/[chatId]/page.tsx index 923562e..c88b403 100644 --- a/apps/web/src/app/(app)/chat/[chatId]/page.tsx +++ b/apps/web/src/app/(app)/chat/[chatId]/page.tsx @@ -16,6 +16,7 @@ import { useParams } from 'next/navigation'; import { useInvalidateChats } from '@/hooks/use-chats'; import { contactsApi } from '@/lib/api/contacts'; import { companiesApi } from '@/lib/api/companies'; +import { dealsApi } from '@/lib/api/deals'; export default function ChatPage() { const params = useParams(); @@ -65,7 +66,7 @@ export default function ChatPage() { type: string; id: number; }): Promise => { - const type = ref.type as 'contact' | 'company'; + const type = ref.type as 'contact' | 'company' | 'deal'; try { if (type === 'contact') { const c = await contactsApi.getContact(ref.id); @@ -76,18 +77,32 @@ export default function ChatPage() { metadata: { type: 'contact', entityId: ref.id }, }; } - const c = await companiesApi.getCompany(ref.id); + if (type === 'company') { + const c = await companiesApi.getCompany(ref.id); + return { + type: 'company', + id: ref.id, + name: c.companyName, + metadata: { type: 'company', entityId: ref.id }, + }; + } + const d = await dealsApi.getDeal(ref.id); return { - type: 'company', + type: 'deal', id: ref.id, - name: c.companyName, - metadata: { type: 'company', entityId: ref.id }, + name: d.title, + metadata: { type: 'deal', entityId: ref.id }, }; } catch { return { type, id: ref.id, - name: type === 'contact' ? 'Contact' : 'Company', + name: + type === 'contact' + ? 'Contact' + : type === 'company' + ? 'Company' + : 'Deal', metadata: { type, entityId: ref.id }, }; } @@ -149,7 +164,7 @@ export default function ChatPage() { // Hydrate context entities from backend response (includes names) const hydratedEntities: ChatEntity[] = contextRefs.map( (ref: { type: string; id: number; name: string }) => ({ - type: ref.type as 'contact' | 'company', + type: ref.type as 'contact' | 'company' | 'deal', id: ref.id, name: ref.name, // Use actual name from backend metadata: { type: ref.type, entityId: ref.id }, diff --git a/apps/web/src/components/Chat/ChatContextDialog.tsx b/apps/web/src/components/Chat/ChatContextDialog.tsx index 6b68222..63f56b2 100644 --- a/apps/web/src/components/Chat/ChatContextDialog.tsx +++ b/apps/web/src/components/Chat/ChatContextDialog.tsx @@ -17,14 +17,18 @@ export const ChatContextDialog = () => { title={ contextManager.dialogConfig.type === 'contact' ? 'Add Contacts' - : 'Add Companies' + : contextManager.dialogConfig.type === 'company' + ? 'Add Companies' + : 'Add Deals' } description={ contextManager.dialogConfig.type === 'contact' ? 'Select contacts to add as context' - : 'Select companies to add as context' + : contextManager.dialogConfig.type === 'company' + ? 'Select companies to add as context' + : 'Select deals to add as context' } - searchPlaceholder={`Search ${contextManager.dialogConfig.type}s...`} + searchPlaceholder={`Search ${contextManager.dialogConfig.type ?? 'items'}...`} items={contextManager.dialogConfig.items} selectedIds={contextManager.dialogConfig.selectedIds} onSelectionChange={contextManager.dialogConfig.onSelectionChange} diff --git a/apps/web/src/components/Chat/ChatContextManager.tsx b/apps/web/src/components/Chat/ChatContextManager.tsx index a74e6a0..4fd26a5 100644 --- a/apps/web/src/components/Chat/ChatContextManager.tsx +++ b/apps/web/src/components/Chat/ChatContextManager.tsx @@ -2,7 +2,7 @@ import { useState, useCallback, useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { UserIcon, BuildingIcon } from 'lucide-react'; +import { UserIcon, BuildingIcon, Briefcase } from 'lucide-react'; import { ContextChip, EntityItem, @@ -10,12 +10,13 @@ import { } from '@zuko/ui-kit'; import { contactsApi, type Contact } from '@/lib/api/contacts'; import { companiesApi, type SalesCompany } from '@/lib/api/companies'; +import { dealsApi, type Deal } from '@/lib/api/deals'; // ============================================================================ // Types // ============================================================================ -export type ChatEntityType = 'contact' | 'company'; +export type ChatEntityType = 'contact' | 'company' | 'deal'; export interface ChatEntity { type: ChatEntityType; @@ -66,6 +67,29 @@ function companyToEntityItem(company: SalesCompany): EntityItem { }; } +function dealToEntityItem(deal: Deal): EntityItem { + const description = + deal.stage || + (deal.value != null + ? `${deal.currency || 'USD'} ${deal.value.toLocaleString()}` + : ''); + + return { + id: `deal-${deal.id}`, + label: deal.title, + description, + icon: , + metadata: { + type: 'deal', + entityId: deal.id, + stage: deal.stage, + value: deal.value, + currency: deal.currency, + summary: deal.summary, + }, + }; +} + function entityItemToChatEntity(item: EntityItem): ChatEntity { const type = item.metadata?.type as ChatEntityType; const entityId = item.metadata?.entityId as number; @@ -90,9 +114,9 @@ export const ChatContextManager = ({ onContextChange, }: ChatContextManagerProps) => { const [dialogOpen, setDialogOpen] = useState(false); - const [dialogType, setDialogType] = useState<'contact' | 'company' | null>( - null, - ); + const [dialogType, setDialogType] = useState< + 'contact' | 'company' | 'deal' | null + >(null); const [selectedIds, setSelectedIds] = useState([]); // Use PromptInput's referenced sources hook - must be inside PromptInput context @@ -110,6 +134,11 @@ export const ChatContextManager = ({ queryFn: () => companiesApi.getCompanies({ limit: 100 }), }); + const { data: dealsData, isLoading: dealsLoading } = useQuery({ + queryKey: ['deals', { limit: 100 }], + queryFn: () => dealsApi.getDeals({ limit: 100 }), + }); + // Convert to generic EntityItem[] const contactItems = useMemo( () => contactsData?.contacts.map(contactToEntityItem) || [], @@ -121,6 +150,11 @@ export const ChatContextManager = ({ [companiesData], ); + const dealItems = useMemo( + () => dealsData?.deals.map(dealToEntityItem) || [], + [dealsData], + ); + // Get current entities from sources const currentEntities = useMemo(() => { return sources.map((source) => ({ @@ -132,11 +166,14 @@ export const ChatContextManager = ({ }, [sources]); // Handle dialog open - const handleOpenDialog = useCallback((type: 'contact' | 'company') => { - setDialogType(type); - setSelectedIds([]); - setDialogOpen(true); - }, []); + const handleOpenDialog = useCallback( + (type: 'contact' | 'company' | 'deal') => { + setDialogType(type); + setSelectedIds([]); + setDialogOpen(true); + }, + [], + ); // Handle entity removal const handleRemove = useCallback( @@ -199,10 +236,16 @@ export const ChatContextManager = ({ ? contactItems : dialogType === 'company' ? companyItems - : []; + : dialogType === 'deal' + ? dealItems + : []; const isDialogLoading = - dialogType === 'contact' ? contactsLoading : companiesLoading; + dialogType === 'contact' + ? contactsLoading + : dialogType === 'company' + ? companiesLoading + : dealsLoading; return { currentEntities, @@ -234,12 +277,15 @@ export const ChatContextDisplay = () => {
{sources.map((source) => { const type = (source as any).metadata?.type as ChatEntityType; - const color = type === 'contact' ? 'blue' : 'purple'; + const color = + type === 'contact' ? 'blue' : type === 'company' ? 'purple' : 'green'; const icon = type === 'contact' ? ( - ) : ( + ) : type === 'company' ? ( + ) : ( + ); const rawLabel = (source as any).title ?? ''; const label = @@ -249,7 +295,9 @@ export const ChatContextDisplay = () => { ? rawLabel : type === 'contact' ? 'Contact' - : 'Company'; + : type === 'company' + ? 'Company' + : 'Deal'; return ( { // ============================================================================ export interface EntitySelectorTriggerProps { - onSelectType: (type: 'contact' | 'company') => void; + onSelectType: (type: 'contact' | 'company' | 'deal') => void; } export const EntitySelectorTrigger = ({ @@ -295,6 +343,14 @@ export const EntitySelectorTrigger = ({ Add company + ); }; diff --git a/apps/web/src/components/Chat/ChatContextMenuItems.tsx b/apps/web/src/components/Chat/ChatContextMenuItems.tsx index 2ec44c8..08d22b0 100644 --- a/apps/web/src/components/Chat/ChatContextMenuItems.tsx +++ b/apps/web/src/components/Chat/ChatContextMenuItems.tsx @@ -1,7 +1,7 @@ 'use client'; import { PromptInputActionMenuItem } from '@zuko/ui-kit'; -import { UserIcon, BuildingIcon } from 'lucide-react'; +import { UserIcon, BuildingIcon, Briefcase } from 'lucide-react'; import { useChatContextManager } from './ChatContextWrapper'; /** @@ -25,6 +25,12 @@ export const ChatContextMenuItems = () => { Add company + contextManager.handleOpenDialog('deal')} + > + + Add deal + ); }; From ffa2f2121e9b2c3288f11d1ddca54e4a70d6a9c7 Mon Sep 17 00:00:00 2001 From: Baba-2001 Date: Tue, 3 Mar 2026 16:03:54 +0530 Subject: [PATCH 2/2] test(web-e2e): add chat attachment E2E tests for Contact, Company, Deal - Attachment menu shows Contact, Company, and Deal options - Can select and add one contact/company/deal as context; assert chip visible - Scope attachment trigger to main to avoid opening sidebar user menu Made-with: Cursor --- apps/web-e2e/src/chat.spec.ts | 92 +++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/apps/web-e2e/src/chat.spec.ts b/apps/web-e2e/src/chat.spec.ts index 3609193..73ac8fa 100644 --- a/apps/web-e2e/src/chat.spec.ts +++ b/apps/web-e2e/src/chat.spec.ts @@ -218,6 +218,98 @@ test.describe("Chat", () => { await expect(textarea).toHaveValue(""); }); + test.describe("chat attachments", () => { + test("attachment menu shows Contact, Company, and Deal options", async ({ + page, + }) => { + await page.goto(`/chat/${chatId}`); + + const attachmentTrigger = page + .getByRole("main") + .locator('button[aria-haspopup="menu"]') + .first(); + await expect(attachmentTrigger).toBeVisible({ timeout: 5000 }); + await attachmentTrigger.click(); + + // Menu should list context options: Add contact, Add company, Add deal + await expect( + page.getByRole("menuitem", { name: /add contact/i }), + ).toBeVisible({ timeout: 3000 }); + await expect( + page.getByRole("menuitem", { name: /add company/i }), + ).toBeVisible(); + await expect( + page.getByRole("menuitem", { name: /add deal/i }), + ).toBeVisible(); + }); + + test("can select and add one contact as context", async ({ page }) => { + await page.goto(`/chat/${chatId}`); + + const attachmentTrigger = page + .getByRole("main") + .locator('button[aria-haspopup="menu"]') + .first(); + await attachmentTrigger.click(); + await page.getByRole("menuitem", { name: /add contact/i }).click(); + + const dialog = page.getByRole("dialog", { name: /add contacts/i }); + await expect(dialog).toBeVisible({ timeout: 5000 }); + await expect(dialog.getByText("TEST CONTACT")).toBeVisible({ + timeout: 10000, + }); + await dialog.getByRole("button", { name: /TEST CONTACT/ }).click(); + await dialog.getByRole("button", { name: /^Add/ }).click(); + + await expect(dialog).not.toBeVisible(); + await expect(page.getByText("TEST CONTACT").first()).toBeVisible(); + }); + + test("can select and add one company as context", async ({ page }) => { + await page.goto(`/chat/${chatId}`); + + const attachmentTrigger = page + .getByRole("main") + .locator('button[aria-haspopup="menu"]') + .first(); + await attachmentTrigger.click(); + await page.getByRole("menuitem", { name: /add company/i }).click(); + + const dialog = page.getByRole("dialog", { name: /add companies/i }); + await expect(dialog).toBeVisible({ timeout: 5000 }); + await expect(dialog.getByText("TEST COMPANY")).toBeVisible({ + timeout: 10000, + }); + await dialog.getByRole("button", { name: /TEST COMPANY/ }).click(); + await dialog.getByRole("button", { name: /^Add/ }).click(); + + await expect(dialog).not.toBeVisible(); + await expect(page.getByText("TEST COMPANY").first()).toBeVisible(); + }); + + test("can select and add one deal as context", async ({ page }) => { + await page.goto(`/chat/${chatId}`); + + const attachmentTrigger = page + .getByRole("main") + .locator('button[aria-haspopup="menu"]') + .first(); + await attachmentTrigger.click(); + await page.getByRole("menuitem", { name: /add deal/i }).click(); + + const dialog = page.getByRole("dialog", { name: /add deals/i }); + await expect(dialog).toBeVisible({ timeout: 5000 }); + await expect(dialog.getByText("TEST DEAL")).toBeVisible({ + timeout: 10000, + }); + await dialog.getByRole("button", { name: /TEST DEAL/ }).click(); + await dialog.getByRole("button", { name: /^Add/ }).click(); + + await expect(dialog).not.toBeVisible(); + await expect(page.getByText("TEST DEAL").first()).toBeVisible(); + }); + }); + test.describe("with mentions", () => { test("can add a contact mention to a message", async ({ page }) => { await page.goto(`/chat/${chatId}`);