diff --git a/apps/web/src/app/inbox/page.tsx b/apps/web/src/app/inbox/page.tsx
index 3df42de..41df5ed 100644
--- a/apps/web/src/app/inbox/page.tsx
+++ b/apps/web/src/app/inbox/page.tsx
@@ -62,6 +62,8 @@ type ConversationUiPatch = {
optimisticLastMessage?: string;
};
+const HANDOFF_REASON_FALLBACK = "Reason not provided by handoff trigger";
+
function sortInboxConversations(
left: { _id: string; createdAt: number; lastMessageAt?: number },
right: { _id: string; createdAt: number; lastMessageAt?: number }
@@ -86,6 +88,13 @@ function getConversationIdentityLabel(conversation: {
});
}
+function getHandoffReasonLabel(reason: string | null | undefined): string {
+ const normalizedReason = reason?.trim();
+ return normalizedReason && normalizedReason.length > 0
+ ? normalizedReason
+ : HANDOFF_REASON_FALLBACK;
+}
+
function InboxContent(): React.JSX.Element | null {
const router = useRouter();
const searchParams = useSearchParams();
@@ -1029,6 +1038,18 @@ function InboxContent(): React.JSX.Element | null {
)}
+ {selectedConversation?.aiWorkflow?.state === "handoff" && (
+
+
+ AI handoff
+
+ {getHandoffReasonLabel(selectedConversation.aiWorkflow.handoffReason)}
+
+
+ )}
{isCompactViewport && (
) : !orderedAiResponses || orderedAiResponses.length === 0 ? (
-
- No AI responses in this conversation yet.
-
+ selectedConversation?.aiWorkflow?.state === "handoff" ? (
+
+
+ Conversation was handed off before an AI response record was stored.
+
+
+ Handoff reason:{" "}
+ {getHandoffReasonLabel(selectedConversation.aiWorkflow.handoffReason)}
+
+
+ ) : (
+
+ No AI responses in this conversation yet.
+
+ )
) : (
orderedAiResponses.map(
(response: NonNullable
[number]) => {
@@ -1627,9 +1663,10 @@ function InboxContent(): React.JSX.Element | null {
{response.handedOff && (
Handoff reason:{" "}
- {response.handoffReason ??
- selectedConversation?.aiWorkflow?.handoffReason ??
- "Not specified"}
+ {getHandoffReasonLabel(
+ response.handoffReason ??
+ selectedConversation?.aiWorkflow?.handoffReason
+ )}
)}
diff --git a/apps/widget/src/components/ConversationView.test.tsx b/apps/widget/src/components/ConversationView.test.tsx
index 625a9d7..e33bf88 100644
--- a/apps/widget/src/components/ConversationView.test.tsx
+++ b/apps/widget/src/components/ConversationView.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from "@testing-library/react";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useAction, useMutation, useQuery } from "convex/react";
import { ConversationView } from "./ConversationView";
@@ -13,6 +13,7 @@ vi.mock("@opencom/convex", () => ({
api: {
messages: {
list: "messages.list",
+ send: "messages.send",
},
conversations: {
get: "conversations.get",
@@ -64,6 +65,12 @@ describe("ConversationView personas", () => {
let messagesResult: MessageFixture[];
let aiResponsesResult: AiResponseFixture[];
let conversationResult: { aiWorkflowState?: "none" | "ai_handled" | "handoff" } | null;
+ let sendMessageMutationMock: ReturnType;
+ let identifyVisitorMutationMock: ReturnType;
+ let submitAiFeedbackMutationMock: ReturnType;
+ let handoffToHumanMutationMock: ReturnType;
+ let generateAiResponseActionMock: ReturnType;
+ let searchSuggestionsActionMock: ReturnType;
beforeEach(() => {
vi.clearAllMocks();
@@ -73,11 +80,40 @@ describe("ConversationView personas", () => {
aiResponsesResult = [];
conversationResult = { aiWorkflowState: "none" };
+ sendMessageMutationMock = vi.fn().mockResolvedValue(undefined);
+ identifyVisitorMutationMock = vi.fn().mockResolvedValue(undefined);
+ submitAiFeedbackMutationMock = vi.fn().mockResolvedValue(undefined);
+ handoffToHumanMutationMock = vi.fn().mockResolvedValue(undefined);
+ generateAiResponseActionMock = vi.fn().mockResolvedValue(undefined);
+ searchSuggestionsActionMock = vi.fn().mockResolvedValue([]);
+
const mockedUseMutation = useMutation as unknown as ReturnType;
- mockedUseMutation.mockReturnValue(vi.fn().mockResolvedValue(undefined));
+ mockedUseMutation.mockImplementation((mutationRef: unknown) => {
+ if (mutationRef === "messages.send") {
+ return sendMessageMutationMock;
+ }
+ if (mutationRef === "visitors.identify") {
+ return identifyVisitorMutationMock;
+ }
+ if (mutationRef === "aiAgent.submitFeedback") {
+ return submitAiFeedbackMutationMock;
+ }
+ if (mutationRef === "aiAgent.handoffToHuman") {
+ return handoffToHumanMutationMock;
+ }
+ return vi.fn().mockResolvedValue(undefined);
+ });
const mockedUseAction = useAction as unknown as ReturnType;
- mockedUseAction.mockReturnValue(vi.fn().mockResolvedValue([]));
+ mockedUseAction.mockImplementation((actionRef: unknown) => {
+ if (actionRef === "aiAgentActions.generateResponse") {
+ return generateAiResponseActionMock;
+ }
+ if (actionRef === "suggestions.searchForWidget") {
+ return searchSuggestionsActionMock;
+ }
+ return vi.fn().mockResolvedValue([]);
+ });
const mockedUseQuery = useQuery as unknown as ReturnType;
mockedUseQuery.mockImplementation((queryRef: unknown, args: unknown) => {
@@ -277,4 +313,21 @@ describe("ConversationView personas", () => {
expect(screen.queryByTestId("widget-waiting-human-divider")).toBeNull();
});
+
+ it("passes explicit handoff reason when visitor clicks talk to human", async () => {
+ renderSubject();
+
+ fireEvent.click(screen.getByTestId("widget-talk-to-human"));
+
+ await waitFor(() => {
+ expect(handoffToHumanMutationMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ conversationId: "conv_1",
+ visitorId: "visitor_1",
+ sessionToken: "wst_test",
+ reason: "Visitor clicked Talk to human button",
+ })
+ );
+ });
+ });
});
diff --git a/apps/widget/src/components/ConversationView.tsx b/apps/widget/src/components/ConversationView.tsx
index 106f9a3..91ca516 100644
--- a/apps/widget/src/components/ConversationView.tsx
+++ b/apps/widget/src/components/ConversationView.tsx
@@ -9,6 +9,7 @@ import { useDebouncedValue } from "../hooks/useDebouncedValue";
import { parseMarkdown } from "../utils/parseMarkdown";
const DEFAULT_HUMAN_AGENT_NAME = "Support";
+const MANUAL_HANDOFF_REASON = "Visitor clicked Talk to human button";
function resolveHumanAgentName(senderName?: string): string {
const normalized = senderName?.trim();
@@ -392,6 +393,7 @@ export function ConversationView({
conversationId,
visitorId,
sessionToken: sessionTokenRef.current ?? undefined,
+ reason: MANUAL_HANDOFF_REASON,
});
} catch (error) {
console.error("Failed to handoff to human:", error);
diff --git a/packages/convex/convex/aiAgent.ts b/packages/convex/convex/aiAgent.ts
index 2b367dc..b92666b 100644
--- a/packages/convex/convex/aiAgent.ts
+++ b/packages/convex/convex/aiAgent.ts
@@ -7,6 +7,7 @@ import {
type MutationCtx,
type QueryCtx,
} from "./_generated/server";
+import { internal } from "./_generated/api";
import { Doc, Id } from "./_generated/dataModel";
import { getAuthenticatedUserFromSession } from "./auth";
import { getWorkspaceMembership, requirePermission } from "./permissions";
@@ -646,25 +647,44 @@ export const handoffToHuman = mutation({
const handoffMessage =
settings?.handoffMessage ?? "Let me connect you with a human agent who can help you better.";
+ const now = Date.now();
+
const messageId: Id<"messages"> = await ctx.db.insert("messages", {
conversationId: args.conversationId,
senderId: "ai-agent",
content: handoffMessage,
senderType: "bot",
- createdAt: Date.now(),
+ createdAt: now,
});
- const now = Date.now();
-
// Update conversation to ensure it's open and visible to agents
await ctx.db.patch(args.conversationId, {
status: "open",
updatedAt: now,
+ lastMessageAt: now,
+ // Preserve existing visitor-unread count while ensuring handoff-only threads surface to agents.
+ unreadByAgent: Math.max(conversation.unreadByAgent || 0, 1),
aiWorkflowState: "handoff",
aiHandoffReason: args.reason,
aiLastResponseAt: now,
});
+ await ctx.scheduler.runAfter(0, internal.notifications.routeEvent, {
+ eventType: "chat_message",
+ domain: "chat",
+ audience: "agent",
+ workspaceId: conversation.workspaceId,
+ actorType: "bot",
+ conversationId: args.conversationId,
+ title: "AI handoff",
+ body: handoffMessage,
+ data: {
+ conversationId: args.conversationId,
+ type: "ai_handoff",
+ },
+ eventKey: `chat_handoff:${messageId}`,
+ });
+
return { messageId, handoffMessage };
},
});
diff --git a/packages/convex/tests/aiAgentHandoffPath.test.ts b/packages/convex/tests/aiAgentHandoffPath.test.ts
index 74096fc..94d4db0 100644
--- a/packages/convex/tests/aiAgentHandoffPath.test.ts
+++ b/packages/convex/tests/aiAgentHandoffPath.test.ts
@@ -7,5 +7,13 @@ describe("aiAgent.handoffToHuman persistence path", () => {
expect(source).toContain('senderId: "ai-agent"');
expect(source).not.toContain('senderId: "system"');
+ expect(source).toContain("const now = Date.now();");
+ expect(source).toContain("createdAt: now");
+ expect(source).toContain("lastMessageAt: now");
+ expect(source).toMatch(
+ /unreadByAgent:\s*Math\.max\(\s*conversation\.unreadByAgent\s*\|\|\s*0,\s*1\s*\)/
+ );
+ expect(source).toContain("internal.notifications.routeEvent");
+ expect(source).toContain('audience: "agent"');
});
});