Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 46 additions & 9 deletions apps/web/src/app/inbox/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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();
Expand Down Expand Up @@ -1029,6 +1038,18 @@ function InboxContent(): React.JSX.Element | null {
</Button>
)}
</div>
{selectedConversation?.aiWorkflow?.state === "handoff" && (
<div
className="inline-flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50 px-2 py-1 text-xs text-amber-800"
data-testid="inbox-handoff-context-banner"
>
<Bot className="h-3 w-3" />
<span className="font-medium">AI handoff</span>
<span className="truncate">
{getHandoffReasonLabel(selectedConversation.aiWorkflow.handoffReason)}
</span>
Comment on lines +1048 to +1050
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fallback copy "Reason not provided by handoff trigger" is duplicated in multiple UI spots (banner + AI review empty state). Consider centralizing this string (e.g., a constant/helper) so future copy changes stay consistent across the inbox UI.

Copilot uses AI. Check for mistakes.
</div>
)}
{isCompactViewport && (
<div
className="flex items-center gap-2"
Expand Down Expand Up @@ -1500,12 +1521,27 @@ function InboxContent(): React.JSX.Element | null {
Loading AI responses...
</p>
) : !orderedAiResponses || orderedAiResponses.length === 0 ? (
<p
className="text-sm text-muted-foreground"
data-testid="inbox-ai-review-empty"
>
No AI responses in this conversation yet.
</p>
selectedConversation?.aiWorkflow?.state === "handoff" ? (
<div
className="space-y-2 rounded-md border border-amber-200 bg-amber-50 p-3"
data-testid="inbox-ai-review-handoff-only"
>
<p className="text-sm font-medium text-amber-800">
Conversation was handed off before an AI response record was stored.
</p>
<p className="text-xs text-amber-800">
Handoff reason:{" "}
{getHandoffReasonLabel(selectedConversation.aiWorkflow.handoffReason)}
</p>
</div>
) : (
<p
className="text-sm text-muted-foreground"
data-testid="inbox-ai-review-empty"
>
No AI responses in this conversation yet.
</p>
)
) : (
orderedAiResponses.map(
(response: NonNullable<typeof orderedAiResponses>[number]) => {
Expand Down Expand Up @@ -1627,9 +1663,10 @@ function InboxContent(): React.JSX.Element | null {
{response.handedOff && (
<p className="rounded bg-amber-50 px-2 py-1 text-xs text-amber-800">
Handoff reason:{" "}
{response.handoffReason ??
selectedConversation?.aiWorkflow?.handoffReason ??
"Not specified"}
{getHandoffReasonLabel(
response.handoffReason ??
selectedConversation?.aiWorkflow?.handoffReason
)}
</p>
)}

Expand Down
59 changes: 56 additions & 3 deletions apps/widget/src/components/ConversationView.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -13,6 +13,7 @@ vi.mock("@opencom/convex", () => ({
api: {
messages: {
list: "messages.list",
send: "messages.send",
},
conversations: {
get: "conversations.get",
Expand Down Expand Up @@ -64,6 +65,12 @@ describe("ConversationView personas", () => {
let messagesResult: MessageFixture[];
let aiResponsesResult: AiResponseFixture[];
let conversationResult: { aiWorkflowState?: "none" | "ai_handled" | "handoff" } | null;
let sendMessageMutationMock: ReturnType<typeof vi.fn>;
let identifyVisitorMutationMock: ReturnType<typeof vi.fn>;
let submitAiFeedbackMutationMock: ReturnType<typeof vi.fn>;
let handoffToHumanMutationMock: ReturnType<typeof vi.fn>;
let generateAiResponseActionMock: ReturnType<typeof vi.fn>;
let searchSuggestionsActionMock: ReturnType<typeof vi.fn>;

beforeEach(() => {
vi.clearAllMocks();
Expand All @@ -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<typeof vi.fn>;
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);
});
Comment on lines 90 to +105
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This useMutation mockImplementation expects mutationRef === "messages.send" to return sendMessageMutationMock, but the @opencom/convex mock in this test file doesn’t define api.messages.send (only messages.list). That means the component will call useMutation(undefined) and this branch will never match, so sendMessageMutationMock is unused and the test setup diverges from real usage. Add messages.send: "messages.send" to the mocked api object so the mutation ref mapping is exercised correctly.

Copilot uses AI. Check for mistakes.

const mockedUseAction = useAction as unknown as ReturnType<typeof vi.fn>;
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<typeof vi.fn>;
mockedUseQuery.mockImplementation((queryRef: unknown, args: unknown) => {
Expand Down Expand Up @@ -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",
})
);
});
});
});
2 changes: 2 additions & 0 deletions apps/widget/src/components/ConversationView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
26 changes: 23 additions & 3 deletions packages/convex/convex/aiAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Comment on lines 662 to 667
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lastMessageAt/updatedAt are set from now, but the inserted handoff message uses its own Date.now() value for createdAt. This can create small ordering inconsistencies (conversation sorted by lastMessageAt slightly after the message timestamp). Consider capturing a single timestamp once and using it for both the message createdAt and the conversation patch fields.

Copilot uses AI. Check for mistakes.
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 };
},
});
Expand Down
8 changes: 8 additions & 0 deletions packages/convex/tests/aiAgentHandoffPath.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"');
});
});
Loading