diff --git a/apps/agent/src/lead_state.py b/apps/agent/src/lead_state.py index 9370606..72fdb10 100644 --- a/apps/agent/src/lead_state.py +++ b/apps/agent/src/lead_state.py @@ -83,6 +83,13 @@ class _Segment(TypedDict, total=False): leadIds: list[str] +class _Followup(TypedDict, total=False): + id: str + text: str + status: str # "pending" | "done" + leadId: str + + def _replace(_left: Any, right: Any) -> Any: """LangGraph reducer that always takes the most recent value. @@ -107,6 +114,7 @@ class LeadCanvasState(AgentState): selectedLeadId: NotRequired[Annotated[Optional[str], _replace]] header: NotRequired[Annotated[_Header, _replace]] sync: NotRequired[Annotated[_SyncMeta, _replace]] + followups: NotRequired[Annotated[list[_Followup], _replace]] class LeadStateMiddleware(AgentMiddleware[LeadCanvasState, Any]): # type: ignore[type-arg] diff --git a/apps/agent/src/prompts.py b/apps/agent/src/prompts.py index b5a1cd2..350784a 100644 --- a/apps/agent/src/prompts.py +++ b/apps/agent/src/prompts.py @@ -39,6 +39,9 @@ "- selectedLeadId: string | null\n" "- header: { title: string, subtitle: string }\n" "- sync: { databaseId: string, databaseTitle: string, syncedAt: string | null }\n" + "- followups: Followup[] // shared follow-up tasks (A2UI demo)\n" + " - Followup = { id: string, text: string,\n" + " status: 'pending' | 'done', leadId?: string }\n" ) @@ -82,6 +85,18 @@ " draft). DO NOT call post_lead_comment in the SAME turn as\n" " renderEmailDraft — wait for the user's approval to come back through\n" " the chat as a follow-up message, then post.\n" + "- manage_followups({followups: Followup[]}): A2UI shared-state tool.\n" + " Both you and the user write to state.followups; the user can also\n" + " toggle / add / remove items via the rendered list. Pass the FULL\n" + " list every call (each side's edit overwrites the array, so partial\n" + " patches would silently drop user-side adds). Each item must include\n" + " status ('pending' | 'done'); leadId is optional and links the item\n" + " to a specific lead. Use this when the user asks to plan / schedule\n" + " / track follow-ups.\n" + "- renderFollowups({}): mount the live shared follow-ups list inline\n" + " in chat. No args — it reads state.followups directly. Call this\n" + " AFTER manage_followups so the user sees the result, or any time\n" + " the user asks to review the current follow-up list.\n" ) @@ -103,6 +118,15 @@ " arguments + result. This means you can call backend tools (like the\n" " Notion MCP read tools) freely and the UI will reflect the activity\n" " without us writing a per-tool renderer.\n\n" + "TWO LAYOUTS:\n" + "- Chat-only mode (the default landing at `/`): centered chat with no\n" + " canvas behind it. Use renderLeadMiniCard / renderWorkshopDemand /\n" + " renderFollowups to give the user a visual answer inline. The user\n" + " can click 'Open canvas' in the header to expand to app mode.\n" + "- App mode (at `/leads`): kanban board, donut, demand bars, lead\n" + " detail panel + sidebar chat. The user reaches it manually from the\n" + " chat header. Drag-drop on a card moves it to a different status\n" + " column and persists via commitLeadEdit.\n\n" "INTERACTION POLICY:\n" "- The default canvas layout is a kanban grouped by Status (Not started\n" " / In progress / Done) with a workshop-demand chart above it. Drag-drop\n" diff --git a/apps/frontend/src/app/leads/page.tsx b/apps/frontend/src/app/leads/page.tsx index 18722ed..ec394a3 100644 --- a/apps/frontend/src/app/leads/page.tsx +++ b/apps/frontend/src/app/leads/page.tsx @@ -1,33 +1,10 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { z } from "zod"; -import { Toaster, toast } from "sonner"; -import { - CopilotChatConfigurationProvider, - CopilotSidebar, - useAgent, - useConfigureSuggestions, - useCopilotKit, - useDefaultRenderTool, - useFrontendTool, -} from "@copilotkit/react-core/v2"; +import { useEffect, useState } from "react"; +import { CopilotChatConfigurationProvider } from "@copilotkit/react-core/v2"; import { ThreadsDrawer } from "@/components/threads-drawer"; import drawerStyles from "@/components/threads-drawer/threads-drawer.module.css"; - -import type { AgentState, Lead, LeadFilter } from "@/lib/leads/types"; -import { initialState, emptyFilter } from "@/lib/leads/state"; -import { applyFilter } from "@/lib/leads/derive"; -import { applyPatch, revertPatch } from "@/lib/leads/optimistic"; - -import { Header } from "@/components/leads/Header"; -import { PipelineBoard } from "@/components/leads/PipelineBoard"; -import { QuickStats } from "@/components/leads/QuickStats"; -import { StatusDonut } from "@/components/leads/StatusDonut"; -import { WorkshopDemand } from "@/components/leads/WorkshopDemand"; -import { LeadMiniCard } from "@/components/leads/inline/LeadMiniCard"; -import { EmailDraftCard } from "@/components/leads/inline/EmailDraftCard"; -import { ToolFallbackCard } from "@/components/copilot/ToolFallbackCard"; +import { LeadCopilotShell } from "@/components/copilot/LeadCopilotShell"; function ClientOnly({ children }: { children: React.ReactNode }) { const [mounted, setMounted] = useState(false); @@ -36,639 +13,7 @@ function ClientOnly({ children }: { children: React.ReactNode }) { return <>{children}; } -const leadShape = z.object({ - id: z.string(), - url: z.string().optional(), - name: z.string(), - company: z.string().default(""), - email: z.string().default(""), - role: z.string().default(""), - phone: z.string().optional(), - source: z.string().optional(), - technical_level: z.string().default(""), - interested_in: z.array(z.string()).default([]), - tools: z.array(z.string()).default([]), - workshop: z.string().default("Not sure yet"), - status: z.string().default("Not started"), - opt_in: z.boolean().default(false), - message: z.string().default(""), - submitted_at: z.string().default(""), -}); - -// Merge raw agent state into the canonical AgentState shape so consumers can -// rely on every nested field existing (filter, header, sync, etc.). -function mergeAgentState(raw: unknown): AgentState { - const partial = - raw && typeof raw === "object" ? (raw as Partial) : {}; - return { - ...initialState, - ...partial, - filter: { ...initialState.filter, ...(partial.filter ?? {}) }, - header: { ...initialState.header, ...(partial.header ?? {}) }, - sync: { ...initialState.sync, ...(partial.sync ?? {}) }, - leads: partial.leads ?? initialState.leads, - highlightedLeadIds: - partial.highlightedLeadIds ?? initialState.highlightedLeadIds, - }; -} - -// v2 `useFrontendTool({ render })` registers the closure once and never -// updates it, so any render that captures `agent.state` directly is stuck -// with the first-mount value. The fix: keep registered renderers as -// `() => ` factories and have the wrapper subscribe to agent state -// itself via `useAgent()`. `useAgent` re-renders on `OnStateChanged`, giving -// us fresh state each time without closure capture. -function useLiveAgentState() { - const { agent } = useAgent(); - const state = mergeAgentState(agent?.state); - const setState = (updater: (prev: AgentState) => AgentState) => { - agent?.setState(updater(mergeAgentState(agent?.state))); - }; - return { agent, state, setState }; -} - -function LiveWorkshopDemand() { - const { state, setState } = useLiveAgentState(); - return ( -
- - setState((prev) => { - const has = prev.filter.workshops.includes(w); - return { - ...prev, - filter: { - ...prev.filter, - workshops: has - ? prev.filter.workshops.filter((x) => x !== w) - : [...prev.filter.workshops, w], - }, - }; - }) - } - /> -
- ); -} - -function CanvasInner() { - const { agent } = useAgent(); - const { copilotkit } = useCopilotKit(); - - useConfigureSuggestions({ - available: "before-first-message", - suggestions: [ - { - title: "Import from Notion", - message: "Import the leads from Notion.", - }, - { - title: "What's hot?", - message: "What workshops are most in demand right now?", - }, - { - title: "Highlight developers", - message: - "Highlight every lead with technical_level Developer or Advanced / expert.", - }, - { - title: "Profile a lead", - message: "Tell me about Ada Lovelace and show her mini card.", - }, - ], - }); - - // Round-trip a synthetic user message + run the agent. Used to ask the - // agent to persist optimistic edits via its Notion tools. - const injectPrompt = useCallback( - (prompt: string) => { - if (!agent) return; - const id = - typeof crypto !== "undefined" && "randomUUID" in crypto - ? crypto.randomUUID() - : `msg-${Date.now()}`; - agent.addMessage({ id, role: "user", content: prompt }); - void copilotkit.runAgent({ agent }).catch((error: unknown) => { - console.error("injectPrompt: runAgent failed", error); - let hint: string | undefined; - if (error && typeof error === "object") { - const anyErr = error as Record; - if (typeof anyErr.hint === "string") { - hint = anyErr.hint; - } else if (typeof anyErr.message === "string") { - try { - const parsed = JSON.parse(anyErr.message); - if (parsed && typeof parsed.hint === "string") hint = parsed.hint; - } catch { - /* not JSON */ - } - } - } - if (hint) toast.error(hint, { duration: 8000 }); - }); - }, - [agent, copilotkit], - ); - - // Optimistic write tracking — snapshot per leadId for rollback, plus two - // sets of ids for the spinner overlay and the post-write green flash. - const [syncingIds, setSyncingIds] = useState>(new Set()); - const [justSyncedIds, setJustSyncedIds] = useState>(new Set()); - const snapshotsRef = useRef>(new Map()); - const processedToolMsgIds = useRef>(new Set()); - const justSyncedTimers = useRef>>( - new Map(), - ); - - const flashJustSynced = useCallback((id: string) => { - setJustSyncedIds((prev) => { - if (prev.has(id)) return prev; - const next = new Set(prev); - next.add(id); - return next; - }); - const existing = justSyncedTimers.current.get(id); - if (existing) clearTimeout(existing); - const t = setTimeout(() => { - setJustSyncedIds((prev) => { - if (!prev.has(id)) return prev; - const next = new Set(prev); - next.delete(id); - return next; - }); - justSyncedTimers.current.delete(id); - }, 800); - justSyncedTimers.current.set(id, t); - }, []); - - useEffect(() => { - return () => { - for (const t of justSyncedTimers.current.values()) clearTimeout(t); - justSyncedTimers.current.clear(); - }; - }, []); - - const state = mergeAgentState(agent?.state); - - const updateState = useCallback( - (updater: (prev: AgentState) => AgentState) => { - agent?.setState(updater(mergeAgentState(agent?.state))); - }, - [agent], - ); - - // ----- State-mutator frontend tools ------------------------------------ - - useFrontendTool({ - name: "setHeader", - description: - "Set the workspace header (title and subtitle shown above the canvas).", - parameters: z.object({ - title: z.string().optional(), - subtitle: z.string().optional(), - }), - handler: async ({ title, subtitle }) => { - updateState((prev) => ({ - ...prev, - header: { - title: title ?? prev.header.title, - subtitle: subtitle ?? prev.header.subtitle, - }, - })); - return "header updated"; - }, - }); - - useFrontendTool({ - name: "setLeads", - description: - "Replace the entire lead list. Call this once after fetching from Notion.", - parameters: z.object({ leads: z.array(leadShape) }), - handler: async ({ leads }) => { - const list = leads as Lead[]; - updateState((prev) => ({ - ...prev, - leads: list, - highlightedLeadIds: prev.highlightedLeadIds.filter((id) => - list.some((l) => l.id === id), - ), - selectedLeadId: - prev.selectedLeadId && - list.some((l) => l.id === prev.selectedLeadId) - ? prev.selectedLeadId - : null, - })); - return `loaded ${leads.length} leads`; - }, - }); - - useFrontendTool({ - name: "setSyncMeta", - description: - "Record which Notion database is the canvas's source of truth and when we last synced.", - parameters: z.object({ - databaseId: z.string().optional(), - databaseTitle: z.string().optional(), - syncedAt: z.string().optional(), - }), - handler: async ({ databaseId, databaseTitle, syncedAt }) => { - updateState((prev) => ({ - ...prev, - sync: { - databaseId: databaseId ?? prev.sync.databaseId, - databaseTitle: databaseTitle ?? prev.sync.databaseTitle, - syncedAt: syncedAt ?? new Date().toISOString(), - }, - })); - return "sync meta updated"; - }, - }); - - useFrontendTool({ - name: "setFilter", - description: - "Narrow the visible leads. Pass any subset of fields; omitted fields are kept.", - parameters: z.object({ - workshops: z.array(z.string()).optional(), - technical_levels: z.array(z.string()).optional(), - tools: z.array(z.string()).optional(), - opt_in: z.enum(["any", "yes", "no"]).optional(), - search: z.string().optional(), - }), - handler: async (patch) => { - updateState((prev) => ({ - ...prev, - filter: { ...prev.filter, ...(patch as Partial) }, - })); - return "filter updated"; - }, - }); - - useFrontendTool({ - name: "clearFilters", - description: "Reset all filters to show every loaded lead.", - parameters: z.object({}), - handler: async () => { - updateState((prev) => ({ ...prev, filter: emptyFilter })); - return "filters cleared"; - }, - }); - - useFrontendTool({ - name: "highlightLeads", - description: - "Visually highlight specific leads. Pass an empty array to clear highlights.", - parameters: z.object({ leadIds: z.array(z.string()) }), - handler: async ({ leadIds }) => { - updateState((prev) => ({ ...prev, highlightedLeadIds: leadIds })); - return `highlighted ${leadIds.length} leads`; - }, - }); - - useFrontendTool({ - name: "selectLead", - description: "Open the detail panel for one lead. Pass null to deselect.", - parameters: z.object({ leadId: z.string().nullable() }), - handler: async ({ leadId }) => { - updateState((prev) => ({ ...prev, selectedLeadId: leadId })); - return leadId ? `selected ${leadId}` : "selection cleared"; - }, - }); - - // Optimistic write: snapshot → apply patch → ask agent to persist. - // The ToolMessage observer below resolves or reverts. - const commitLeadEdit = useCallback( - (leadId: string, patch: Partial) => { - const snap = mergeAgentState(agent?.state).leads.find( - (l) => l.id === leadId, - ); - if (!snap) return; - snapshotsRef.current.set(leadId, snap); - setSyncingIds((prev) => { - if (prev.has(leadId)) return prev; - const next = new Set(prev); - next.add(leadId); - return next; - }); - updateState((prev) => applyPatch(prev, leadId, patch)); - injectPrompt(`Update lead ${leadId} in Notion: ${JSON.stringify(patch)}`); - }, - [agent, updateState, injectPrompt], - ); - - useFrontendTool({ - name: "commitLeadEdit", - description: - "Commit an edit to a single lead with optimistic UI. Asks the agent to persist via update_notion_lead. The patch is a partial Lead — only include fields that change.", - parameters: z.object({ - leadId: z.string(), - patch: z - .object({ - name: z.string().optional(), - company: z.string().optional(), - email: z.string().optional(), - role: z.string().optional(), - phone: z.string().optional(), - source: z.string().optional(), - technical_level: z.string().optional(), - interested_in: z.array(z.string()).optional(), - tools: z.array(z.string()).optional(), - workshop: z.string().optional(), - status: z.string().optional(), - opt_in: z.boolean().optional(), - message: z.string().optional(), - }) - .passthrough(), - }), - handler: async ({ leadId, patch }) => { - const lead = mergeAgentState(agent?.state).leads.find( - (l) => l.id === leadId, - ); - commitLeadEdit(leadId, patch as Partial); - return `queued: editing ${lead?.name ?? leadId}`; - }, - }); - - // Watch the tail of agent.messages for tool replies that confirm or reject - // pending optimistic writes. Notion writers reply "Updated " / "Added " on - // success, "Update failed" / "Insert failed" on failure. - const messageTail = - ( - agent?.messages as Array<{ - id?: string; - role?: string; - content?: unknown; - }> - )?.slice(-10) ?? []; - // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(() => { - if (!agent || !messageTail.length) return; - for (const m of messageTail) { - const id = m.id; - if (!id || m.role !== "tool") continue; - if (processedToolMsgIds.current.has(id)) continue; - processedToolMsgIds.current.add(id); - - const content = - typeof m.content === "string" - ? m.content - : Array.isArray(m.content) - ? m.content - .map((b) => - typeof b === "string" - ? b - : (b as { text?: string })?.text ?? "", - ) - .join("") - : ""; - if (!content) continue; - - const isFailure = - content.startsWith("Update failed") || - content.startsWith("Insert failed"); - const isSuccess = - content.startsWith("Updated ") || content.startsWith("Added "); - if (!isFailure && !isSuccess) continue; - - const pending = Array.from(snapshotsRef.current.entries()); - if (pending.length === 0) continue; - - if (isSuccess) { - const [leadId] = pending[pending.length - 1]; - snapshotsRef.current.delete(leadId); - setSyncingIds((prev) => { - if (!prev.has(leadId)) return prev; - const next = new Set(prev); - next.delete(leadId); - return next; - }); - flashJustSynced(leadId); - } else { - const reverted: Lead[] = []; - updateState((prev) => { - let next = prev; - for (const [, snap] of pending) { - next = revertPatch(next, snap); - reverted.push(snap); - } - return next; - }); - snapshotsRef.current.clear(); - setSyncingIds(new Set()); - toast.error( - reverted.length === 1 - ? `Couldn't sync ${reverted[0].name} to Notion — change reverted.` - : `Couldn't sync ${reverted.length} leads to Notion — changes reverted.`, - { duration: 5000 }, - ); - } - } - }, [messageTail.map((m) => m.id).join(","), agent, flashJustSynced]); - - // ----- Controlled gen UI: named renderers ------------------------------ - - useFrontendTool({ - name: "renderLeadMiniCard", - description: - "Render an inline lead-mini-card in the chat when mentioning a specific lead by name. Pass leadId plus as much of name/role/company/email/workshop/technical_level as you have.", - parameters: z.object({ - leadId: z.string(), - name: z.string().optional(), - role: z.string().optional(), - company: z.string().optional(), - email: z.string().optional(), - workshop: z.string().optional(), - technical_level: z.string().optional(), - }), - render: ({ args }) => ( - - updateState((prev) => ({ ...prev, selectedLeadId: id })) - } - /> - ), - }); - - useFrontendTool({ - name: "renderWorkshopDemand", - description: - "Render an inline horizontal bar chart of leads-per-workshop. Reads live agent state, takes no args.", - parameters: z.object({}), - render: () => , - }); - - // HITL email draft. Agent supplies leadId + subject + body. The user can - // edit the fields in chat, then click Send — which fires post_lead_comment - // through injectPrompt so the agent persists it as a Notion comment. - useFrontendTool({ - name: "renderEmailDraft", - description: - "Render a human-in-the-loop email draft inline in chat. Use this AFTER finding the lead and BEFORE posting any comment — the user must approve, edit, or discard the draft. On Send, the canvas will round-trip a post_lead_comment call back to the agent. Do NOT call post_lead_comment in the same turn — wait for the user.", - parameters: z.object({ - leadId: z.string(), - leadName: z.string().optional(), - leadEmail: z.string().optional(), - subject: z.string(), - body: z.string(), - }), - render: ({ args }) => { - if (!args.leadId || !args.subject || !args.body) { - return ( -
- - Drafting email… -
- ); - } - const leadId = args.leadId; - return ( - - injectPrompt( - `The user approved the email draft for lead ${leadId}. Post it as a Notion comment by calling post_lead_comment with leadId=${JSON.stringify(leadId)}, subject=${JSON.stringify(final.subject)}, body=${JSON.stringify(final.body)}. Do not modify the wording.`, - ) - } - onRegenerate={() => - injectPrompt( - `Regenerate the outreach email draft for lead ${leadId} and call renderEmailDraft again with the new version.`, - ) - } - /> - ); - }, - }); - - // Catch-all: any tool call without a dedicated render lands here. Notion - // MCP tools (notion_query_database, etc.) and ad-hoc backend tools surface - // as a small CopilotKit-branded card so the user can see what's happening. - useDefaultRenderTool({ - render: ({ name, status, result, parameters }) => ( - - ), - }); - - // ----- Render ---------------------------------------------------------- - - const visibleLeads = useMemo( - () => applyFilter(state.leads, state.filter), - [state.leads, state.filter], - ); - - const handleSelect = (id: string) => - updateState((prev) => ({ - ...prev, - selectedLeadId: prev.selectedLeadId === id ? null : id, - })); - - // Drag-drop on the pipeline board moves a lead between status columns, - // routed through commitLeadEdit so it persists to Notion. - const handleMoveLead = ( - leadId: string, - _fromStatus: string, - toStatus: string, - ) => commitLeadEdit(leadId, { status: toStatus }); - - const handlePickWorkshop = (w: string) => - updateState((prev) => { - const has = prev.filter.workshops.includes(w); - return { - ...prev, - filter: { - ...prev.filter, - workshops: has - ? prev.filter.workshops.filter((x) => x !== w) - : [...prev.filter.workshops, w], - }, - }; - }); - - return ( - <> -
-
- - {state.leads.length === 0 ? ( -
-

- Ask the assistant to{" "} - - pull workshop signups from Notion - {" "} - to populate the canvas. -

-
- ) : ( - <> - -
- - -
-
- -
- - )} -
- - null, className: "pb-6" }} - /> - - - - ); -} - -function HomePage() { +function CanvasHome() { const [threadId, setThreadId] = useState(undefined); return (
@@ -679,7 +24,7 @@ function HomePage() { />
- +
@@ -689,7 +34,7 @@ function HomePage() { export default function Page() { return ( - + ); } diff --git a/apps/frontend/src/app/page.tsx b/apps/frontend/src/app/page.tsx index dd3713e..fe3281b 100644 --- a/apps/frontend/src/app/page.tsx +++ b/apps/frontend/src/app/page.tsx @@ -1,5 +1,40 @@ -import { redirect } from "next/navigation"; +"use client"; -export default function HomePage() { - redirect("/leads"); +import { useEffect, useState } from "react"; +import { CopilotChatConfigurationProvider } from "@copilotkit/react-core/v2"; +import { ThreadsDrawer } from "@/components/threads-drawer"; +import drawerStyles from "@/components/threads-drawer/threads-drawer.module.css"; +import { LeadCopilotShell } from "@/components/copilot/LeadCopilotShell"; + +function ClientOnly({ children }: { children: React.ReactNode }) { + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + if (!mounted) return null; + return <>{children}; +} + +function ChatOnlyHome() { + const [threadId, setThreadId] = useState(undefined); + return ( +
+ +
+ + + +
+
+ ); +} + +export default function Page() { + return ( + + + + ); } diff --git a/apps/frontend/src/components/copilot/LeadCopilotShell.tsx b/apps/frontend/src/components/copilot/LeadCopilotShell.tsx new file mode 100644 index 0000000..e8db0b8 --- /dev/null +++ b/apps/frontend/src/components/copilot/LeadCopilotShell.tsx @@ -0,0 +1,892 @@ +"use client"; + +/** + * LeadCopilotShell — single source of truth for the lead-form Copilot + * surface. Both routes (`/` for chat-only, `/leads` for app mode) render + * this component; the `mode` prop swaps the visible chrome only. Frontend + * tools, suggestions, and the open-gen-UI fallback are registered here + * exactly once per page so the agent has the same surface in both modes. + * + * The component lives inside CopilotChatConfigurationProvider but the + * threads drawer is rendered in the route file (it owns the threadId). + * + * Durability: every time `agent.state.leads` changes to a non-empty list, + * we persist a snapshot to localStorage. On mount of any thread whose + * `agent.state.leads` is empty, we hydrate from the cache so users don't + * have to re-import after starting a new chat. Notion stays the source of + * truth for explicit refreshes — this is just a no-flash startup cache. + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import Link from "next/link"; +import { z } from "zod"; +import { Toaster, toast } from "sonner"; +import { + CopilotChat, + CopilotSidebar, + useAgent, + useConfigureSuggestions, + useCopilotKit, + useDefaultRenderTool, + useFrontendTool, +} from "@copilotkit/react-core/v2"; +import { LayoutDashboard, MessageCircle } from "lucide-react"; + +import type { + AgentState, + Followup, + Lead, + LeadFilter, +} from "@/lib/leads/types"; +import { initialState, emptyFilter } from "@/lib/leads/state"; +import { applyFilter } from "@/lib/leads/derive"; +import { applyPatch, revertPatch } from "@/lib/leads/optimistic"; + +import { Header } from "@/components/leads/Header"; +import { PipelineBoard } from "@/components/leads/PipelineBoard"; +import { QuickStats } from "@/components/leads/QuickStats"; +import { StatusDonut } from "@/components/leads/StatusDonut"; +import { WorkshopDemand } from "@/components/leads/WorkshopDemand"; +import { LeadMiniCard } from "@/components/leads/inline/LeadMiniCard"; +import { EmailDraftCard } from "@/components/leads/inline/EmailDraftCard"; +import { FollowupList } from "@/components/leads/inline/FollowupList"; +import { ToolFallbackCard } from "@/components/copilot/ToolFallbackCard"; + +const LEADS_CACHE_KEY = "lead-form:cached-leads/v1"; + +const leadShape = z.object({ + id: z.string(), + url: z.string().optional(), + name: z.string(), + company: z.string().default(""), + email: z.string().default(""), + role: z.string().default(""), + phone: z.string().optional(), + source: z.string().optional(), + technical_level: z.string().default(""), + interested_in: z.array(z.string()).default([]), + tools: z.array(z.string()).default([]), + workshop: z.string().default("Not sure yet"), + status: z.string().default("Not started"), + opt_in: z.boolean().default(false), + message: z.string().default(""), + submitted_at: z.string().default(""), +}); + +const followupShape = z.object({ + id: z.string(), + text: z.string(), + status: z.enum(["pending", "done"]), + leadId: z.string().optional(), +}); + +function mergeAgentState(raw: unknown): AgentState { + const partial = + raw && typeof raw === "object" ? (raw as Partial) : {}; + return { + ...initialState, + ...partial, + filter: { ...initialState.filter, ...(partial.filter ?? {}) }, + header: { ...initialState.header, ...(partial.header ?? {}) }, + sync: { ...initialState.sync, ...(partial.sync ?? {}) }, + leads: partial.leads ?? initialState.leads, + followups: partial.followups ?? initialState.followups, + highlightedLeadIds: + partial.highlightedLeadIds ?? initialState.highlightedLeadIds, + }; +} + +function useLiveAgentState() { + const { agent } = useAgent(); + const state = mergeAgentState(agent?.state); + const setState = (updater: (prev: AgentState) => AgentState) => { + agent?.setState(updater(mergeAgentState(agent?.state))); + }; + return { agent, state, setState }; +} + +function LiveWorkshopDemand() { + const { state, setState } = useLiveAgentState(); + return ( +
+ + setState((prev) => { + const has = prev.filter.workshops.includes(w); + return { + ...prev, + filter: { + ...prev.filter, + workshops: has + ? prev.filter.workshops.filter((x) => x !== w) + : [...prev.filter.workshops, w], + }, + }; + }) + } + /> +
+ ); +} + +function LiveFollowupList() { + const { state, setState } = useLiveAgentState(); + const setFollowups = (next: Followup[]) => + setState((prev) => ({ ...prev, followups: next })); + return ( + + setFollowups( + state.followups.map((f) => + f.id === id + ? { ...f, status: f.status === "done" ? "pending" : "done" } + : f, + ), + ) + } + onAdd={(text, leadId) => + setFollowups([ + ...state.followups, + { + id: + typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `f-${Date.now()}`, + text, + status: "pending", + leadId, + }, + ]) + } + onRemove={(id) => + setFollowups(state.followups.filter((f) => f.id !== id)) + } + /> + ); +} + +export interface LeadCopilotShellProps { + mode: "chat" | "app"; +} + +export function LeadCopilotShell({ mode }: LeadCopilotShellProps) { + const { agent } = useAgent(); + const { copilotkit } = useCopilotKit(); + + // ----- localStorage durability ---------------------------------------- + // Hydrate empty state from cache on first mount; persist whenever leads + // change. New threads inherit the cached snapshot so the user doesn't + // have to re-import after starting fresh chats. + const hydratedRef = useRef(false); + useEffect(() => { + if (hydratedRef.current) return; + if (!agent) return; + const current = mergeAgentState(agent.state); + // Only attempt to read cache when state is empty; either way, we + // mark hydration complete so the auto-navigation effect below can + // distinguish "load-time replay" from "fresh import". + if (current.leads.length === 0) { + try { + const raw = window.localStorage.getItem(LEADS_CACHE_KEY); + if (raw) { + const parsed = JSON.parse(raw); + if (parsed?.leads && Array.isArray(parsed.leads)) { + agent.setState({ + ...current, + leads: parsed.leads, + sync: parsed.sync ?? current.sync, + header: parsed.header ?? current.header, + }); + } + } + } catch { + // localStorage unavailable / parse error — fall through silently + } + } + hydratedRef.current = true; + }, [agent]); + + useEffect(() => { + if (!agent) return; + const current = mergeAgentState(agent.state); + if (current.leads.length === 0) return; + try { + window.localStorage.setItem( + LEADS_CACHE_KEY, + JSON.stringify({ + leads: current.leads, + sync: current.sync, + header: current.header, + }), + ); + } catch { + // quota / disabled — silently skip persistence + } + }, [agent, agent?.state]); + + // (Auto-promote-to-canvas was removed: LeadStateMiddleware hydrates the + // agent's state from the local lead store on every fresh thread's first + // turn, so any unrelated prompt would fire the navigation. The chat + // header's "Open canvas (N leads)" link is the explicit signal instead.) + + // ----- Suggestion chips ----------------------------------------------- + + useConfigureSuggestions({ + available: "before-first-message", + suggestions: + mode === "chat" + ? [ + { + title: "Import from Notion", + message: "Import the leads from Notion.", + }, + { + title: "What's hot?", + message: "What workshops are most in demand right now?", + }, + { + title: "Plan follow-ups", + message: + "Add follow-up tasks for the top 3 hottest leads and show the list.", + }, + { + title: "Profile a lead", + message: "Tell me about Ada Lovelace and show her mini card.", + }, + ] + : [ + { + title: "What's hot?", + message: "What workshops are most in demand right now?", + }, + { + title: "Highlight developers", + message: + "Highlight every lead with technical_level Developer or Advanced / expert.", + }, + { + title: "Plan follow-ups", + message: + "Add follow-up tasks for the top 3 hottest leads and show the list.", + }, + { + title: "Profile a lead", + message: "Tell me about Ada Lovelace and show her mini card.", + }, + ], + }); + + // ----- injectPrompt helper -------------------------------------------- + + const injectPrompt = useCallback( + (prompt: string) => { + if (!agent) return; + const id = + typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `msg-${Date.now()}`; + agent.addMessage({ id, role: "user", content: prompt }); + void copilotkit.runAgent({ agent }).catch((error: unknown) => { + console.error("injectPrompt: runAgent failed", error); + let hint: string | undefined; + if (error && typeof error === "object") { + const anyErr = error as Record; + if (typeof anyErr.hint === "string") { + hint = anyErr.hint; + } else if (typeof anyErr.message === "string") { + try { + const parsed = JSON.parse(anyErr.message); + if (parsed && typeof parsed.hint === "string") hint = parsed.hint; + } catch { + /* not JSON */ + } + } + } + if (hint) toast.error(hint, { duration: 8000 }); + }); + }, + [agent, copilotkit], + ); + + // ----- Optimistic-write tracking (app-mode only) ---------------------- + + const [syncingIds, setSyncingIds] = useState>(new Set()); + const [justSyncedIds, setJustSyncedIds] = useState>(new Set()); + const snapshotsRef = useRef>(new Map()); + const processedToolMsgIds = useRef>(new Set()); + const justSyncedTimers = useRef>>( + new Map(), + ); + + const flashJustSynced = useCallback((id: string) => { + setJustSyncedIds((prev) => { + if (prev.has(id)) return prev; + const next = new Set(prev); + next.add(id); + return next; + }); + const existing = justSyncedTimers.current.get(id); + if (existing) clearTimeout(existing); + const t = setTimeout(() => { + setJustSyncedIds((prev) => { + if (!prev.has(id)) return prev; + const next = new Set(prev); + next.delete(id); + return next; + }); + justSyncedTimers.current.delete(id); + }, 800); + justSyncedTimers.current.set(id, t); + }, []); + + useEffect(() => { + return () => { + for (const t of justSyncedTimers.current.values()) clearTimeout(t); + justSyncedTimers.current.clear(); + }; + }, []); + + const state = mergeAgentState(agent?.state); + + const updateState = useCallback( + (updater: (prev: AgentState) => AgentState) => { + agent?.setState(updater(mergeAgentState(agent?.state))); + }, + [agent], + ); + + // ----- State-mutator frontend tools ----------------------------------- + + useFrontendTool({ + name: "setHeader", + description: + "Set the workspace header (title and subtitle shown above the canvas).", + parameters: z.object({ + title: z.string().optional(), + subtitle: z.string().optional(), + }), + handler: async ({ title, subtitle }) => { + updateState((prev) => ({ + ...prev, + header: { + title: title ?? prev.header.title, + subtitle: subtitle ?? prev.header.subtitle, + }, + })); + return "header updated"; + }, + }); + + useFrontendTool({ + name: "setLeads", + description: + "Replace the entire lead list. Call this once after fetching from Notion.", + parameters: z.object({ leads: z.array(leadShape) }), + handler: async ({ leads }) => { + const list = leads as Lead[]; + updateState((prev) => ({ + ...prev, + leads: list, + highlightedLeadIds: prev.highlightedLeadIds.filter((id) => + list.some((l) => l.id === id), + ), + selectedLeadId: + prev.selectedLeadId && + list.some((l) => l.id === prev.selectedLeadId) + ? prev.selectedLeadId + : null, + })); + return `loaded ${leads.length} leads`; + }, + }); + + useFrontendTool({ + name: "setSyncMeta", + description: + "Record which Notion database is the canvas's source of truth and when we last synced.", + parameters: z.object({ + databaseId: z.string().optional(), + databaseTitle: z.string().optional(), + syncedAt: z.string().optional(), + }), + handler: async ({ databaseId, databaseTitle, syncedAt }) => { + updateState((prev) => ({ + ...prev, + sync: { + databaseId: databaseId ?? prev.sync.databaseId, + databaseTitle: databaseTitle ?? prev.sync.databaseTitle, + syncedAt: syncedAt ?? new Date().toISOString(), + }, + })); + return "sync meta updated"; + }, + }); + + useFrontendTool({ + name: "setFilter", + description: + "Narrow the visible leads. Pass any subset of fields; omitted fields are kept.", + parameters: z.object({ + workshops: z.array(z.string()).optional(), + technical_levels: z.array(z.string()).optional(), + tools: z.array(z.string()).optional(), + opt_in: z.enum(["any", "yes", "no"]).optional(), + search: z.string().optional(), + }), + handler: async (patch) => { + updateState((prev) => ({ + ...prev, + filter: { ...prev.filter, ...(patch as Partial) }, + })); + return "filter updated"; + }, + }); + + useFrontendTool({ + name: "clearFilters", + description: "Reset all filters to show every loaded lead.", + parameters: z.object({}), + handler: async () => { + updateState((prev) => ({ ...prev, filter: emptyFilter })); + return "filters cleared"; + }, + }); + + useFrontendTool({ + name: "highlightLeads", + description: + "Visually highlight specific leads. Pass an empty array to clear highlights.", + parameters: z.object({ leadIds: z.array(z.string()) }), + handler: async ({ leadIds }) => { + updateState((prev) => ({ ...prev, highlightedLeadIds: leadIds })); + return `highlighted ${leadIds.length} leads`; + }, + }); + + useFrontendTool({ + name: "selectLead", + description: "Open the detail panel for one lead. Pass null to deselect.", + parameters: z.object({ leadId: z.string().nullable() }), + handler: async ({ leadId }) => { + updateState((prev) => ({ ...prev, selectedLeadId: leadId })); + return leadId ? `selected ${leadId}` : "selection cleared"; + }, + }); + + // ----- A2UI shared-state demo: manage_followups ----------------------- + // This is the threads-demo "manage_todos" pattern, adapted: state.followups + // is read+write for both the agent (via this tool) and the user (via the + // FollowupList component). Each side overwrites the full list on each + // edit, so they can never disagree about what items exist. + useFrontendTool({ + name: "manage_followups", + description: + "Manage the shared follow-ups list. Pass the FULL list each call (the agent and the user both edit this list, so partial patches would lose user-side adds). Each item: { id, text, status: 'pending'|'done', leadId? }. Always include status. Call renderFollowups after this so the user can see the result.", + parameters: z.object({ followups: z.array(followupShape) }), + handler: async ({ followups }) => { + updateState((prev) => ({ ...prev, followups: followups as Followup[] })); + return `set ${followups.length} follow-ups`; + }, + }); + + useFrontendTool({ + name: "renderFollowups", + description: + "Render the shared follow-ups list inline in chat. Reads live agent state, takes no args. Call this after manage_followups, or any time the user asks to see / review the follow-up list.", + parameters: z.object({}), + render: () => , + }); + + // ----- Optimistic write: commitLeadEdit (app-mode only) --------------- + + const commitLeadEdit = useCallback( + (leadId: string, patch: Partial) => { + const snap = mergeAgentState(agent?.state).leads.find( + (l) => l.id === leadId, + ); + if (!snap) return; + snapshotsRef.current.set(leadId, snap); + setSyncingIds((prev) => { + if (prev.has(leadId)) return prev; + const next = new Set(prev); + next.add(leadId); + return next; + }); + updateState((prev) => applyPatch(prev, leadId, patch)); + injectPrompt(`Update lead ${leadId} in Notion: ${JSON.stringify(patch)}`); + }, + [agent, updateState, injectPrompt], + ); + + useFrontendTool({ + name: "commitLeadEdit", + description: + "Commit an edit to a single lead with optimistic UI. Asks the agent to persist via update_notion_lead. The patch is a partial Lead — only include fields that change.", + parameters: z.object({ + leadId: z.string(), + patch: z + .object({ + name: z.string().optional(), + company: z.string().optional(), + email: z.string().optional(), + role: z.string().optional(), + phone: z.string().optional(), + source: z.string().optional(), + technical_level: z.string().optional(), + interested_in: z.array(z.string()).optional(), + tools: z.array(z.string()).optional(), + workshop: z.string().optional(), + status: z.string().optional(), + opt_in: z.boolean().optional(), + message: z.string().optional(), + }) + .passthrough(), + }), + handler: async ({ leadId, patch }) => { + const lead = mergeAgentState(agent?.state).leads.find( + (l) => l.id === leadId, + ); + commitLeadEdit(leadId, patch as Partial); + return `queued: editing ${lead?.name ?? leadId}`; + }, + }); + + // Watch tool-message tail for write confirmations / failures. + const messageTail = + ( + agent?.messages as Array<{ + id?: string; + role?: string; + content?: unknown; + }> + )?.slice(-10) ?? []; + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + if (!agent || !messageTail.length) return; + for (const m of messageTail) { + const id = m.id; + if (!id || m.role !== "tool") continue; + if (processedToolMsgIds.current.has(id)) continue; + processedToolMsgIds.current.add(id); + + const content = + typeof m.content === "string" + ? m.content + : Array.isArray(m.content) + ? m.content + .map((b) => + typeof b === "string" + ? b + : (b as { text?: string })?.text ?? "", + ) + .join("") + : ""; + if (!content) continue; + + const isFailure = + content.startsWith("Update failed") || + content.startsWith("Insert failed"); + const isSuccess = + content.startsWith("Updated ") || content.startsWith("Added "); + if (!isFailure && !isSuccess) continue; + + const pending = Array.from(snapshotsRef.current.entries()); + if (pending.length === 0) continue; + + if (isSuccess) { + const [leadId] = pending[pending.length - 1]; + snapshotsRef.current.delete(leadId); + setSyncingIds((prev) => { + if (!prev.has(leadId)) return prev; + const next = new Set(prev); + next.delete(leadId); + return next; + }); + flashJustSynced(leadId); + } else { + const reverted: Lead[] = []; + updateState((prev) => { + let next = prev; + for (const [, snap] of pending) { + next = revertPatch(next, snap); + reverted.push(snap); + } + return next; + }); + snapshotsRef.current.clear(); + setSyncingIds(new Set()); + toast.error( + reverted.length === 1 + ? `Couldn't sync ${reverted[0].name} to Notion — change reverted.` + : `Couldn't sync ${reverted.length} leads to Notion — changes reverted.`, + { duration: 5000 }, + ); + } + } + }, [messageTail.map((m) => m.id).join(","), agent, flashJustSynced]); + + // ----- Controlled gen UI: named renderers ----------------------------- + + useFrontendTool({ + name: "renderLeadMiniCard", + description: + "Render an inline lead-mini-card in the chat when mentioning a specific lead by name. Pass leadId plus as much of name/role/company/email/workshop/technical_level as you have.", + parameters: z.object({ + leadId: z.string(), + name: z.string().optional(), + role: z.string().optional(), + company: z.string().optional(), + email: z.string().optional(), + workshop: z.string().optional(), + technical_level: z.string().optional(), + }), + render: ({ args }) => ( + + updateState((prev) => ({ ...prev, selectedLeadId: id })) + } + /> + ), + }); + + useFrontendTool({ + name: "renderWorkshopDemand", + description: + "Render an inline horizontal bar chart of leads-per-workshop. Reads live agent state, takes no args.", + parameters: z.object({}), + render: () => , + }); + + useFrontendTool({ + name: "renderEmailDraft", + description: + "Render a human-in-the-loop email draft inline in chat. Use this AFTER finding the lead and BEFORE posting any comment — the user must approve, edit, or discard the draft. On Send, the canvas will round-trip a post_lead_comment call back to the agent. Do NOT call post_lead_comment in the same turn — wait for the user.", + parameters: z.object({ + leadId: z.string(), + leadName: z.string().optional(), + leadEmail: z.string().optional(), + subject: z.string(), + body: z.string(), + }), + render: ({ args }) => { + if (!args.leadId || !args.subject || !args.body) { + return ( +
+ + Drafting email… +
+ ); + } + const leadId = args.leadId; + return ( + + injectPrompt( + `The user approved the email draft for lead ${leadId}. Post it as a Notion comment by calling post_lead_comment with leadId=${JSON.stringify(leadId)}, subject=${JSON.stringify(final.subject)}, body=${JSON.stringify(final.body)}. Do not modify the wording.`, + ) + } + onRegenerate={() => + injectPrompt( + `Regenerate the outreach email draft for lead ${leadId} and call renderEmailDraft again with the new version.`, + ) + } + /> + ); + }, + }); + + // Open generative UI catch-all. The ignore list matches the + // threads-demo: A2UI internal tools are rendered by the A2UI subsystem + // (the `openGenerativeUI={{}}` provider config), so the wildcard must + // skip them — otherwise the same tool call mounts twice and React + // raises duplicate-key warnings on the chat message list. + useDefaultRenderTool({ + render: ({ name, status, result, parameters }) => { + if ( + name === "render_a2ui" || + name === "generate_a2ui" || + name === "log_a2ui_event" + ) { + return <>; + } + return ( + + ); + }, + }); + + // ----- Render --------------------------------------------------------- + + const visibleLeads = useMemo( + () => applyFilter(state.leads, state.filter), + [state.leads, state.filter], + ); + + const handleSelect = (id: string) => + updateState((prev) => ({ + ...prev, + selectedLeadId: prev.selectedLeadId === id ? null : id, + })); + + const handleMoveLead = ( + leadId: string, + _fromStatus: string, + toStatus: string, + ) => commitLeadEdit(leadId, { status: toStatus }); + + const handlePickWorkshop = (w: string) => + updateState((prev) => { + const has = prev.filter.workshops.includes(w); + return { + ...prev, + filter: { + ...prev.filter, + workshops: has + ? prev.filter.workshops.filter((x) => x !== w) + : [...prev.filter.workshops, w], + }, + }; + }); + + if (mode === "chat") { + return ( + 0} /> + ); + } + + return ( + <> +
+
+ + {state.leads.length === 0 ? ( +
+

+ Ask the assistant to{" "} + + pull workshop signups from Notion + {" "} + to populate the canvas. +

+
+ ) : ( + <> + +
+ + +
+
+ +
+ + )} +
+ + null, className: "pb-6" }} + /> + + + + ); +} + +function ChatOnlyView({ + leadCount, + threadHasRun, +}: { + leadCount: number; + threadHasRun: boolean; +}) { + return ( +
+
+
+ + + + + Lead-form chat + +
+ + + + {leadCount > 0 ? `Open canvas (${leadCount} leads)` : "Open canvas"} + + +
+ +
+ null }} + welcomeScreen={!threadHasRun && leadCount === 0 ? true : false} + /> +
+ + +
+ ); +} diff --git a/apps/frontend/src/components/copilot/ToolFallbackCard.tsx b/apps/frontend/src/components/copilot/ToolFallbackCard.tsx index c368807..46bafd7 100644 --- a/apps/frontend/src/components/copilot/ToolFallbackCard.tsx +++ b/apps/frontend/src/components/copilot/ToolFallbackCard.tsx @@ -1,6 +1,7 @@ "use client"; -import { useMemo, useState } from "react"; +import { Check, Loader2, Wrench } from "lucide-react"; +import { useMemo } from "react"; export interface ToolFallbackCardProps { name: string; @@ -9,16 +10,28 @@ export interface ToolFallbackCardProps { parameters?: unknown; } +/** + * Open Generative UI catch-all renderer (the threads-demo "ToolReasoning" + * pattern, ported). Any tool the agent invokes that doesn't have a + * dedicated render slot lands here — backend Notion calls, health checks, + * planner dispatches, anything new the user adds. + * + * Native
collapsible so it's keyboard accessible and degrades + * cleanly. The card renders running / complete states with distinct + * iconography (spinning wrench → green check) so the user can see at a + * glance what the agent is doing. + */ export function ToolFallbackCard({ name, status, result, parameters, }: ToolFallbackCardProps) { - const [open, setOpen] = useState(false); - const dotColor = status === "complete" ? "#BEC2FF" : "#F4D35E"; + const isRunning = status === "executing" || status === "inProgress"; + const isComplete = status === "complete"; + const payload = useMemo(() => { - const value = status === "complete" ? result ?? parameters : parameters; + const value = isComplete ? result ?? parameters : parameters; if (value === undefined || value === null) return ""; if (typeof value === "string") { try { @@ -32,35 +45,45 @@ export function ToolFallbackCard({ } catch { return String(value); } - }, [parameters, result, status]); + }, [isComplete, parameters, result]); return ( -
-
+
+ - {name} + > + {isComplete ? ( + + ) : isRunning ? ( + + ) : ( + + )} + + + {name} + - {status} + {isComplete ? "done" : isRunning ? "running" : status} -
+ {payload ? ( - - ) : null} - {open && payload ? ( -
-          {payload}
-        
+
+
+            {payload}
+          
+
) : null} -
+
); } diff --git a/apps/frontend/src/components/leads/Header.tsx b/apps/frontend/src/components/leads/Header.tsx index c74cdde..dc38315 100644 --- a/apps/frontend/src/components/leads/Header.tsx +++ b/apps/frontend/src/components/leads/Header.tsx @@ -1,6 +1,7 @@ "use client"; -import { RefreshCw } from "lucide-react"; +import Link from "next/link"; +import { MessageCircle, RefreshCw } from "lucide-react"; import type { SyncMeta } from "@/lib/leads/types"; interface HeaderProps { @@ -28,6 +29,14 @@ export function Header({

{subtitle}

+ + + Chat only + {visibleLeads} {visibleLeads !== totalLeads ? ( diff --git a/apps/frontend/src/components/leads/inline/FollowupList.tsx b/apps/frontend/src/components/leads/inline/FollowupList.tsx new file mode 100644 index 0000000..8419002 --- /dev/null +++ b/apps/frontend/src/components/leads/inline/FollowupList.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { Check, Plus, X } from "lucide-react"; +import { useState } from "react"; +import type { Followup, Lead } from "@/lib/leads/types"; + +export interface FollowupListProps { + followups: Followup[]; + leads: Lead[]; + onToggle: (id: string) => void; + onAdd: (text: string, leadId?: string) => void; + onRemove: (id: string) => void; +} + +/** + * The threads-demo "shared list" (A2UI) demo, adapted to the lead-form domain. + * Both the agent and the user write to `state.followups`; this component + * renders the live list and lets the user check off / remove / add items. + * + * Render slot is intentionally minimal so it works in chat-only mode AND + * inline in the canvas. No card chrome, no lead-detail integration. + */ +export function FollowupList({ + followups, + leads, + onToggle, + onAdd, + onRemove, +}: FollowupListProps) { + const [draft, setDraft] = useState(""); + const leadName = (id?: string) => + id ? leads.find((l) => l.id === id)?.name ?? "" : ""; + + return ( +
+
+ + Follow-ups + + + {followups.filter((f) => f.status === "done").length}/ + {followups.length} done + +
+ + {followups.length === 0 ? ( +

+ No follow-ups yet. Ask the agent to draft some, or add one below. +

+ ) : ( +
    + {followups.map((f) => { + const done = f.status === "done"; + return ( +
  • + +
    +

    + {f.text} +

    + {f.leadId && leadName(f.leadId) ? ( +

    + → {leadName(f.leadId)} +

    + ) : null} +
    + +
  • + ); + })} +
+ )} + +
{ + e.preventDefault(); + const t = draft.trim(); + if (!t) return; + onAdd(t); + setDraft(""); + }} + > + setDraft(e.target.value)} + /> + +
+
+ ); +} diff --git a/apps/frontend/src/lib/leads/state.ts b/apps/frontend/src/lib/leads/state.ts index 14808d6..adbf83d 100644 --- a/apps/frontend/src/lib/leads/state.ts +++ b/apps/frontend/src/lib/leads/state.ts @@ -18,6 +18,7 @@ export const initialState: AgentState = { subtitle: "Live from Notion", }, sync: { databaseId: "", databaseTitle: "", syncedAt: null }, + followups: [], }; export function isFilterEmpty(f: LeadFilter): boolean { diff --git a/apps/frontend/src/lib/leads/types.ts b/apps/frontend/src/lib/leads/types.ts index 5030d09..48dfdb2 100644 --- a/apps/frontend/src/lib/leads/types.ts +++ b/apps/frontend/src/lib/leads/types.ts @@ -77,6 +77,17 @@ export interface SyncMeta { syncedAt: string | null; } +// Follow-up tasks the user/agent can both edit. This is the canonical +// shared-state (A2UI) demo from the threads-demo starter, ported to the +// lead-form domain — instead of generic todos, items are scoped to a lead +// (or unscoped for general next-actions). +export interface Followup { + id: string; + text: string; + status: "pending" | "done"; + leadId?: string; +} + export interface AgentState { leads: Lead[]; filter: LeadFilter; @@ -84,6 +95,7 @@ export interface AgentState { selectedLeadId: string | null; header: { title: string; subtitle: string }; sync: SyncMeta; + followups: Followup[]; } // Mirrors the Python `NotionHealth` TypedDict in