Skip to content

Commit e00b2e7

Browse files
committed
Handoff UX improvements
1 parent b3cb874 commit e00b2e7

4 files changed

Lines changed: 172 additions & 15 deletions

File tree

apps/widget/src/components/ConversationView.tsx

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useEffect, useRef } from "react";
1+
import { useState, useEffect, useMemo, useRef } from "react";
22
import { useQuery, useMutation, useAction } from "convex/react";
33
import { api } from "@opencom/convex";
44
import type { Id } from "@opencom/convex/dataModel";
@@ -373,6 +373,36 @@ export function ConversationView({
373373
}
374374
};
375375

376+
const showWaitingForHumanSupport = useMemo(() => {
377+
if (!messages || messages.length === 0 || !aiResponses || aiResponses.length === 0) {
378+
return false;
379+
}
380+
381+
const handoffMessageIds = new Set(
382+
aiResponses
383+
.filter((response: { handedOff?: boolean; messageId: string }) => response.handedOff)
384+
.map((response: { messageId: string }) => response.messageId)
385+
);
386+
if (handoffMessageIds.size === 0) {
387+
return false;
388+
}
389+
390+
let lastHandoffMessageIndex = -1;
391+
for (let i = messages.length - 1; i >= 0; i -= 1) {
392+
if (handoffMessageIds.has(messages[i]._id)) {
393+
lastHandoffMessageIndex = i;
394+
break;
395+
}
396+
}
397+
if (lastHandoffMessageIndex < 0) {
398+
return false;
399+
}
400+
401+
return !messages
402+
.slice(lastHandoffMessageIndex + 1)
403+
.some((message) => message.senderType === "agent" || message.senderType === "user");
404+
}, [aiResponses, messages]);
405+
376406
const dismissCsatPrompt = () => {
377407
setCsatPromptVisible(false);
378408
setDismissedCsatByConversation((prev) => ({ ...prev, [conversationKey]: true }));
@@ -516,6 +546,11 @@ export function ConversationView({
516546
}
517547
)
518548
)}
549+
{showWaitingForHumanSupport && (
550+
<div className="opencom-status-divider" data-testid="widget-waiting-human-divider">
551+
Waiting for human support
552+
</div>
553+
)}
519554
{isAiTyping && (
520555
<div className="opencom-message opencom-message-agent opencom-message-ai opencom-typing">
521556
<span className="opencom-ai-badge">

apps/widget/src/styles.css

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,25 @@
253253
font-weight: 500;
254254
}
255255

256+
.opencom-status-divider {
257+
display: flex;
258+
align-items: center;
259+
gap: 8px;
260+
color: var(--opencom-text-muted);
261+
font-size: 11px;
262+
font-weight: 500;
263+
text-align: center;
264+
margin: 4px 0;
265+
}
266+
267+
.opencom-status-divider::before,
268+
.opencom-status-divider::after {
269+
content: "";
270+
flex: 1;
271+
height: 1px;
272+
background: color-mix(in srgb, var(--opencom-text-muted) 25%, transparent);
273+
}
274+
256275
.opencom-message {
257276
max-width: 80%;
258277
padding: 12px 16px;

packages/convex/convex/aiAgentActions.ts

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,42 @@ export const generateResponse = action({
538538
title: r.title,
539539
}));
540540

541+
if (handoff) {
542+
// Persist only the precanned handoff message so visitors see one assistant message.
543+
const handoffResult = await ctx.runMutation(api.aiAgent.handoffToHuman, {
544+
conversationId: args.conversationId,
545+
visitorId: args.visitorId,
546+
sessionToken: args.sessionToken,
547+
reason: handoffReason ?? undefined,
548+
});
549+
550+
await ctx.runMutation(api.aiAgent.storeResponse, {
551+
conversationId: args.conversationId,
552+
visitorId: args.visitorId,
553+
sessionToken: args.sessionToken,
554+
messageId: handoffResult.messageId,
555+
query: args.query,
556+
response: handoffResult.handoffMessage,
557+
sources: [],
558+
confidence,
559+
handedOff: true,
560+
handoffReason: handoffReason ?? undefined,
561+
generationTimeMs,
562+
tokensUsed,
563+
model: settings.model,
564+
provider,
565+
});
566+
567+
return {
568+
response: handoffResult.handoffMessage,
569+
confidence,
570+
sources: [],
571+
handoff: true,
572+
handoffReason,
573+
messageId: handoffResult.messageId,
574+
};
575+
}
576+
541577
// Create the AI message via the internal bot-only path.
542578
const messageId = await ctx.runMutation(internal.messages.internalSendBotMessage, {
543579
conversationId: args.conversationId,
@@ -555,30 +591,19 @@ export const generateResponse = action({
555591
response: responseText,
556592
sources,
557593
confidence,
558-
handedOff: handoff,
559-
handoffReason: handoffReason ?? undefined,
594+
handedOff: false,
560595
generationTimeMs,
561596
tokensUsed,
562597
model: settings.model,
563598
provider,
564599
});
565600

566-
// If handoff is needed, trigger it
567-
if (handoff) {
568-
await ctx.runMutation(api.aiAgent.handoffToHuman, {
569-
conversationId: args.conversationId,
570-
visitorId: args.visitorId,
571-
sessionToken: args.sessionToken,
572-
reason: handoffReason ?? undefined,
573-
});
574-
}
575-
576601
return {
577602
response: responseText,
578603
confidence,
579604
sources,
580-
handoff,
581-
handoffReason,
605+
handoff: false,
606+
handoffReason: null,
582607
messageId,
583608
};
584609
},

packages/convex/tests/aiAgentRuntimeSafety.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,84 @@ describe("aiAgentActions runtime safety", () => {
362362
);
363363
});
364364

365+
it("stores only the handoff message when AI output requires a handoff", async () => {
366+
const handoffMessage = "Let me connect you with a human agent who can help you better.";
367+
mockGenerateText.mockResolvedValue({
368+
text: "I don't have enough information to answer that question. Let me connect you with a human agent.",
369+
usage: { totalTokens: 33 },
370+
} as any);
371+
372+
const runQuery = vi.fn(async (_reference: unknown, args: Record<string, unknown>) => {
373+
if ("query" in args) {
374+
return [];
375+
}
376+
if ("workspaceId" in args && "conversationId" in args === false) {
377+
return {
378+
enabled: true,
379+
model: "openai/gpt-5-nano",
380+
confidenceThreshold: 0.2,
381+
knowledgeSources: ["articles"],
382+
personality: null,
383+
};
384+
}
385+
return {
386+
conversationId: "conversation_1",
387+
workspaceId: "workspace_1",
388+
visitorId: "visitor_1",
389+
};
390+
});
391+
392+
const runMutation = vi.fn(async (_reference: unknown, args: Record<string, unknown>) => {
393+
if (Object.keys(args).length === 1 && "workspaceId" in args) {
394+
return "cleared";
395+
}
396+
if ("reason" in args) {
397+
return {
398+
messageId: "handoff_message_single_1",
399+
handoffMessage,
400+
};
401+
}
402+
if ("query" in args && "response" in args) {
403+
return "ai_response_handoff_1";
404+
}
405+
if ("senderId" in args && "content" in args) {
406+
throw new Error("Unexpected AI message persistence before handoff");
407+
}
408+
throw new Error(`Unexpected mutation args: ${JSON.stringify(args)}`);
409+
});
410+
411+
const result = await generateResponse._handler(
412+
{
413+
runQuery,
414+
runMutation,
415+
} as any,
416+
{
417+
workspaceId: "workspace_1" as any,
418+
conversationId: "conversation_1" as any,
419+
query: "Who should I vote for?",
420+
}
421+
);
422+
423+
expect(result.handoff).toBe(true);
424+
expect(result.messageId).toBe("handoff_message_single_1");
425+
expect(result.response).toBe(handoffMessage);
426+
expect(runMutation).toHaveBeenCalledWith(
427+
expect.anything(),
428+
expect.objectContaining({
429+
query: "Who should I vote for?",
430+
response: handoffMessage,
431+
messageId: "handoff_message_single_1",
432+
handedOff: true,
433+
})
434+
);
435+
expect(runMutation).not.toHaveBeenCalledWith(
436+
expect.anything(),
437+
expect.objectContaining({
438+
senderId: "ai-agent",
439+
})
440+
);
441+
});
442+
365443
it("persists a handoff message when generation fails", async () => {
366444
mockGenerateText.mockRejectedValue(new Error("gateway timeout"));
367445

0 commit comments

Comments
 (0)