From 5e2e5bb5ac067c69b888317e7c982ebc3a3587e7 Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Wed, 8 Jul 2026 17:23:01 -0700 Subject: [PATCH] (mock) support app --- .../[projectId]/support/approval-store.ts | 20 + .../support/components/approval-card.tsx | 70 +++ .../support/components/confidence-meter.tsx | 27 + .../support/components/copilot-pane.tsx | 84 +++ .../components/docs-suggestion-card.tsx | 59 ++ .../support/components/dossier-card.tsx | 105 ++++ .../support/components/fused-timeline.tsx | 66 ++ .../support/components/inbox-pane.tsx | 176 ++++++ .../support/components/incident-banner.tsx | 51 ++ .../support/components/message-bubble.tsx | 93 +++ .../[projectId]/support/components/shared.tsx | 67 ++ .../support/components/thread-pane.tsx | 178 ++++++ .../projects/[projectId]/support/fixtures.ts | 591 ++++++++++++++++++ .../support/mock-copilot-adapter.ts | 135 ++++ .../[projectId]/support/page-client.tsx | 82 +++ .../projects/[projectId]/support/page.tsx | 14 +- .../[projectId]/support/use-demo-playback.ts | 252 ++++++++ 17 files changed, 2058 insertions(+), 12 deletions(-) create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/approval-store.ts create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/components/approval-card.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/components/confidence-meter.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/components/copilot-pane.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/components/docs-suggestion-card.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/components/dossier-card.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/components/fused-timeline.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/components/inbox-pane.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/components/incident-banner.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/components/message-bubble.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/components/shared.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/components/thread-pane.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/fixtures.ts create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/mock-copilot-adapter.ts create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/page-client.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/use-demo-playback.ts diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/approval-store.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/approval-store.ts new file mode 100644 index 000000000..d17e0d438 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/approval-store.ts @@ -0,0 +1,20 @@ +/** + * Bridges the mock copilot adapter (which pauses mid-stream awaiting an + * approval) and the approval card rendered inside the assistant-ui thread. + */ +const resolvers = new Map void>(); + +export function waitForApproval(approvalId: string, abortSignal: AbortSignal): Promise { + return new Promise((resolve) => { + const settle = (approved: boolean) => { + resolvers.delete(approvalId); + resolve(approved); + }; + resolvers.set(approvalId, settle); + abortSignal.addEventListener("abort", () => settle(false), { once: true }); + }); +} + +export function resolveApproval(approvalId: string, approved: boolean): void { + resolvers.get(approvalId)?.(approved); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/components/approval-card.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/components/approval-card.tsx new file mode 100644 index 000000000..4d4ed3ca0 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/components/approval-card.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { cn } from "@/components/ui"; +import { type ToolCallContentPartProps } from "@assistant-ui/react"; +import { CheckIcon, ShieldCheckIcon, XIcon } from "@phosphor-icons/react"; +import { useEffect, useRef } from "react"; +import { resolveApproval } from "../approval-store"; + +/** + * Rendered for the copilot's `request-approval` tool call. The adapter pauses + * mid-stream until the operator approves or declines; the resolution flows + * back through the approval store. + */ +export function ApprovalCard({ toolCallId, args, result }: ToolCallContentPartProps) { + const info = args as { action?: string, summary?: string }; + const outcome = (result ?? undefined) as { approved?: boolean } | undefined; + const pending = outcome === undefined; + + // The card is the blocking decision — pin the thread viewport to the bottom + // so it sits right above the composer instead of clipped out of view. + const rootRef = useRef(null); + useEffect(() => { + if (!pending) return; + const viewport = rootRef.current?.closest(".overflow-y-auto"); + if (viewport) viewport.scrollTo({ top: viewport.scrollHeight, behavior: "smooth" }); + }, [pending]); + + return ( +
+
+ + {info.action ?? "Agent action"} + {!pending && ( + + {outcome.approved ? "Approved" : "Declined"} + + )} +
+ {info.summary && ( +

{info.summary}

+ )} + {pending && ( +
+ + +
+ )} +
+ ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/components/confidence-meter.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/components/confidence-meter.tsx new file mode 100644 index 000000000..20ec2738e --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/components/confidence-meter.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { cn } from "@/components/ui"; + +/** + * Quiet confidence readout: thin bar with a tick at the 90% auto-reply + * threshold. Stays monochrome below the threshold, tints green above it. + */ +export function ConfidenceMeter(props: { value: number, className?: string }) { + const clamped = Math.max(0, Math.min(100, props.value)); + const aboveThreshold = clamped >= 90; + return ( +
+ {clamped}% +
+
+
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/components/copilot-pane.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/components/copilot-pane.tsx new file mode 100644 index 000000000..846b2807e --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/components/copilot-pane.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { MarkdownText } from "@/components/assistant-ui/markdown-text"; +import { Thread } from "@/components/assistant-ui/thread"; +import { ToolFallback } from "@/components/assistant-ui/tool-fallback"; +import { AssistantRuntimeProvider, useLocalRuntime } from "@assistant-ui/react"; +import { SparkleIcon } from "@phosphor-icons/react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import type { DemoConversation, DossierField } from "../fixtures"; +import { buildInitialCopilotMessages, createMockCopilotAdapter } from "../mock-copilot-adapter"; +import { ApprovalCard } from "./approval-card"; +import { DocsSuggestionCard } from "./docs-suggestion-card"; +import { DossierCard } from "./dossier-card"; + +const RUNNING_STATUS_MESSAGES = ["Looking at the seeded context..."]; + +/** + * Keyed by conversation id from the parent so the runtime and canned script + * reset cleanly whenever the selected conversation changes. + */ +export function CopilotPane(props: { + conversation: DemoConversation, + revealedDossierFields: ReadonlySet, + onThreadEffect: (body: string) => void, +}) { + const { conversation, onThreadEffect } = props; + + // The dossier folds away once the operator starts working with the copilot, + // so the conversation (and any approval card) has the pane to itself. + const [dossierExpanded, setDossierExpanded] = useState(true); + + // Route thread effects through a ref so the adapter (and runtime) survive + // parent re-renders; only a conversation switch rebuilds them. + const onThreadEffectRef = useRef(onThreadEffect); + useEffect(() => { + onThreadEffectRef.current = onThreadEffect; + }, [onThreadEffect]); + const adapter = useMemo( + () => createMockCopilotAdapter(conversation, { + onThreadEffect: (body) => onThreadEffectRef.current(body), + onRunStart: () => setDossierExpanded(false), + }), + [conversation], + ); + const initialMessages = useMemo(() => buildInitialCopilotMessages(conversation), [conversation]); + const runtime = useLocalRuntime(adapter, { initialMessages }); + + const assistantContentComponents = useMemo(() => ({ + Text: MarkdownText, + tools: { by_name: { "request-approval": ApprovalCard }, Fallback: ToolFallback }, + }), []); + + return ( +
+
+ + Copilot + SQL · docs · replays · replies +
+ +
+ setDossierExpanded((prev) => !prev)} + /> + {conversation.docsSuggestion && } +
+ +
+ + + +
+
+ ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/components/docs-suggestion-card.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/components/docs-suggestion-card.tsx new file mode 100644 index 000000000..c10ad5b9c --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/components/docs-suggestion-card.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { FileTextIcon } from "@phosphor-icons/react"; +import { useState } from "react"; +import type { DemoConversation } from "../fixtures"; + +/** + * AI-suggested docs edit, shown when repeated conversations trace back to the + * same documentation gap. Mini diff, quiet colors. + */ +export function DocsSuggestionCard(props: { suggestion: NonNullable }) { + const { suggestion } = props; + const [applied, setApplied] = useState(false); + + return ( +
+
+ + Suggested docs update +
+

{suggestion.reason}

+
+
+ {suggestion.title} +
+
+ {suggestion.removed.map((line) => ( +
+ - {line} +
+ ))} + {suggestion.added.map((line) => ( +
+ + {line} +
+ ))} +
+
+
+ {applied ? ( + Draft PR opened against the docs repo + ) : ( + <> + + + + )} +
+
+ ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/components/dossier-card.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/components/dossier-card.tsx new file mode 100644 index 000000000..de9b9479f --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/components/dossier-card.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { cn } from "@/components/ui"; +import { CaretDownIcon, PlayCircleIcon } from "@phosphor-icons/react"; +import type { DemoConversation, DossierField } from "../fixtures"; +import { FusedTimeline } from "./fused-timeline"; + +function Row(props: { label: string, revealed: boolean, children: React.ReactNode }) { + return ( +
+
{props.label}
+
{props.children}
+
+ ); +} + +/** + * The auto-generated customer dossier. Fields fade in as the AI "collects" + * them during intake playback; static conversations show everything at once. + */ +export function DossierCard(props: { + conversation: DemoConversation, + revealedFields: ReadonlySet, + expanded: boolean, + onToggle: () => void, +}) { + const { conversation, revealedFields, expanded } = props; + const dossier = conversation.dossier; + const has = (field: DossierField) => revealedFields.has(field); + const anyRevealed = revealedFields.size > 0; + + return ( +
+ + + {expanded && ( +
+
+ + {dossier.userId} +
{dossier.email}
+
+ + {dossier.plan} +
customer for {dossier.signedUpAgo}
+
+
+ + +
    + {dossier.authEvents.map((event) => ( +
  • {event}
  • + ))} +
+
+ + {dossier.replay && ( + + + + )} + + + {dossier.pastTickets.length === 0 ? ( + None — first time reaching out + ) : ( +
    + {dossier.pastTickets.map((ticket) => ( +
  • + {ticket.subject} + · resolved {ticket.resolvedAgo} +
  • + ))} +
+ )} +
+ + {has("authEvents") && } +
+ )} +
+ ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/components/fused-timeline.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/components/fused-timeline.tsx new file mode 100644 index 000000000..c59815e4f --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/components/fused-timeline.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { cn } from "@/components/ui"; +import { useState } from "react"; +import type { DemoTimelineEntry } from "../fixtures"; + +const TONE_DOT: Record = { + ok: "bg-green-500/70", + warn: "bg-amber-500/70", + error: "bg-red-500/70", + neutral: "bg-foreground/25", +}; + +/** + * Fused activity timeline: the customer's events and spans interleaved with + * their support messages, so the thread and the telemetry read as one story. + * Dot/line pattern borrowed from the email viewer's timeline. + */ +export function FusedTimeline(props: { entries: DemoTimelineEntry[] }) { + const [expanded, setExpanded] = useState(false); + const entries = expanded ? props.entries : props.entries.slice(0, 4); + + return ( +
+
Timeline — events, spans, and messages
+
+ {entries.map((entry, index) => ( +
+
+ + {index < entries.length - 1 && } +
+
+
+ + {entry.label} + + {entry.at} +
+ {entry.detail && ( +
+ {entry.detail} +
+ )} +
+
+ ))} +
+ {props.entries.length > 4 && ( + + )} +
+ ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/components/inbox-pane.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/components/inbox-pane.tsx new file mode 100644 index 000000000..e918f4b29 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/components/inbox-pane.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { cn } from "@/components/ui"; +import { MagnifyingGlassIcon, SparkleIcon } from "@phosphor-icons/react"; +import { useMemo, useState } from "react"; +import { CHANNEL_LABELS, DEMO_INCIDENT, getClusterSize, type DemoChannel, type DemoConversation } from "../fixtures"; +import type { ConversationPlaybackState } from "../use-demo-playback"; +import { ChannelIcon, CustomerAvatar } from "./shared"; + +const FILTERABLE_CHANNELS: DemoChannel[] = ["slack", "whatsapp", "imessage", "email", "web", "telegram", "discord", "sms"]; + +function formatAge(minutesAgo: number): string { + if (minutesAgo < 60) return `${minutesAgo}m`; + return `${Math.floor(minutesAgo / 60)}h`; +} + +function PriorityDot(props: { priority: DemoConversation["priority"] }) { + if (props.priority === "normal") return null; + return ( + + ); +} + +function ConversationRow(props: { + conversation: DemoConversation, + playback: ConversationPlaybackState, + incidentTripped: boolean, + selected: boolean, + onSelect: () => void, +}) { + const { conversation, playback, selected } = props; + const aiActive = playback.typing === "ai" || (conversation.aiState === "intake" && playback.scriptStatus === "playing"); + const clusterSize = getClusterSize(conversation.clusterId, props.incidentTripped); + const lastMessage = playback.messages[playback.messages.length - 1] as typeof playback.messages[number] | undefined; + const preview = lastMessage?.kind === "text" || lastMessage?.kind === "auto-reply" ? lastMessage.body : conversation.preview; + + return ( + + ); +} + +export function InboxPane(props: { + conversations: DemoConversation[], + playbackFor: (conversationId: string) => ConversationPlaybackState, + incidentTripped: boolean, + selectedId: string, + onSelect: (conversationId: string) => void, +}) { + const [search, setSearch] = useState(""); + const [channelFilter, setChannelFilter] = useState(null); + + const filtered = useMemo(() => { + const query = search.trim().toLowerCase(); + return props.conversations.filter((conversation) => { + if (channelFilter && conversation.channel !== channelFilter) return false; + if (query === "") return true; + return [conversation.customer.name, conversation.customer.company, conversation.subject, conversation.preview] + .some((field) => field.toLowerCase().includes(query)); + }); + }, [props.conversations, search, channelFilter]); + + return ( +
+
+
+ + setSearch(event.target.value)} + placeholder="Search conversations" + className="w-full bg-transparent text-xs text-foreground outline-none placeholder:text-muted-foreground/50" + /> +
+
+ + {FILTERABLE_CHANNELS.map((channel) => ( + + ))} +
+
+ + {props.incidentTripped && ( +
+ + + + + + Incident — {DEMO_INCIDENT.reportCount} reports in {DEMO_INCIDENT.windowMinutes}m + +
+ )} + +
+ {filtered.length === 0 ? ( +

+ No conversations match. +

+ ) : ( +
+ {filtered.map((conversation) => ( + props.onSelect(conversation.id)} + /> + ))} +
+ )} +
+
+ ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/components/incident-banner.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/components/incident-banner.tsx new file mode 100644 index 000000000..cc2f8cba3 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/components/incident-banner.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { cn } from "@/components/ui"; +import { CaretDownIcon } from "@phosphor-icons/react"; +import { useState } from "react"; +import { DEMO_INCIDENT } from "../fixtures"; + +export function IncidentBanner(props: { released: boolean, onRelease: () => void }) { + const [expanded, setExpanded] = useState(false); + + return ( +
+
+ + + + +

+ {DEMO_INCIDENT.title} — {DEMO_INCIDENT.reportCount} reports in {DEMO_INCIDENT.windowMinutes} minutes +

+ +
+ {expanded && ( +
+

{DEMO_INCIDENT.statusDraft}

+
+ + {!props.released && ( + + )} +
+
+ )} +
+ ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/components/message-bubble.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/components/message-bubble.tsx new file mode 100644 index 000000000..88b8920e6 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/components/message-bubble.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { cn } from "@/components/ui"; +import { PlayIcon, SparkleIcon } from "@phosphor-icons/react"; +import type { DemoConversation, DemoMessage, DemoSender } from "../fixtures"; +import { CustomerAvatar, TypingDots } from "./shared"; + +function SenderLabel(props: { children: React.ReactNode }) { + return {props.children}; +} + +export function MessageBubble(props: { message: DemoMessage, conversation: DemoConversation }) { + const { message, conversation } = props; + + if (message.kind === "status") { + return ( +
+

+ Internal + {message.body} +

+
+ ); + } + + if (message.kind === "devin-video") { + return ( +
+
+
+
+ +
+
+
+ Devin repro — Safari 26 + 0:42 +
+

{message.body}

+
+
+ ); + } + + const isCustomer = message.sender === "customer"; + const isAi = message.sender === "ai"; + const isAutoReply = message.kind === "auto-reply"; + + return ( +
+ {isCustomer && } +
+
+ {message.body} +
+
+ {message.at} + {isAi && ( + + + {isAutoReply ? "AI · auto-replied at 93% confidence" : "AI"} + + )} + {!isCustomer && !isAi && You} +
+
+
+ ); +} + +export function TypingBubble(props: { sender: Exclude, conversation: DemoConversation }) { + const isCustomer = props.sender === "customer"; + return ( +
+ {isCustomer && } +
+ +
+
+ ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/components/shared.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/components/shared.tsx new file mode 100644 index 000000000..734adfda9 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/components/shared.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { cn } from "@/components/ui"; +import { + AppleLogoIcon, + ChatTeardropDotsIcon, + DiscordLogoIcon, + EnvelopeSimpleIcon, + GlobeSimpleIcon, + SlackLogoIcon, + TelegramLogoIcon, + WhatsappLogoIcon, +} from "@phosphor-icons/react"; +import type { DemoChannel } from "../fixtures"; + +const CHANNEL_ICONS: Record = { + slack: SlackLogoIcon, + whatsapp: WhatsappLogoIcon, + imessage: AppleLogoIcon, + telegram: TelegramLogoIcon, + discord: DiscordLogoIcon, + email: EnvelopeSimpleIcon, + web: GlobeSimpleIcon, + sms: ChatTeardropDotsIcon, +}; + +export function ChannelIcon(props: { channel: DemoChannel, className?: string }) { + const Icon = CHANNEL_ICONS[props.channel]; + return ; +} + +export function CustomerAvatar(props: { name: string, hue: number, className?: string }) { + const initials = props.name + .split(" ") + .map((part) => part.slice(0, 1)) + .slice(0, 2) + .join(""); + return ( +
+ {initials} +
+ ); +} + +export function TypingDots(props: { className?: string }) { + return ( + + + + + + ); +} + +export function PaneHeading(props: { children: React.ReactNode, className?: string }) { + return ( +
+ {props.children} +
+ ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/components/thread-pane.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/components/thread-pane.tsx new file mode 100644 index 000000000..a7feccb29 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/components/thread-pane.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { cn } from "@/components/ui"; +import { ArrowCounterClockwiseIcon, PaperPlaneRightIcon, SparkleIcon } from "@phosphor-icons/react"; +import { useEffect, useRef } from "react"; +import { CHANNEL_LABELS, DEMO_INCIDENT, type DemoConversation } from "../fixtures"; +import type { ConversationPlaybackState, DemoPlayback } from "../use-demo-playback"; +import { ConfidenceMeter } from "./confidence-meter"; +import { IncidentBanner } from "./incident-banner"; +import { MessageBubble, TypingBubble } from "./message-bubble"; + +function AiStateLine(props: { conversation: DemoConversation, playback: ConversationPlaybackState }) { + const { conversation, playback } = props; + if (playback.scriptStatus === "playing") return null; + if (conversation.aiState !== "standing-by") return null; + return ( +
+ + + AI standing by — this reads as a question for a human + +
+ ); +} + +function Composer(props: { + conversation: DemoConversation, + playback: ConversationPlaybackState, + demo: DemoPlayback, + held: boolean, +}) { + const { conversation, playback, demo } = props; + const draftIsFromAi = playback.draft !== "" && (playback.draft === conversation.initialDraft || conversation.script !== undefined); + + const handleSend = () => { + if (playback.draft.trim().toLowerCase().includes("@devin")) { + demo.setDraft(conversation.id, ""); + demo.tagDevin(conversation.id); + return; + } + demo.sendAgentReply(conversation.id, playback.draft); + }; + + if (props.held && !playback.repliesReleased) { + return ( +
+
+ + Reply held — this conversation gets the canonical incident answer + + +
+
+ ); + } + + return ( +
+ {draftIsFromAi && ( +
+ + Draft prepared by AI — edit freely before sending +
+ )} +
+