mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
(mock) support app
This commit is contained in:
parent
de492a3ca3
commit
5e2e5bb5ac
@ -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<string, (approved: boolean) => void>();
|
||||
|
||||
export function waitForApproval(approvalId: string, abortSignal: AbortSignal): Promise<boolean> {
|
||||
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);
|
||||
}
|
||||
@ -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<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!pending) return;
|
||||
const viewport = rootRef.current?.closest(".overflow-y-auto");
|
||||
if (viewport) viewport.scrollTo({ top: viewport.scrollHeight, behavior: "smooth" });
|
||||
}, [pending]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={cn(
|
||||
"my-2 rounded-lg bg-foreground/[0.03] px-3 py-2.5 ring-1",
|
||||
pending ? "ring-purple-500/25" : "ring-foreground/[0.08]",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheckIcon className="h-3.5 w-3.5 shrink-0 text-purple-400/80" />
|
||||
<span className="text-[12px] font-medium text-foreground/90">{info.action ?? "Agent action"}</span>
|
||||
{!pending && (
|
||||
<span className={cn("ml-auto text-[10px]", outcome.approved ? "text-green-500/80" : "text-muted-foreground/60")}>
|
||||
{outcome.approved ? "Approved" : "Declined"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{info.summary && (
|
||||
<p className="mt-1 text-[11px] leading-relaxed text-muted-foreground">{info.summary}</p>
|
||||
)}
|
||||
{pending && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resolveApproval(toolCallId, true)}
|
||||
className="flex h-6 items-center gap-1 rounded-md bg-foreground px-2.5 text-[11px] font-medium text-background transition-colors hover:bg-foreground/90"
|
||||
>
|
||||
<CheckIcon className="h-3 w-3" />
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resolveApproval(toolCallId, false)}
|
||||
className="flex h-6 items-center gap-1 rounded-md px-2 text-[11px] text-muted-foreground/70 transition-colors hover:bg-foreground/[0.04] hover:text-foreground/80"
|
||||
>
|
||||
<XIcon className="h-3 w-3" />
|
||||
Decline
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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 (
|
||||
<div className={cn("flex items-center gap-2", props.className)} title={`AI confidence ${clamped}% — auto-replies at 90%`}>
|
||||
<span className="text-[11px] tabular-nums text-muted-foreground/70">{clamped}%</span>
|
||||
<div className="relative h-[3px] w-16 overflow-hidden rounded-full bg-foreground/[0.08]">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-y-0 left-0 rounded-full transition-[width] duration-700 ease-out",
|
||||
aboveThreshold ? "bg-green-500/70" : "bg-foreground/30",
|
||||
)}
|
||||
style={{ width: `${clamped}%` }}
|
||||
/>
|
||||
<div className="absolute inset-y-0 left-[90%] w-px bg-foreground/25" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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<DossierField>,
|
||||
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 (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-foreground/[0.06] px-4 py-3.5">
|
||||
<SparkleIcon className="h-3.5 w-3.5 text-purple-400/70" />
|
||||
<span className="text-sm font-medium tracking-tight text-foreground">Copilot</span>
|
||||
<span className="ml-auto text-[10px] text-muted-foreground/50">SQL · docs · replays · replies</span>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[34%] shrink-0 overflow-y-auto">
|
||||
<DossierCard
|
||||
conversation={conversation}
|
||||
revealedFields={props.revealedDossierFields}
|
||||
expanded={dossierExpanded}
|
||||
onToggle={() => setDossierExpanded((prev) => !prev)}
|
||||
/>
|
||||
{conversation.docsSuggestion && <DocsSuggestionCard suggestion={conversation.docsSuggestion} />}
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread
|
||||
composerPlaceholder="Ask about this customer..."
|
||||
runningStatusMessages={RUNNING_STATUS_MESSAGES}
|
||||
assistantContentComponents={assistantContentComponents}
|
||||
hideMessageActions
|
||||
autoFocusComposer={false}
|
||||
/>
|
||||
</AssistantRuntimeProvider>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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<DemoConversation["docsSuggestion"]> }) {
|
||||
const { suggestion } = props;
|
||||
const [applied, setApplied] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="shrink-0 border-b border-foreground/[0.06] px-4 py-3.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileTextIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60" />
|
||||
<span className="text-[11px] font-medium text-muted-foreground/70">Suggested docs update</span>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[11px] leading-relaxed text-muted-foreground">{suggestion.reason}</p>
|
||||
<div className="mt-2 overflow-hidden rounded-lg bg-foreground/[0.02]">
|
||||
<div className="border-b border-foreground/[0.05] px-2.5 py-1.5 text-[10px] text-muted-foreground/60">
|
||||
{suggestion.title}
|
||||
</div>
|
||||
<div className="space-y-px p-2 font-mono text-[10px] leading-relaxed">
|
||||
{suggestion.removed.map((line) => (
|
||||
<div key={line} className="rounded px-1.5 py-0.5 text-red-500/80 dark:text-red-400/80">
|
||||
- {line}
|
||||
</div>
|
||||
))}
|
||||
{suggestion.added.map((line) => (
|
||||
<div key={line} className="whitespace-pre-wrap rounded px-1.5 py-0.5 text-green-600/90 dark:text-green-400/80">
|
||||
+ {line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
{applied ? (
|
||||
<span className="text-[11px] text-muted-foreground/60">Draft PR opened against the docs repo</span>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setApplied(true)}
|
||||
className="text-[11px] font-medium text-foreground/70 transition-colors hover:text-foreground"
|
||||
>
|
||||
Open docs PR
|
||||
</button>
|
||||
<button type="button" className="text-[11px] text-muted-foreground/60 transition-colors hover:text-foreground/80">
|
||||
Dismiss
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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 (
|
||||
<div className={cn("transition-opacity duration-500", props.revealed ? "opacity-100" : "opacity-0")}>
|
||||
<div className="text-[10px] text-muted-foreground/50">{props.label}</div>
|
||||
<div className="mt-0.5 text-xs leading-relaxed text-foreground/85">{props.children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<DossierField>,
|
||||
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 (
|
||||
<div className="shrink-0 border-b border-foreground/[0.06]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onToggle}
|
||||
className="flex w-full items-center gap-2 px-4 py-2.5 text-left"
|
||||
>
|
||||
<span className="text-[11px] font-medium text-muted-foreground/70">Customer dossier</span>
|
||||
{!anyRevealed && (
|
||||
<span className="flex items-center gap-1.5 text-[10px] text-muted-foreground/50">
|
||||
<span className="h-1 w-1 animate-pulse rounded-full bg-purple-400/60" />
|
||||
gathering
|
||||
</span>
|
||||
)}
|
||||
<CaretDownIcon className={cn("ml-auto h-3 w-3 text-muted-foreground/40 transition-transform duration-200", !expanded && "-rotate-90")} />
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="space-y-3 px-4 pb-4">
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-3">
|
||||
<Row label="User" revealed={has("identity")}>
|
||||
<span className="font-mono text-[11px]">{dossier.userId}</span>
|
||||
<div className="truncate text-[11px] text-muted-foreground/70">{dossier.email}</div>
|
||||
</Row>
|
||||
<Row label="Plan" revealed={has("plan")}>
|
||||
{dossier.plan}
|
||||
<div className="text-[11px] text-muted-foreground/70">customer for {dossier.signedUpAgo}</div>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
<Row label="Recent activity" revealed={has("authEvents")}>
|
||||
<ul className="space-y-1">
|
||||
{dossier.authEvents.map((event) => (
|
||||
<li key={event} className="text-[11px] leading-relaxed text-foreground/75">{event}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Row>
|
||||
|
||||
{dossier.replay && (
|
||||
<Row label="Session replay" revealed={has("replay")}>
|
||||
<button
|
||||
type="button"
|
||||
className="group flex w-full items-center gap-2 rounded-lg bg-foreground/[0.03] px-2.5 py-2 text-left transition-colors hover:bg-foreground/[0.05]"
|
||||
>
|
||||
<PlayCircleIcon className="h-4 w-4 shrink-0 text-foreground/50 transition-colors group-hover:text-foreground/80" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[11px] text-foreground/85">{dossier.replay.label}</span>
|
||||
<span className="block text-[10px] tabular-nums text-muted-foreground/60">{dossier.replay.id} · {dossier.replay.duration}</span>
|
||||
</span>
|
||||
</button>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<Row label="Past tickets" revealed={has("pastTickets")}>
|
||||
{dossier.pastTickets.length === 0 ? (
|
||||
<span className="text-[11px] text-muted-foreground/60">None — first time reaching out</span>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{dossier.pastTickets.map((ticket) => (
|
||||
<li key={ticket.subject} className="text-[11px] text-foreground/75">
|
||||
{ticket.subject}
|
||||
<span className="text-muted-foreground/55"> · resolved {ticket.resolvedAgo}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Row>
|
||||
|
||||
{has("authEvents") && <FusedTimeline entries={conversation.timeline} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/components/ui";
|
||||
import { useState } from "react";
|
||||
import type { DemoTimelineEntry } from "../fixtures";
|
||||
|
||||
const TONE_DOT: Record<DemoTimelineEntry["tone"], string> = {
|
||||
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 (
|
||||
<div className="animate-in fade-in duration-500">
|
||||
<div className="text-[10px] text-muted-foreground/50">Timeline — events, spans, and messages</div>
|
||||
<div className="mt-1.5">
|
||||
{entries.map((entry, index) => (
|
||||
<div key={entry.id} className="flex gap-2.5">
|
||||
<div className="flex w-2 flex-col items-center">
|
||||
<span className={cn("mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full", TONE_DOT[entry.tone])} />
|
||||
{index < entries.length - 1 && <span className="w-px flex-1 bg-foreground/[0.08]" />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pb-2.5">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className={cn(
|
||||
"min-w-0 truncate text-[11px]",
|
||||
entry.kind === "span" ? "font-mono text-foreground/75" : "text-foreground/85",
|
||||
)}>
|
||||
{entry.label}
|
||||
</span>
|
||||
<span className="ml-auto shrink-0 text-[10px] tabular-nums text-muted-foreground/50">{entry.at}</span>
|
||||
</div>
|
||||
{entry.detail && (
|
||||
<div className={cn(
|
||||
"text-[10px]",
|
||||
entry.tone === "error" ? "text-red-400/80" : "text-muted-foreground/60",
|
||||
)}>
|
||||
{entry.detail}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{props.entries.length > 4 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
className="text-[10px] text-muted-foreground/60 transition-colors hover:text-foreground/80"
|
||||
>
|
||||
{expanded ? "Show less" : `Show ${props.entries.length - 4} more`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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 (
|
||||
<span
|
||||
className={cn("h-1.5 w-1.5 shrink-0 rounded-full", props.priority === "urgent" ? "bg-red-500/80" : "bg-amber-500/80")}
|
||||
title={props.priority === "urgent" ? "Urgent" : "High priority"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onSelect}
|
||||
className={cn(
|
||||
"w-full px-4 py-3 text-left transition-colors duration-150 hover:transition-none",
|
||||
selected ? "bg-foreground/[0.04]" : "hover:bg-foreground/[0.02]",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<CustomerAvatar name={conversation.customer.name} hue={conversation.customer.hue} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={cn("truncate text-[13px]", conversation.unread ? "font-semibold text-foreground" : "font-medium text-foreground/85")}>
|
||||
{conversation.customer.name}
|
||||
</span>
|
||||
<PriorityDot priority={conversation.priority} />
|
||||
<span className="ml-auto flex shrink-0 items-center gap-1.5">
|
||||
<ChannelIcon channel={conversation.channel} className="h-3 w-3 text-muted-foreground/50" />
|
||||
<span className="text-[10px] tabular-nums text-muted-foreground/50">{formatAge(conversation.minutesAgo)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<p className={cn("mt-0.5 truncate text-xs", conversation.unread ? "text-foreground/75" : "text-muted-foreground/70")}>
|
||||
{conversation.subject}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center gap-2 pl-[38px]">
|
||||
<p className="min-w-0 flex-1 truncate text-[11px] leading-relaxed text-muted-foreground/55">
|
||||
{preview}
|
||||
</p>
|
||||
{aiActive && <SparkleIcon className="h-3 w-3 shrink-0 animate-pulse text-purple-400/70" />}
|
||||
{clusterSize > 1 && (
|
||||
<span className="shrink-0 rounded-full bg-foreground/[0.05] px-1.5 py-px text-[10px] tabular-nums text-muted-foreground/70">
|
||||
{clusterSize} similar
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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<DemoChannel | null>(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 (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="shrink-0 px-4 pb-3 pt-4">
|
||||
<div className="flex items-center gap-2 rounded-lg bg-foreground/[0.03] px-2.5 py-1.5 focus-within:bg-foreground/[0.05] transition-colors">
|
||||
<MagnifyingGlassIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground/50" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Search conversations"
|
||||
className="w-full bg-transparent text-xs text-foreground outline-none placeholder:text-muted-foreground/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2.5 flex items-center gap-0.5 overflow-x-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setChannelFilter(null)}
|
||||
className={cn(
|
||||
"shrink-0 rounded-md px-2 py-1 text-[11px] transition-colors",
|
||||
channelFilter === null ? "bg-foreground/[0.06] text-foreground" : "text-muted-foreground/60 hover:text-foreground/80",
|
||||
)}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{FILTERABLE_CHANNELS.map((channel) => (
|
||||
<button
|
||||
key={channel}
|
||||
type="button"
|
||||
onClick={() => setChannelFilter((prev) => (prev === channel ? null : channel))}
|
||||
title={CHANNEL_LABELS[channel]}
|
||||
className={cn(
|
||||
"shrink-0 rounded-md p-1.5 transition-colors",
|
||||
channelFilter === channel ? "bg-foreground/[0.06]" : "hover:bg-foreground/[0.03]",
|
||||
)}
|
||||
>
|
||||
<ChannelIcon channel={channel} className={cn(channelFilter === channel ? "text-foreground/80" : "text-muted-foreground/50")} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{props.incidentTripped && (
|
||||
<div className="mx-4 mb-2 flex shrink-0 items-center gap-2 rounded-lg bg-amber-500/[0.06] px-3 py-2 animate-in fade-in slide-in-from-top-1 duration-500">
|
||||
<span className="relative flex h-1.5 w-1.5 shrink-0">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-amber-500/50" />
|
||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-amber-500/80" />
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-[11px] text-amber-600/90 dark:text-amber-400/90">
|
||||
Incident — {DEMO_INCIDENT.reportCount} reports in {DEMO_INCIDENT.windowMinutes}m
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{filtered.length === 0 ? (
|
||||
<p className="px-4 py-8 text-center text-xs text-muted-foreground/60">
|
||||
No conversations match.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col divide-y divide-foreground/[0.04]">
|
||||
{filtered.map((conversation) => (
|
||||
<ConversationRow
|
||||
key={conversation.id}
|
||||
conversation={conversation}
|
||||
playback={props.playbackFor(conversation.id)}
|
||||
incidentTripped={props.incidentTripped}
|
||||
selected={conversation.id === props.selectedId}
|
||||
onSelect={() => props.onSelect(conversation.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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 (
|
||||
<div className="shrink-0 border-b border-foreground/[0.06] px-5 py-2.5 animate-in fade-in slide-in-from-top-1 duration-500">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="relative flex h-1.5 w-1.5 shrink-0">
|
||||
<span className={cn("absolute inline-flex h-full w-full rounded-full bg-amber-500/50", !props.released && "animate-ping")} />
|
||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-amber-500/80" />
|
||||
</span>
|
||||
<p className="min-w-0 flex-1 truncate text-xs text-foreground/80">
|
||||
{DEMO_INCIDENT.title} — {DEMO_INCIDENT.reportCount} reports in {DEMO_INCIDENT.windowMinutes} minutes
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
className="flex shrink-0 items-center gap-1 text-[11px] text-muted-foreground/60 transition-colors hover:text-foreground/80"
|
||||
>
|
||||
Status draft
|
||||
<CaretDownIcon className={cn("h-3 w-3 transition-transform duration-200", expanded && "rotate-180")} />
|
||||
</button>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="mt-2 rounded-lg bg-foreground/[0.02] px-3 py-2.5 animate-in fade-in duration-200">
|
||||
<p className="text-[11px] leading-relaxed text-muted-foreground">{DEMO_INCIDENT.statusDraft}</p>
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
<button type="button" className="text-[11px] font-medium text-foreground/70 transition-colors hover:text-foreground">
|
||||
Publish to status page
|
||||
</button>
|
||||
{!props.released && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onRelease}
|
||||
className="text-[11px] text-muted-foreground/60 transition-colors hover:text-foreground/80"
|
||||
>
|
||||
Release held replies
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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 <span className="text-[10px] text-muted-foreground/60">{props.children}</span>;
|
||||
}
|
||||
|
||||
export function MessageBubble(props: { message: DemoMessage, conversation: DemoConversation }) {
|
||||
const { message, conversation } = props;
|
||||
|
||||
if (message.kind === "status") {
|
||||
return (
|
||||
<div className="flex justify-center px-8 py-1 animate-in fade-in duration-500">
|
||||
<p className="max-w-md text-center text-[11px] leading-relaxed text-muted-foreground/60">
|
||||
<span className="mr-1.5 rounded bg-foreground/[0.05] px-1 py-px text-[9px] text-muted-foreground/70">Internal</span>
|
||||
{message.body}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (message.kind === "devin-video") {
|
||||
return (
|
||||
<div className="flex justify-center py-1 animate-in fade-in slide-in-from-bottom-1 duration-500">
|
||||
<div className="w-full max-w-sm rounded-xl border border-foreground/[0.07] bg-foreground/[0.02] p-3">
|
||||
<div className="flex aspect-video items-center justify-center rounded-lg bg-foreground/[0.04]">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-background/80 ring-1 ring-foreground/[0.08]">
|
||||
<PlayIcon className="ml-0.5 h-4 w-4 text-foreground/70" weight="fill" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2.5 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-foreground/90">Devin repro — Safari 26</span>
|
||||
<span className="text-[10px] tabular-nums text-muted-foreground/60">0:42</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] leading-relaxed text-muted-foreground">{message.body}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isCustomer = message.sender === "customer";
|
||||
const isAi = message.sender === "ai";
|
||||
const isAutoReply = message.kind === "auto-reply";
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-end gap-2 animate-in fade-in slide-in-from-bottom-1 duration-300", isCustomer ? "justify-start" : "justify-end")}>
|
||||
{isCustomer && <CustomerAvatar name={conversation.customer.name} hue={conversation.customer.hue} className="mb-4" />}
|
||||
<div className={cn("flex max-w-[min(34rem,85%)] flex-col gap-1", isCustomer ? "items-start" : "items-end")}>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-2xl px-3.5 py-2.5 text-sm leading-relaxed",
|
||||
isCustomer && "rounded-bl-md bg-foreground/[0.04] text-foreground",
|
||||
!isCustomer && !isAi && "rounded-br-md bg-foreground/[0.07] text-foreground",
|
||||
isAi && "rounded-br-md border border-purple-500/[0.14] bg-purple-500/[0.04] text-foreground",
|
||||
)}
|
||||
>
|
||||
{message.body}
|
||||
</div>
|
||||
<div className={cn("flex items-center gap-1.5 px-1", isCustomer ? "flex-row" : "flex-row-reverse")}>
|
||||
<SenderLabel>{message.at}</SenderLabel>
|
||||
{isAi && (
|
||||
<span className="flex items-center gap-1 text-[10px] text-purple-400/80">
|
||||
<SparkleIcon className="h-2.5 w-2.5" />
|
||||
{isAutoReply ? "AI · auto-replied at 93% confidence" : "AI"}
|
||||
</span>
|
||||
)}
|
||||
{!isCustomer && !isAi && <SenderLabel>You</SenderLabel>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TypingBubble(props: { sender: Exclude<DemoSender, "system">, conversation: DemoConversation }) {
|
||||
const isCustomer = props.sender === "customer";
|
||||
return (
|
||||
<div className={cn("flex items-end gap-2 animate-in fade-in duration-300", isCustomer ? "justify-start" : "justify-end")}>
|
||||
{isCustomer && <CustomerAvatar name={props.conversation.customer.name} hue={props.conversation.customer.hue} />}
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-2xl px-3.5 py-3",
|
||||
isCustomer ? "rounded-bl-md bg-foreground/[0.04]" : "rounded-br-md border border-purple-500/[0.14] bg-purple-500/[0.04]",
|
||||
)}
|
||||
>
|
||||
<TypingDots />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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<DemoChannel, typeof SlackLogoIcon> = {
|
||||
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 <Icon className={cn("h-3.5 w-3.5 text-muted-foreground/70", props.className)} />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={cn("flex h-7 w-7 shrink-0 select-none items-center justify-center rounded-full text-[10px] font-medium", props.className)}
|
||||
style={{
|
||||
backgroundColor: `hsl(${props.hue} 45% 50% / 0.12)`,
|
||||
color: `hsl(${props.hue} 40% 45%)`,
|
||||
}}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TypingDots(props: { className?: string }) {
|
||||
return (
|
||||
<span className={cn("inline-flex items-center gap-1", props.className)}>
|
||||
<span className="h-1 w-1 animate-pulse rounded-full bg-muted-foreground/60" />
|
||||
<span className="h-1 w-1 animate-pulse rounded-full bg-muted-foreground/60" style={{ animationDelay: "150ms" }} />
|
||||
<span className="h-1 w-1 animate-pulse rounded-full bg-muted-foreground/60" style={{ animationDelay: "300ms" }} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function PaneHeading(props: { children: React.ReactNode, className?: string }) {
|
||||
return (
|
||||
<div className={cn("text-[11px] font-medium text-muted-foreground/70", props.className)}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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 (
|
||||
<div className="flex items-center justify-center gap-1.5 py-2">
|
||||
<SparkleIcon className="h-3 w-3 text-purple-400/60" />
|
||||
<span className="text-[11px] text-muted-foreground/60">
|
||||
AI standing by — this reads as a question for a human
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="shrink-0 px-5 pb-4">
|
||||
<div className="flex items-center justify-between rounded-xl bg-foreground/[0.02] px-4 py-3">
|
||||
<span className="text-xs text-muted-foreground/70">
|
||||
Reply held — this conversation gets the canonical incident answer
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={demo.releaseHeldReplies}
|
||||
className="shrink-0 text-xs font-medium text-foreground/70 transition-colors hover:text-foreground"
|
||||
>
|
||||
Release
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shrink-0 px-5 pb-4">
|
||||
{draftIsFromAi && (
|
||||
<div className="mb-1.5 flex items-center gap-1.5 px-1">
|
||||
<SparkleIcon className="h-3 w-3 text-purple-400/70" />
|
||||
<span className="text-[11px] text-muted-foreground/60">Draft prepared by AI — edit freely before sending</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col rounded-xl border border-foreground/[0.07] bg-background focus-within:border-foreground/[0.14] transition-colors">
|
||||
<textarea
|
||||
value={playback.draft}
|
||||
onChange={(event) => demo.setDraft(conversation.id, event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
placeholder={`Reply on ${CHANNEL_LABELS[conversation.channel]}${conversation.devinSuggested ? " — tag @Devin to reproduce" : ""}`}
|
||||
rows={playback.draft === "" ? 1 : 3}
|
||||
className="max-h-40 w-full resize-none bg-transparent px-3.5 py-2.5 text-sm leading-relaxed text-foreground outline-none placeholder:text-muted-foreground/50"
|
||||
/>
|
||||
<div className="flex items-center justify-between px-2.5 pb-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{conversation.devinSuggested && playback.devinStage === "idle" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => demo.tagDevin(conversation.id)}
|
||||
className="rounded-md px-2 py-1 text-[11px] text-muted-foreground/60 transition-colors hover:bg-foreground/[0.04] hover:text-foreground/80"
|
||||
>
|
||||
@Devin reproduce
|
||||
</button>
|
||||
)}
|
||||
{playback.devinStage === "working" && (
|
||||
<span className="flex items-center gap-1.5 px-2 py-1 text-[11px] text-muted-foreground/60">
|
||||
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-foreground/40" />
|
||||
Devin is reproducing
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSend}
|
||||
disabled={playback.draft.trim() === ""}
|
||||
className={cn(
|
||||
"flex h-7 items-center gap-1.5 rounded-lg px-3 text-xs font-medium transition-colors",
|
||||
playback.draft.trim() === ""
|
||||
? "text-muted-foreground/40"
|
||||
: "bg-foreground text-background hover:bg-foreground/90",
|
||||
)}
|
||||
>
|
||||
<PaperPlaneRightIcon className="h-3 w-3" />
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ThreadPane(props: {
|
||||
conversation: DemoConversation,
|
||||
playback: ConversationPlaybackState,
|
||||
demo: DemoPlayback,
|
||||
headerExtra?: React.ReactNode,
|
||||
}) {
|
||||
const { conversation, playback, demo } = props;
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const held = demo.incidentTripped && DEMO_INCIDENT.heldConversationIds.includes(conversation.id);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
||||
}, [playback.messages.length, playback.typing]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex shrink-0 items-center gap-3 border-b border-foreground/[0.06] px-5 py-3.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="truncate text-sm font-semibold tracking-tight text-foreground">{conversation.subject}</h1>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground/70">
|
||||
{conversation.customer.name} · {conversation.customer.company} · {CHANNEL_LABELS[conversation.channel]}
|
||||
</p>
|
||||
</div>
|
||||
<ConfidenceMeter value={playback.confidence} className="shrink-0" />
|
||||
{conversation.script && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => demo.replayScript(conversation.id)}
|
||||
title="Replay this demo conversation"
|
||||
className="shrink-0 rounded-md p-1.5 text-muted-foreground/50 transition-colors hover:bg-foreground/[0.04] hover:text-foreground/80"
|
||||
>
|
||||
<ArrowCounterClockwiseIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{props.headerExtra}
|
||||
</div>
|
||||
|
||||
{held && <IncidentBanner released={playback.repliesReleased} onRelease={demo.releaseHeldReplies} />}
|
||||
|
||||
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||||
<div className="mx-auto flex max-w-2xl flex-col gap-3">
|
||||
{playback.messages.map((message) => (
|
||||
<MessageBubble key={message.id} message={message} conversation={conversation} />
|
||||
))}
|
||||
{playback.typing && playback.typing !== "system" && (
|
||||
<TypingBubble sender={playback.typing} conversation={conversation} />
|
||||
)}
|
||||
<AiStateLine conversation={conversation} playback={playback} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Composer conversation={conversation} playback={playback} demo={demo} held={held} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,591 @@
|
||||
export type DemoChannel = "slack" | "whatsapp" | "imessage" | "telegram" | "discord" | "email" | "web" | "sms";
|
||||
export type DemoSender = "customer" | "agent" | "ai" | "system";
|
||||
export type DemoMessageKind = "text" | "auto-reply" | "status" | "devin-video";
|
||||
export type DemoPriority = "urgent" | "high" | "normal";
|
||||
export type DemoAiState = "intake" | "standing-by" | "auto-replied" | "held-for-incident" | "resolved";
|
||||
export type DossierField = "identity" | "plan" | "authEvents" | "replay" | "pastTickets";
|
||||
|
||||
export type DemoMessage = {
|
||||
id: string,
|
||||
sender: DemoSender,
|
||||
kind: DemoMessageKind,
|
||||
body: string,
|
||||
at: string,
|
||||
};
|
||||
|
||||
export type DemoTimelineEntry = {
|
||||
id: string,
|
||||
kind: "event" | "span" | "message",
|
||||
label: string,
|
||||
at: string,
|
||||
detail?: string,
|
||||
tone: "ok" | "warn" | "error" | "neutral",
|
||||
};
|
||||
|
||||
export type DemoDossier = {
|
||||
userId: string,
|
||||
email: string,
|
||||
plan: string,
|
||||
signedUpAgo: string,
|
||||
authEvents: string[],
|
||||
replay: { id: string, label: string, duration: string } | null,
|
||||
pastTickets: { subject: string, resolvedAgo: string }[],
|
||||
};
|
||||
|
||||
export type PlaybackStep =
|
||||
| { kind: "wait", ms: number }
|
||||
| { kind: "typing", sender: DemoSender }
|
||||
| { kind: "message", message: DemoMessage }
|
||||
| { kind: "confidence", to: number }
|
||||
| { kind: "dossier", field: DossierField }
|
||||
| { kind: "draft", text: string }
|
||||
| { kind: "incident-trip" };
|
||||
|
||||
export type CopilotToolCall = {
|
||||
toolName: string,
|
||||
args: Record<string, string | number | boolean>,
|
||||
result: unknown,
|
||||
};
|
||||
|
||||
export type CopilotTurn = {
|
||||
toolCalls?: CopilotToolCall[],
|
||||
text: string,
|
||||
};
|
||||
|
||||
/**
|
||||
* A write-action the agent can execute (refund, re-run sync, plan change,
|
||||
* status-page publish, ...). Triggered when the operator's message contains
|
||||
* one of the keywords; `threadEffect` posts a status line into the customer
|
||||
* thread so the mutation is visible outside the copilot.
|
||||
*/
|
||||
export type CopilotAction = {
|
||||
triggers: string[],
|
||||
/** Shown on the pending-approval card; the mutation only runs after the operator approves. */
|
||||
approval: { title: string, summary: string },
|
||||
turn: CopilotTurn,
|
||||
threadEffect?: string,
|
||||
};
|
||||
|
||||
export type CopilotScript = {
|
||||
initial: CopilotTurn,
|
||||
responses: CopilotTurn[],
|
||||
actions?: CopilotAction[],
|
||||
fallback: string,
|
||||
};
|
||||
|
||||
export type DemoConversation = {
|
||||
id: string,
|
||||
channel: DemoChannel,
|
||||
customer: { name: string, company: string, hue: number },
|
||||
subject: string,
|
||||
preview: string,
|
||||
minutesAgo: number,
|
||||
unread: boolean,
|
||||
priority: DemoPriority,
|
||||
aiState: DemoAiState,
|
||||
confidence: number,
|
||||
clusterId?: string,
|
||||
/** Messages visible before any script runs. Scripted conversations start (nearly) empty. */
|
||||
seedMessages: DemoMessage[],
|
||||
script?: PlaybackStep[],
|
||||
/** Pre-filled reply draft for non-scripted conversations. */
|
||||
initialDraft?: string,
|
||||
dossier: DemoDossier,
|
||||
timeline: DemoTimelineEntry[],
|
||||
copilot: CopilotScript,
|
||||
docsSuggestion?: { title: string, reason: string, removed: string[], added: string[] },
|
||||
devinSuggested?: boolean,
|
||||
};
|
||||
|
||||
export type DemoIncident = {
|
||||
id: string,
|
||||
title: string,
|
||||
reportCount: number,
|
||||
windowMinutes: number,
|
||||
clusterId: string,
|
||||
statusDraft: string,
|
||||
heldConversationIds: string[],
|
||||
};
|
||||
|
||||
export type DemoCluster = {
|
||||
id: string,
|
||||
label: string,
|
||||
conversationIds: string[],
|
||||
};
|
||||
|
||||
export const CHANNEL_LABELS: Record<DemoChannel, string> = {
|
||||
slack: "Slack",
|
||||
whatsapp: "WhatsApp",
|
||||
imessage: "iMessage",
|
||||
telegram: "Telegram",
|
||||
discord: "Discord",
|
||||
email: "Email",
|
||||
web: "Web chat",
|
||||
sms: "SMS",
|
||||
};
|
||||
|
||||
// Customer-facing copy never exposes internals (webhooks, sync jobs, tools).
|
||||
// The root cause lives in the copilot pane and dossier, for the team only.
|
||||
const PAYMENTS_INTAKE_DRAFT = "Hi Maya — thanks for your patience, and sorry for the confusion. We found the issue on our side and fixed it; your workspace should show Pro within a minute. Your payment went through correctly and there's nothing you need to do. Let us know if anything still looks off!";
|
||||
|
||||
export const DEMO_CONVERSATIONS: DemoConversation[] = [
|
||||
{
|
||||
id: "conv-payments",
|
||||
channel: "slack",
|
||||
customer: { name: "Maya Chen", company: "Northstar Labs", hue: 262 },
|
||||
subject: "Payment succeeded but seat not upgraded",
|
||||
preview: "We paid for the Pro upgrade but the account still shows starter.",
|
||||
minutesAgo: 3,
|
||||
unread: true,
|
||||
priority: "urgent",
|
||||
aiState: "intake",
|
||||
confidence: 74,
|
||||
clusterId: "billing-upgrade",
|
||||
seedMessages: [],
|
||||
script: [
|
||||
{ kind: "wait", ms: 600 },
|
||||
{ kind: "message", message: { id: "pm1", sender: "customer", kind: "text", body: "We paid for the Pro upgrade but the account still shows starter. Checkout said it succeeded.", at: "10:41" } },
|
||||
{ kind: "wait", ms: 900 },
|
||||
{ kind: "typing", sender: "ai" },
|
||||
{ kind: "wait", ms: 1600 },
|
||||
{ kind: "message", message: { id: "pm2", sender: "ai", kind: "text", body: "Sorry about that — let me gather the details so the team can fix this quickly. Could you share the transaction id from your receipt, and the workspace slug?", at: "10:41" } },
|
||||
{ kind: "confidence", to: 22 },
|
||||
{ kind: "wait", ms: 2200 },
|
||||
{ kind: "typing", sender: "customer" },
|
||||
{ kind: "wait", ms: 1800 },
|
||||
{ kind: "message", message: { id: "pm3", sender: "customer", kind: "text", body: "txn_9bc1f2 — workspace is northstar-prod.", at: "10:42" } },
|
||||
{ kind: "wait", ms: 700 },
|
||||
{ kind: "dossier", field: "identity" },
|
||||
{ kind: "typing", sender: "ai" },
|
||||
{ kind: "wait", ms: 1000 },
|
||||
{ kind: "dossier", field: "plan" },
|
||||
{ kind: "confidence", to: 51 },
|
||||
{ kind: "wait", ms: 1000 },
|
||||
{ kind: "dossier", field: "authEvents" },
|
||||
{ kind: "wait", ms: 900 },
|
||||
{ kind: "message", message: { id: "pm4", sender: "ai", kind: "text", body: "Thanks, that's everything I need. Your payment did go through, so no need to worry there. I've passed this to the team with everything they need and someone will follow up with you shortly.", at: "10:43" } },
|
||||
{ kind: "dossier", field: "replay" },
|
||||
{ kind: "dossier", field: "pastTickets" },
|
||||
{ kind: "confidence", to: 74 },
|
||||
{ kind: "wait", ms: 500 },
|
||||
{ kind: "message", message: { id: "pm5", sender: "system", kind: "status", body: "AI handed off to the team with full context — confidence 74%, below the 90% auto-reply threshold", at: "10:43" } },
|
||||
{ kind: "draft", text: PAYMENTS_INTAKE_DRAFT },
|
||||
],
|
||||
dossier: {
|
||||
userId: "user_8YgQm",
|
||||
email: "maya@northstarlabs.dev",
|
||||
plan: "Starter (Pro upgrade pending)",
|
||||
signedUpAgo: "14 months ago",
|
||||
authEvents: ["Signed in via Google · 10:36", "checkout.completed · 10:38", "stripe.webhook.retry ×3 · 10:39", "entitlement.sync failed (502) · 10:39"],
|
||||
replay: { id: "rpl_3ka92", label: "Checkout session, rage-clicks on plan badge", duration: "5m 44s" },
|
||||
pastTickets: [{ subject: "Invite emails going to spam", resolvedAgo: "3 months ago" }],
|
||||
},
|
||||
timeline: [
|
||||
{ id: "pt1", kind: "event", label: "checkout.completed", at: "10:38", detail: "plan=pro seats=12", tone: "ok" },
|
||||
{ id: "pt2", kind: "span", label: "POST /payments/checkout", at: "10:38", detail: "214ms", tone: "ok" },
|
||||
{ id: "pt3", kind: "span", label: "stripe.webhook.apply", at: "10:39", detail: "3.8s · retried ×3", tone: "warn" },
|
||||
{ id: "pt4", kind: "span", label: "entitlements.sync", at: "10:39", detail: "502 Bad Gateway", tone: "error" },
|
||||
{ id: "pt5", kind: "event", label: "plan badge rage-clicks", at: "10:40", detail: "from session replay", tone: "warn" },
|
||||
{ id: "pt6", kind: "message", label: "Reached out on Slack", at: "10:41", tone: "neutral" },
|
||||
],
|
||||
copilot: {
|
||||
initial: {
|
||||
toolCalls: [
|
||||
{
|
||||
toolName: "sql-query",
|
||||
args: { query: "SELECT status, amount, webhook_state FROM payments WHERE transaction_id = 'txn_9bc1f2'" },
|
||||
result: { success: true, rowCount: 1, result: [{ status: "succeeded", amount: "$1,188.00", webhook_state: "retrying", entitlement_sync: "failed_502" }] },
|
||||
},
|
||||
{
|
||||
toolName: "find-replay",
|
||||
args: { userId: "user_8YgQm", around: "checkout.completed" },
|
||||
result: { success: true, rowCount: 1, result: [{ replayId: "rpl_3ka92", window: "10:37–10:43", signal: "rage-clicks on plan badge after checkout" }] },
|
||||
},
|
||||
{
|
||||
toolName: "read-docs",
|
||||
args: { query: "entitlement sync failure after webhook retry" },
|
||||
result: { success: true, rowCount: 2, result: [{ page: "Billing / Webhooks", section: "Retry semantics" }, { page: "Runbooks / Entitlements", section: "Manual re-sync" }] },
|
||||
},
|
||||
],
|
||||
text: "The payment is fine — `txn_9bc1f2` charged $1,188 successfully. The failure is downstream: Stripe's webhook retried 3 times and the entitlement sync 502'd, so the plan never flipped to Pro. The replay shows Maya rage-clicking the plan badge right after checkout.\n\nA manual re-sync from the entitlements runbook fixes her account immediately — tell me to \"re-run the sync\" and I'll do it. A customer-safe reply (no internals) is drafted in the composer; Maya was only told that her payment is fine and the team is on it.",
|
||||
},
|
||||
responses: [
|
||||
{
|
||||
toolCalls: [
|
||||
{
|
||||
toolName: "sql-query",
|
||||
args: { query: "SELECT count(*) FROM payments WHERE webhook_state = 'retrying' AND created_at > now() - interval '24 hours'" },
|
||||
result: { success: true, rowCount: 1, result: [{ count: 7 }] },
|
||||
},
|
||||
],
|
||||
text: "7 other payments hit the same retrying state in the last 24h — this is systemic, not just Maya. Worth flagging to the billing team; I can group these into one cluster and hold a canonical reply if more reports come in.",
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
triggers: ["resync", "re-sync", "re-run", "rerun", "fix it", "fix the", "apply the upgrade"],
|
||||
approval: {
|
||||
title: "Re-run entitlement sync",
|
||||
summary: "Workspace northstar-prod · apply the paid Pro upgrade (12 seats) from txn_9bc1f2",
|
||||
},
|
||||
turn: {
|
||||
toolCalls: [
|
||||
{
|
||||
toolName: "entitlements.resync",
|
||||
args: { workspace: "northstar-prod", transactionId: "txn_9bc1f2", dryRun: false },
|
||||
result: { success: true, rowCount: 1, result: [{ workspace: "northstar-prod", plan: "pro", seats: 12, synced_at: "just now" }] },
|
||||
},
|
||||
],
|
||||
text: "Done — I re-ran the entitlement sync for `northstar-prod`. The workspace is on Pro with 12 seats now, and I've posted a note into the thread so whoever replies knows the account is already fixed.",
|
||||
},
|
||||
threadEffect: "Agent action — entitlement sync re-run by AI (approved by you) · northstar-prod is now on Pro",
|
||||
},
|
||||
{
|
||||
triggers: ["refund"],
|
||||
approval: {
|
||||
title: "Issue full refund",
|
||||
summary: "$1,188.00 back to the card on txn_9bc1f2 · reason: upgrade not applied",
|
||||
},
|
||||
turn: {
|
||||
toolCalls: [
|
||||
{
|
||||
toolName: "payments.refund",
|
||||
args: { transactionId: "txn_9bc1f2", amount: "$1,188.00", reason: "upgrade-not-applied" },
|
||||
result: { success: true, rowCount: 1, result: [{ refundId: "re_7Hd2k", status: "succeeded", amount: "$1,188.00" }] },
|
||||
},
|
||||
],
|
||||
text: "Refund issued — $1,188.00 back to the card on `txn_9bc1f2` (refund `re_7Hd2k`). If you'd rather keep the charge and just apply the upgrade, ask me to re-run the sync instead; refunds and fixes are both one action from here.",
|
||||
},
|
||||
threadEffect: "Agent action — full refund of $1,188.00 issued by AI (approved by you) · re_7Hd2k",
|
||||
},
|
||||
],
|
||||
fallback: "This demo answers from Maya's seeded context — try asking about the failed upgrade, or tell me to \"re-run the sync\" or \"issue a refund\" to see agent actions.",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "conv-passkey",
|
||||
channel: "whatsapp",
|
||||
customer: { name: "Elena Rivera", company: "Orbit Desk", hue: 152 },
|
||||
subject: "Passkey rollout for admins first?",
|
||||
preview: "Can we turn on passkeys for admins before customers?",
|
||||
minutesAgo: 12,
|
||||
unread: true,
|
||||
priority: "normal",
|
||||
aiState: "standing-by",
|
||||
confidence: 38,
|
||||
seedMessages: [
|
||||
{ id: "pk1", sender: "customer", kind: "text", body: "Can we turn on passkeys for admins before rolling them out to customers? We want to trial internally first.", at: "10:30" },
|
||||
{ id: "pk2", sender: "system", kind: "status", body: "AI classified this as a rollout-strategy question and is standing by for a human — confidence 38%", at: "10:30" },
|
||||
],
|
||||
initialDraft: "Hi Elena — yes, that's a common pattern. You can scope passkeys to a team via a sign-up rule so only your admin team sees the option, trial it, then remove the rule to open it up. Happy to walk you through it on a call if useful.",
|
||||
dossier: {
|
||||
userId: "user_J4p2A",
|
||||
email: "elena@orbitdesk.io",
|
||||
plan: "Growth",
|
||||
signedUpAgo: "7 months ago",
|
||||
authEvents: ["Searched docs for \"passkeys\" · 10:24", "Opened Auth Methods page · 10:26", "Signed in via email OTP · 10:22"],
|
||||
replay: null,
|
||||
pastTickets: [
|
||||
{ subject: "SSO domain verification", resolvedAgo: "2 months ago" },
|
||||
{ subject: "Webhook signature mismatch", resolvedAgo: "5 months ago" },
|
||||
],
|
||||
},
|
||||
timeline: [
|
||||
{ id: "kt1", kind: "event", label: "docs.search \"passkeys\"", at: "10:24", tone: "neutral" },
|
||||
{ id: "kt2", kind: "span", label: "GET /auth-methods", at: "10:26", detail: "96ms", tone: "ok" },
|
||||
{ id: "kt3", kind: "event", label: "auth_methods.page.opened", at: "10:26", tone: "neutral" },
|
||||
{ id: "kt4", kind: "message", label: "Asked on WhatsApp", at: "10:30", tone: "neutral" },
|
||||
],
|
||||
copilot: {
|
||||
initial: {
|
||||
toolCalls: [
|
||||
{
|
||||
toolName: "read-docs",
|
||||
args: { query: "staged passkey rollout by team" },
|
||||
result: { success: true, rowCount: 2, result: [{ page: "Auth Methods / Passkeys", section: "Gradual rollout" }, { page: "Sign-up Rules", section: "Scoping by team" }] },
|
||||
},
|
||||
],
|
||||
text: "This is a how-to question, not a bug — the AI held back so a human answers rollout strategy. The docs support her plan: passkeys can be scoped to a team via a sign-up rule, trialed by admins, then opened up. She's technical (set up SSO herself 2 months ago), so a concise pointer will land well. Draft is in the composer.",
|
||||
},
|
||||
responses: [],
|
||||
fallback: "This demo answers from Elena's seeded context — try asking why the AI didn't reply, or what to tell her about staged rollouts.",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "conv-magiclink",
|
||||
channel: "email",
|
||||
customer: { name: "Jon Bell", company: "Beacon Forms", hue: 24 },
|
||||
subject: "Magic link emails are slow",
|
||||
preview: "Users are waiting 5–10 minutes for magic links.",
|
||||
minutesAgo: 18,
|
||||
unread: true,
|
||||
priority: "high",
|
||||
aiState: "auto-replied",
|
||||
confidence: 93,
|
||||
clusterId: "email-latency",
|
||||
seedMessages: [],
|
||||
script: [
|
||||
{ kind: "wait", ms: 500 },
|
||||
{ kind: "message", message: { id: "ml1", sender: "customer", kind: "text", body: "Our users are waiting 5–10 minutes for magic link emails this morning. Nothing changed on our side.", at: "10:17" } },
|
||||
{ kind: "wait", ms: 900 },
|
||||
{ kind: "typing", sender: "ai" },
|
||||
{ kind: "confidence", to: 46 },
|
||||
{ kind: "wait", ms: 1400 },
|
||||
{ kind: "confidence", to: 78 },
|
||||
{ kind: "wait", ms: 1200 },
|
||||
{ kind: "confidence", to: 93 },
|
||||
{ kind: "wait", ms: 600 },
|
||||
{ kind: "message", message: { id: "ml2", sender: "ai", kind: "auto-reply", body: "Hi Jon — this is a known delay with our email provider's US-East delivery starting around 10:05. Your integration is healthy; links are queued, not lost, and deliver within ~8 minutes. We're rerouting through a secondary provider now and expect normal latency within the hour. Status: hexclave.statuspage.io/incidents/4821", at: "10:18" } },
|
||||
{ kind: "wait", ms: 800 },
|
||||
{ kind: "incident-trip" },
|
||||
{ kind: "message", message: { id: "ml3", sender: "system", kind: "status", body: "8 similar reports in the last 18 minutes — incident opened, replies to this cluster are held for one canonical answer", at: "10:19" } },
|
||||
],
|
||||
dossier: {
|
||||
userId: "user_2SmV9",
|
||||
email: "jon@beaconforms.com",
|
||||
plan: "Enterprise",
|
||||
signedUpAgo: "2 years ago",
|
||||
authEvents: ["magic_link.requested ×41 · since 10:05", "email.delivered p95 7m 12s · last hour", "Provider delivery degraded · 10:05"],
|
||||
replay: { id: "rpl_8mm41", label: "Repeated resend clicks on sign-in screen", duration: "2m 10s" },
|
||||
pastTickets: [],
|
||||
},
|
||||
timeline: [
|
||||
{ id: "mt1", kind: "event", label: "magic_link.requested ×41", at: "10:05+", detail: "normal volume", tone: "neutral" },
|
||||
{ id: "mt2", kind: "span", label: "POST /auth/send-magic-link", at: "10:06", detail: "188ms", tone: "ok" },
|
||||
{ id: "mt3", kind: "span", label: "email.outbox.enqueue", at: "10:06", detail: "44ms", tone: "ok" },
|
||||
{ id: "mt4", kind: "span", label: "provider.delivery", at: "10:06", detail: "7m 12s p95", tone: "error" },
|
||||
{ id: "mt5", kind: "event", label: "resend clicked ×6", at: "10:12", detail: "from session replay", tone: "warn" },
|
||||
{ id: "mt6", kind: "message", label: "Wrote in via email", at: "10:17", tone: "neutral" },
|
||||
],
|
||||
copilot: {
|
||||
initial: {
|
||||
toolCalls: [
|
||||
{
|
||||
toolName: "sql-query",
|
||||
args: { query: "SELECT percentile(delivery_seconds, 0.95) FROM email_deliveries WHERE sent_at > now() - interval '1 hour'" },
|
||||
result: { success: true, rowCount: 1, result: [{ p95_delivery: "7m 12s", baseline: "9s", affected_projects: 31 }] },
|
||||
},
|
||||
{
|
||||
toolName: "find-replay",
|
||||
args: { userId: "user_2SmV9", around: "magic_link.requested" },
|
||||
result: { success: true, rowCount: 1, result: [{ replayId: "rpl_8mm41", window: "10:10–10:12", signal: "6 resend clicks in 2 minutes" }] },
|
||||
},
|
||||
],
|
||||
text: "Provider-side incident, not Jon's integration: p95 email delivery is 7m 12s against a 9s baseline, across 31 projects. That's why confidence cleared 90% and the AI auto-replied with the canonical incident answer.\n\n8 similar reports arrived within 18 minutes, so an incident is open and further replies in this cluster are held — one canonical answer instead of nine hand-written ones.",
|
||||
},
|
||||
responses: [
|
||||
{
|
||||
text: "Once the provider reroute lands, I'd release the held replies with a short all-clear and close the incident. Everyone in the cluster gets the same resolution message with their own delivery stats attached.",
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
triggers: ["publish", "status page", "statuspage"],
|
||||
approval: {
|
||||
title: "Publish status-page incident",
|
||||
summary: "Incident 4821 · \"Delayed magic-link and OTP email delivery\" goes public with the drafted copy",
|
||||
},
|
||||
turn: {
|
||||
toolCalls: [
|
||||
{
|
||||
toolName: "statuspage.publish",
|
||||
args: { incidentId: "inc-4821", state: "investigating" },
|
||||
result: { success: true, rowCount: 1, result: [{ incident: "inc-4821", published: true, url: "hexclave.statuspage.io/incidents/4821" }] },
|
||||
},
|
||||
],
|
||||
text: "Published — the incident is live on the status page with the drafted copy. The 8 held conversations now reference it automatically; I'll draft the all-clear once provider latency recovers.",
|
||||
},
|
||||
threadEffect: "Agent action — status page incident 4821 published by AI (approved by you)",
|
||||
},
|
||||
],
|
||||
fallback: "This demo answers from the incident context — try asking about the other affected reports, or tell me to \"publish the status page\".",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "conv-saml",
|
||||
channel: "web",
|
||||
customer: { name: "Priya Shah", company: "KiteCloud", hue: 205 },
|
||||
subject: "SAML metadata step is unclear",
|
||||
preview: "Do we paste the entity id or the metadata URL?",
|
||||
minutesAgo: 25,
|
||||
unread: false,
|
||||
priority: "normal",
|
||||
aiState: "resolved",
|
||||
confidence: 62,
|
||||
seedMessages: [
|
||||
{ id: "sm1", sender: "customer", kind: "text", body: "The SAML setup docs lost me at step 3 — do we paste the entity id or the metadata URL? The field just says \"identifier\".", at: "10:04" },
|
||||
{ id: "sm2", sender: "ai", kind: "text", body: "Good catch — that field takes the metadata URL, and your IdP's entity id is read from it automatically. I've also flagged the docs page for clarification.", at: "10:05" },
|
||||
{ id: "sm3", sender: "customer", kind: "text", body: "That worked, thank you! The docs really should say that.", at: "10:09" },
|
||||
],
|
||||
initialDraft: "Glad that unblocked you, Priya! We're updating the docs today so the field name and the guide match — thanks for flagging it.",
|
||||
dossier: {
|
||||
userId: "user_7MzL1",
|
||||
email: "priya@kitecloud.io",
|
||||
plan: "Business",
|
||||
signedUpAgo: "4 months ago",
|
||||
authEvents: ["docs.search \"saml metadata\" · 09:58", "SAML config PATCH failed (400) ×2 · 10:01", "SAML config saved · 10:07"],
|
||||
replay: { id: "rpl_5xq77", label: "SAML setup page and docs tab side by side", duration: "9m 02s" },
|
||||
pastTickets: [],
|
||||
},
|
||||
timeline: [
|
||||
{ id: "st1", kind: "event", label: "docs.search \"saml metadata\"", at: "09:58", tone: "neutral" },
|
||||
{ id: "st2", kind: "span", label: "PATCH /oauth-providers/saml", at: "10:01", detail: "400 · invalid identifier", tone: "error" },
|
||||
{ id: "st3", kind: "message", label: "Asked in web chat", at: "10:04", tone: "neutral" },
|
||||
{ id: "st4", kind: "span", label: "PATCH /oauth-providers/saml", at: "10:07", detail: "200", tone: "ok" },
|
||||
{ id: "st5", kind: "event", label: "saml.connection.verified", at: "10:08", tone: "ok" },
|
||||
],
|
||||
copilot: {
|
||||
initial: {
|
||||
toolCalls: [
|
||||
{
|
||||
toolName: "sql-query",
|
||||
args: { query: "SELECT count(*) FROM support_conversations WHERE topic = 'saml-metadata-confusion' AND created_at > now() - interval '7 days'" },
|
||||
result: { success: true, rowCount: 1, result: [{ count: 3, distinct_companies: 3 }] },
|
||||
},
|
||||
{
|
||||
toolName: "read-docs",
|
||||
args: { query: "SAML setup step 3 identifier" },
|
||||
result: { success: true, rowCount: 1, result: [{ page: "SSO / SAML", section: "Step 3 — Identity provider details", issue: "field labeled 'identifier', guide says 'entity id', product expects metadata URL" }] },
|
||||
},
|
||||
],
|
||||
text: "Resolved, but the root cause is the docs: three separate companies hit the same confusion this week. The guide says \"entity id\", the field says \"identifier\", and the product actually wants the metadata URL. I've drafted a docs edit below — one sentence and a field-name alignment would eliminate this ticket category.",
|
||||
},
|
||||
responses: [],
|
||||
fallback: "This demo answers from Priya's seeded context — try asking about the suggested docs update.",
|
||||
},
|
||||
docsSuggestion: {
|
||||
title: "SSO / SAML — Step 3, identity provider details",
|
||||
reason: "3 conversations this week hit the same confusion; the field label, guide text, and expected value disagree.",
|
||||
removed: ["3. Paste your identity provider's entity id into the identifier field."],
|
||||
added: ["3. Paste your identity provider's metadata URL into the identifier field.", " Hexclave reads the entity id from the metadata automatically — you never need to enter it by hand."],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "conv-devin",
|
||||
channel: "slack",
|
||||
customer: { name: "Marcus Webb", company: "TailForge", hue: 340 },
|
||||
subject: "Sign-in button dead on Safari 26",
|
||||
preview: "Clicking sign-in does nothing on Safari 26. Console shows a TypeError.",
|
||||
minutesAgo: 34,
|
||||
unread: false,
|
||||
priority: "high",
|
||||
aiState: "intake",
|
||||
confidence: 55,
|
||||
seedMessages: [
|
||||
{ id: "dv1", sender: "customer", kind: "text", body: "Clicking the sign-in button does nothing on Safari 26. Console shows `TypeError: navigator.credentials.get is not a function`. Works in Chrome.", at: "09:48" },
|
||||
{ id: "dv2", sender: "ai", kind: "text", body: "Thanks for the exact error — that points at the passkey conditional-UI path. Which SDK version are you on, and is this the hosted sign-in page or your own?", at: "09:49" },
|
||||
{ id: "dv3", sender: "customer", kind: "text", body: "@hexclave/next 2.8.1, our own page with the SignIn component.", at: "09:52" },
|
||||
{ id: "dv4", sender: "system", kind: "status", body: "AI gathered repro details — needs a hands-on reproduction to confirm the fix. Tag @Devin to reproduce and record it.", at: "09:53" },
|
||||
],
|
||||
initialDraft: "Hi Marcus — confirmed: 2.8.1 calls the passkey conditional UI without feature-detecting `navigator.credentials`, which Safari 26 dropped behind a flag. Upgrading to 2.8.3 fixes it; if you're pinned, wrapping SignIn with `passkeys={false}` is a safe stopgap.",
|
||||
devinSuggested: true,
|
||||
dossier: {
|
||||
userId: "user_5TrX8",
|
||||
email: "marcus@tailforge.dev",
|
||||
plan: "Growth",
|
||||
signedUpAgo: "11 months ago",
|
||||
authEvents: ["sign_in.attempt ×9, 0 completions (Safari) · 09:40–09:47", "SDK @hexclave/next 2.8.1 · from client hello", "sign_in.completed (Chrome) · 09:55"],
|
||||
replay: { id: "rpl_9wd12", label: "Sign-in clicks with no navigation, Safari 26", duration: "1m 38s" },
|
||||
pastTickets: [{ subject: "CORS on custom domain", resolvedAgo: "6 months ago" }],
|
||||
},
|
||||
timeline: [
|
||||
{ id: "dt1", kind: "event", label: "sign_in.attempt ×9, 0 completions", at: "09:40+", detail: "Safari 26 only", tone: "error" },
|
||||
{ id: "dt2", kind: "span", label: "GET /api/latest/auth/passkey/options", at: "09:41", detail: "never called — throws client-side", tone: "warn" },
|
||||
{ id: "dt3", kind: "message", label: "Reported on Slack", at: "09:48", tone: "neutral" },
|
||||
{ id: "dt4", kind: "event", label: "sign_in.completed (Chrome fallback)", at: "09:55", tone: "ok" },
|
||||
],
|
||||
copilot: {
|
||||
initial: {
|
||||
toolCalls: [
|
||||
{
|
||||
toolName: "sql-query",
|
||||
args: { query: "SELECT browser, count(*) FROM sign_in_attempts WHERE project = 'tailforge' AND completed = false GROUP BY browser" },
|
||||
result: { success: true, rowCount: 2, result: [{ browser: "Safari 26", failed: 9 }, { browser: "Chrome 138", failed: 0 }] },
|
||||
},
|
||||
{
|
||||
toolName: "find-replay",
|
||||
args: { userId: "user_5TrX8", around: "sign_in.attempt" },
|
||||
result: { success: true, rowCount: 1, result: [{ replayId: "rpl_9wd12", window: "09:40–09:42", signal: "button clicks, no navigation, console TypeError" }] },
|
||||
},
|
||||
],
|
||||
text: "Classic Safari 26 regression: SDK 2.8.1's passkey conditional UI calls `navigator.credentials.get` without feature detection, and Safari 26 moved it behind a flag. All 9 failed attempts are Safari; Chrome is clean.\n\nThe fix shipped in 2.8.3. To confirm and get a repro on record, tag @Devin in the composer — it will reproduce on Safari 26 and record a demo video for the ticket.",
|
||||
},
|
||||
responses: [
|
||||
{
|
||||
text: "Devin's repro confirms it: same TypeError on a clean Safari 26 with SDK 2.8.1, gone on 2.8.3. The video is attached to the thread — safe to send the drafted reply with the upgrade guidance.",
|
||||
},
|
||||
],
|
||||
fallback: "This demo answers from Marcus's seeded context — try asking about the Safari failure or tag @Devin in the reply box.",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "conv-closed",
|
||||
channel: "imessage",
|
||||
customer: { name: "Aiko Tanaka", company: "Lumen Analytics", hue: 95 },
|
||||
subject: "OAuth redirect loops on staging",
|
||||
preview: "Resolved — trusted domain was missing the staging URL.",
|
||||
minutesAgo: 190,
|
||||
unread: false,
|
||||
priority: "normal",
|
||||
aiState: "resolved",
|
||||
confidence: 88,
|
||||
seedMessages: [
|
||||
{ id: "cl1", sender: "customer", kind: "text", body: "Our staging env redirect-loops after Google sign-in. Production is fine.", at: "07:12" },
|
||||
{ id: "cl2", sender: "ai", kind: "text", body: "Can you share the staging URL? Loops like this are usually a missing trusted domain.", at: "07:13" },
|
||||
{ id: "cl3", sender: "customer", kind: "text", body: "staging.lumen-analytics.app", at: "07:15" },
|
||||
{ id: "cl4", sender: "agent", kind: "text", body: "Confirmed — staging wasn't in your trusted domains, so the callback bounced. Added a note to the config page; adding `staging.lumen-analytics.app` fixes it.", at: "07:24" },
|
||||
{ id: "cl5", sender: "customer", kind: "text", body: "That was it. Thanks for the quick turnaround!", at: "07:31" },
|
||||
{ id: "cl6", sender: "system", kind: "status", body: "Resolved by Sam · trusted-domain misconfiguration · 19 minute handle time", at: "07:31" },
|
||||
],
|
||||
dossier: {
|
||||
userId: "user_4KpB3",
|
||||
email: "aiko@lumen-analytics.app",
|
||||
plan: "Growth",
|
||||
signedUpAgo: "9 months ago",
|
||||
authEvents: ["oauth.callback.rejected ×12 (staging) · 07:02–07:14", "trusted_domain.added · 07:28", "sign_in.completed (staging) · 07:30"],
|
||||
replay: { id: "rpl_2hh80", label: "Redirect loop on staging sign-in", duration: "0m 51s" },
|
||||
pastTickets: [{ subject: "OAuth redirect loops on staging", resolvedAgo: "3 hours ago" }],
|
||||
},
|
||||
timeline: [
|
||||
{ id: "ct1", kind: "event", label: "oauth.callback.rejected ×12", at: "07:02+", detail: "untrusted domain", tone: "error" },
|
||||
{ id: "ct2", kind: "message", label: "Reported on iMessage", at: "07:12", tone: "neutral" },
|
||||
{ id: "ct3", kind: "event", label: "trusted_domain.added", at: "07:28", tone: "ok" },
|
||||
{ id: "ct4", kind: "event", label: "sign_in.completed on staging", at: "07:30", tone: "ok" },
|
||||
],
|
||||
copilot: {
|
||||
initial: {
|
||||
text: "Closed ticket, kept for context: staging redirect-looped because the domain wasn't trusted. The AI narrowed it to a config issue in two messages; Sam confirmed and resolved in 19 minutes. If Aiko writes in again, this history and her config state load automatically.",
|
||||
},
|
||||
responses: [],
|
||||
fallback: "This conversation is resolved — its context stays available for future tickets from Aiko.",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const DEMO_CLUSTERS: DemoCluster[] = [
|
||||
{ id: "billing-upgrade", label: "Billing upgrade delays", conversationIds: ["conv-payments"] },
|
||||
{ id: "email-latency", label: "Email delivery latency", conversationIds: ["conv-magiclink"] },
|
||||
];
|
||||
|
||||
export const DEMO_INCIDENT: DemoIncident = {
|
||||
id: "inc-4821",
|
||||
title: "Delayed magic-link and OTP email delivery",
|
||||
reportCount: 9,
|
||||
windowMinutes: 18,
|
||||
clusterId: "email-latency",
|
||||
statusDraft: "Investigating — Transactional email delivery via our primary provider is delayed (p95 ~7 minutes) starting 10:05 UTC. Sign-in links are queued, not lost. We are rerouting through a secondary provider; no action is needed on your side.",
|
||||
heldConversationIds: ["conv-magiclink"],
|
||||
};
|
||||
|
||||
export function getConversation(id: string): DemoConversation | undefined {
|
||||
return DEMO_CONVERSATIONS.find((conversation) => conversation.id === id);
|
||||
}
|
||||
|
||||
export function getClusterSize(clusterId: string | undefined, incidentTripped: boolean): number {
|
||||
if (!clusterId) return 0;
|
||||
if (clusterId === DEMO_INCIDENT.clusterId) {
|
||||
return incidentTripped ? DEMO_INCIDENT.reportCount : 1;
|
||||
}
|
||||
const cluster = DEMO_CLUSTERS.find((candidate) => candidate.id === clusterId);
|
||||
return cluster?.conversationIds.length ?? 0;
|
||||
}
|
||||
@ -0,0 +1,135 @@
|
||||
import type { ChatModelAdapter, ChatModelRunOptions, ChatModelRunResult, ThreadAssistantContentPart, ThreadMessageLike } from "@assistant-ui/react";
|
||||
import { waitForApproval } from "./approval-store";
|
||||
import type { CopilotToolCall, CopilotTurn, DemoConversation } from "./fixtures";
|
||||
|
||||
// Read through a function call so TS doesn't narrow `signal.aborted` to a
|
||||
// constant across awaits — it genuinely flips when the run is cancelled.
|
||||
function isAborted(signal: AbortSignal): boolean {
|
||||
return signal.aborted;
|
||||
}
|
||||
|
||||
function sleep(ms: number, abortSignal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (abortSignal.aborted) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(resolve, ms);
|
||||
abortSignal.addEventListener("abort", () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function toolCallPart(toolCall: CopilotToolCall, index: number, withResult: boolean): ThreadAssistantContentPart {
|
||||
return {
|
||||
type: "tool-call",
|
||||
toolCallId: `mock-${toolCall.toolName}-${index}`,
|
||||
toolName: toolCall.toolName,
|
||||
args: toolCall.args,
|
||||
argsText: JSON.stringify(toolCall.args),
|
||||
...(withResult ? { result: toolCall.result } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** The copilot's opening analysis, pre-seeded into the thread with completed tool calls. */
|
||||
export function buildInitialCopilotMessages(conversation: DemoConversation): ThreadMessageLike[] {
|
||||
const turn = conversation.copilot.initial;
|
||||
const content: ThreadAssistantContentPart[] = [
|
||||
...(turn.toolCalls ?? []).map((toolCall, index) => toolCallPart(toolCall, index, true)),
|
||||
{ type: "text", text: turn.text },
|
||||
];
|
||||
return [{ id: `${conversation.id}-copilot-initial`, role: "assistant", content }];
|
||||
}
|
||||
|
||||
function lastUserText(messages: ChatModelRunOptions["messages"]): string {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i];
|
||||
if (message.role !== "user") continue;
|
||||
return message.content
|
||||
.map((part) => (part.type === "text" ? part.text : ""))
|
||||
.join(" ");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic stand-in for the unified AI endpoint: streams the next canned
|
||||
* turn for this conversation, tool calls first, then the text word by word.
|
||||
* Keyword-matched actions (refunds, re-syncs, publishes) take precedence over
|
||||
* the index-based canned responses; their `threadEffect` is reported through
|
||||
* `onThreadEffect` so the customer thread reflects the mutation.
|
||||
*/
|
||||
export function createMockCopilotAdapter(
|
||||
conversation: DemoConversation,
|
||||
callbacks?: { onThreadEffect?: (body: string) => void, onRunStart?: () => void },
|
||||
): ChatModelAdapter {
|
||||
return {
|
||||
async *run({ messages, abortSignal }: ChatModelRunOptions): AsyncGenerator<ChatModelRunResult, void> {
|
||||
callbacks?.onRunStart?.();
|
||||
const userTurnIndex = messages.filter((message) => message.role === "user").length - 1;
|
||||
const responses = conversation.copilot.responses;
|
||||
const question = lastUserText(messages).toLowerCase();
|
||||
const action = conversation.copilot.actions?.find((candidate) =>
|
||||
candidate.triggers.some((trigger) => question.includes(trigger)));
|
||||
const turn: CopilotTurn = action
|
||||
? action.turn
|
||||
: userTurnIndex >= 0 && userTurnIndex < responses.length
|
||||
? responses[userTurnIndex]
|
||||
: { text: conversation.copilot.fallback };
|
||||
|
||||
const settledParts: ThreadAssistantContentPart[] = [];
|
||||
|
||||
await sleep(500, abortSignal);
|
||||
if (isAborted(abortSignal)) return;
|
||||
|
||||
// Write-actions pause on an approval card until the operator decides.
|
||||
if (action) {
|
||||
const approvalId = `approval-${conversation.id}-${userTurnIndex}`;
|
||||
const approvalArgs = { action: action.approval.title, summary: action.approval.summary };
|
||||
const approvalPart = {
|
||||
type: "tool-call" as const,
|
||||
toolCallId: approvalId,
|
||||
toolName: "request-approval",
|
||||
args: approvalArgs,
|
||||
argsText: JSON.stringify(approvalArgs),
|
||||
};
|
||||
yield { content: [...settledParts, approvalPart] };
|
||||
const approved = await waitForApproval(approvalId, abortSignal);
|
||||
if (isAborted(abortSignal)) return;
|
||||
settledParts.push({ ...approvalPart, result: { approved } });
|
||||
yield { content: [...settledParts] };
|
||||
if (!approved) {
|
||||
yield { content: [...settledParts, { type: "text", text: "Understood — I didn't run it. Nothing was changed." }] };
|
||||
return;
|
||||
}
|
||||
await sleep(400, abortSignal);
|
||||
if (isAborted(abortSignal)) return;
|
||||
}
|
||||
|
||||
for (const [index, toolCall] of (turn.toolCalls ?? []).entries()) {
|
||||
yield { content: [...settledParts, toolCallPart(toolCall, index, false)] };
|
||||
await sleep(1100, abortSignal);
|
||||
if (isAborted(abortSignal)) return;
|
||||
settledParts.push(toolCallPart(toolCall, index, true));
|
||||
yield { content: [...settledParts] };
|
||||
await sleep(250, abortSignal);
|
||||
if (isAborted(abortSignal)) return;
|
||||
}
|
||||
|
||||
const words = turn.text.split(" ");
|
||||
let partialText = "";
|
||||
for (const word of words) {
|
||||
partialText = partialText === "" ? word : `${partialText} ${word}`;
|
||||
yield { content: [...settledParts, { type: "text", text: partialText }] };
|
||||
await sleep(24, abortSignal);
|
||||
if (isAborted(abortSignal)) return;
|
||||
}
|
||||
|
||||
if (action?.threadEffect) {
|
||||
callbacks?.onThreadEffect?.(action.threadEffect);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/components/ui";
|
||||
import { SidebarSimpleIcon } from "@phosphor-icons/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
|
||||
import { AppEnabledGuard } from "../app-enabled-guard";
|
||||
import { PageLayout } from "../page-layout";
|
||||
import { CopilotPane } from "./components/copilot-pane";
|
||||
import { InboxPane } from "./components/inbox-pane";
|
||||
import { ThreadPane } from "./components/thread-pane";
|
||||
import { DEMO_CONVERSATIONS, getConversation } from "./fixtures";
|
||||
import { useDemoPlayback } from "./use-demo-playback";
|
||||
|
||||
const PANEL_SHELL_CLASS =
|
||||
"flex-1 min-h-0 overflow-hidden rounded-xl bg-white/90 ring-1 ring-black/[0.06] dark:bg-background/60 dark:ring-white/[0.06]";
|
||||
const RESIZE_HANDLE_CLASS =
|
||||
"w-px bg-black/[0.08] transition-colors duration-150 hover:bg-black/[0.14] hover:transition-none dark:bg-border/40 dark:hover:bg-border";
|
||||
|
||||
export default function PageClient() {
|
||||
const demo = useDemoPlayback();
|
||||
const [selectedId, setSelectedId] = useState(DEMO_CONVERSATIONS[0].id);
|
||||
const [copilotOpen, setCopilotOpen] = useState(true);
|
||||
const selected = getConversation(selectedId) ?? DEMO_CONVERSATIONS[0];
|
||||
const playback = demo.stateFor(selected.id);
|
||||
|
||||
// Scripted conversations play out when opened; the returned canceller keeps
|
||||
// StrictMode's double-mount and mid-script switches from leaking timers.
|
||||
const startScript = demo.startScript;
|
||||
useEffect(() => startScript(selectedId), [startScript, selectedId]);
|
||||
|
||||
return (
|
||||
<AppEnabledGuard appId="support">
|
||||
<PageLayout fillWidth noPadding containedHeight>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 px-4 py-4 sm:px-6">
|
||||
<PanelGroup direction="horizontal" className={PANEL_SHELL_CLASS} autoSaveId="support-demo-panes">
|
||||
<Panel defaultSize={24} minSize={18} className="min-h-0 max-lg:hidden">
|
||||
<InboxPane
|
||||
conversations={DEMO_CONVERSATIONS}
|
||||
playbackFor={demo.stateFor}
|
||||
incidentTripped={demo.incidentTripped}
|
||||
selectedId={selected.id}
|
||||
onSelect={setSelectedId}
|
||||
/>
|
||||
</Panel>
|
||||
<PanelResizeHandle className={cn(RESIZE_HANDLE_CLASS, "max-lg:hidden")} />
|
||||
<Panel defaultSize={48} minSize={30} className="min-h-0">
|
||||
<ThreadPane
|
||||
conversation={selected}
|
||||
playback={playback}
|
||||
demo={demo}
|
||||
headerExtra={(
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCopilotOpen((prev) => !prev)}
|
||||
title={copilotOpen ? "Hide copilot" : "Show copilot"}
|
||||
className="hidden shrink-0 rounded-md p-1.5 text-muted-foreground/50 transition-colors hover:bg-foreground/[0.04] hover:text-foreground/80 lg:block"
|
||||
>
|
||||
<SidebarSimpleIcon className={cn("h-3.5 w-3.5", copilotOpen && "text-foreground/70")} />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</Panel>
|
||||
{copilotOpen && (
|
||||
<>
|
||||
<PanelResizeHandle className={cn(RESIZE_HANDLE_CLASS, "max-lg:hidden")} />
|
||||
<Panel defaultSize={28} minSize={22} className="min-h-0 max-lg:hidden">
|
||||
<CopilotPane
|
||||
key={selected.id}
|
||||
conversation={selected}
|
||||
revealedDossierFields={playback.revealedDossierFields}
|
||||
onThreadEffect={(body) => demo.appendSystemMessage(selected.id, body)}
|
||||
/>
|
||||
</Panel>
|
||||
</>
|
||||
)}
|
||||
</PanelGroup>
|
||||
</div>
|
||||
</PageLayout>
|
||||
</AppEnabledGuard>
|
||||
);
|
||||
}
|
||||
@ -1,17 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "@/components/router";
|
||||
import { urlString } from "@hexclave/shared/dist/utils/urls";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import PageClient from "./page-client";
|
||||
|
||||
export default function Page() {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
router.replace(urlString`/projects/${projectId}/conversations`);
|
||||
}, [projectId, router]);
|
||||
|
||||
return null;
|
||||
return <PageClient />;
|
||||
}
|
||||
|
||||
@ -0,0 +1,252 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { DEMO_CONVERSATIONS, type DemoConversation, type DemoMessage, type DemoSender, type DossierField } from "./fixtures";
|
||||
|
||||
export type ConversationPlaybackState = {
|
||||
messages: DemoMessage[],
|
||||
typing: DemoSender | null,
|
||||
confidence: number,
|
||||
revealedDossierFields: ReadonlySet<DossierField>,
|
||||
draft: string,
|
||||
scriptStatus: "idle" | "playing" | "done",
|
||||
devinStage: "idle" | "working" | "done",
|
||||
repliesReleased: boolean,
|
||||
};
|
||||
|
||||
export type DemoPlayback = {
|
||||
stateFor: (conversationId: string) => ConversationPlaybackState,
|
||||
incidentTripped: boolean,
|
||||
/** Starts (or restarts) an unfinished script. Returns a canceller suitable as an effect cleanup. */
|
||||
startScript: (conversationId: string) => (() => void) | undefined,
|
||||
replayScript: (conversationId: string) => void,
|
||||
setDraft: (conversationId: string, draft: string) => void,
|
||||
sendAgentReply: (conversationId: string, body: string) => void,
|
||||
appendSystemMessage: (conversationId: string, body: string) => void,
|
||||
tagDevin: (conversationId: string) => void,
|
||||
releaseHeldReplies: () => void,
|
||||
};
|
||||
|
||||
const ALL_DOSSIER_FIELDS: DossierField[] = ["identity", "plan", "authEvents", "replay", "pastTickets"];
|
||||
|
||||
function nowLabel(): string {
|
||||
return new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
}
|
||||
|
||||
function initialStateFor(conversation: DemoConversation): ConversationPlaybackState {
|
||||
const isScripted = conversation.script !== undefined;
|
||||
return {
|
||||
messages: conversation.seedMessages,
|
||||
typing: null,
|
||||
confidence: isScripted ? 0 : conversation.confidence,
|
||||
revealedDossierFields: new Set(isScripted ? [] : ALL_DOSSIER_FIELDS),
|
||||
draft: isScripted ? "" : (conversation.initialDraft ?? ""),
|
||||
scriptStatus: isScripted ? "idle" : "done",
|
||||
devinStage: "idle",
|
||||
repliesReleased: false,
|
||||
};
|
||||
}
|
||||
|
||||
function buildInitialStates(): Record<string, ConversationPlaybackState> {
|
||||
return Object.fromEntries(DEMO_CONVERSATIONS.map((conversation) => [conversation.id, initialStateFor(conversation)]));
|
||||
}
|
||||
|
||||
export function useDemoPlayback(): DemoPlayback {
|
||||
const [states, setStates] = useState<Record<string, ConversationPlaybackState>>(buildInitialStates);
|
||||
const [incidentTripped, setIncidentTripped] = useState(false);
|
||||
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>[]>>(new Map());
|
||||
const finishedScriptsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
const timers = timersRef.current;
|
||||
return () => {
|
||||
for (const list of timers.values()) {
|
||||
for (const timer of list) clearTimeout(timer);
|
||||
}
|
||||
timers.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const clearTimersFor = useCallback((conversationId: string) => {
|
||||
const list = timersRef.current.get(conversationId);
|
||||
if (list) {
|
||||
for (const timer of list) clearTimeout(timer);
|
||||
}
|
||||
timersRef.current.set(conversationId, []);
|
||||
}, []);
|
||||
|
||||
const schedule = useCallback((conversationId: string, delayMs: number, fn: () => void) => {
|
||||
const timer = setTimeout(fn, delayMs);
|
||||
const list = timersRef.current.get(conversationId) ?? [];
|
||||
list.push(timer);
|
||||
timersRef.current.set(conversationId, list);
|
||||
}, []);
|
||||
|
||||
const patchState = useCallback((conversationId: string, patch: (prev: ConversationPlaybackState) => Partial<ConversationPlaybackState>) => {
|
||||
setStates((prev) => {
|
||||
const current = prev[conversationId] as ConversationPlaybackState | undefined;
|
||||
if (!current) return prev;
|
||||
return { ...prev, [conversationId]: { ...current, ...patch(current) } };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const runScript = useCallback((conversation: DemoConversation) => {
|
||||
const script = conversation.script;
|
||||
if (!script) return;
|
||||
|
||||
patchState(conversation.id, () => ({ scriptStatus: "playing" }));
|
||||
|
||||
let cumulativeMs = 0;
|
||||
for (const step of script) {
|
||||
if (step.kind === "wait") {
|
||||
cumulativeMs += step.ms;
|
||||
continue;
|
||||
}
|
||||
schedule(conversation.id, cumulativeMs, () => {
|
||||
switch (step.kind) {
|
||||
case "typing": {
|
||||
patchState(conversation.id, () => ({ typing: step.sender }));
|
||||
break;
|
||||
}
|
||||
case "message": {
|
||||
patchState(conversation.id, (prev) => ({ typing: null, messages: [...prev.messages, step.message] }));
|
||||
break;
|
||||
}
|
||||
case "confidence": {
|
||||
patchState(conversation.id, () => ({ confidence: step.to }));
|
||||
break;
|
||||
}
|
||||
case "dossier": {
|
||||
patchState(conversation.id, (prev) => ({
|
||||
revealedDossierFields: new Set([...prev.revealedDossierFields, step.field]),
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case "draft": {
|
||||
patchState(conversation.id, () => ({ draft: step.text }));
|
||||
break;
|
||||
}
|
||||
case "incident-trip": {
|
||||
setIncidentTripped(true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
schedule(conversation.id, cumulativeMs, () => {
|
||||
finishedScriptsRef.current.add(conversation.id);
|
||||
patchState(conversation.id, () => ({
|
||||
typing: null,
|
||||
scriptStatus: "done",
|
||||
// Whatever the script didn't reveal step-by-step is available once
|
||||
// the AI finishes gathering.
|
||||
revealedDossierFields: new Set(ALL_DOSSIER_FIELDS),
|
||||
}));
|
||||
});
|
||||
}, [patchState, schedule]);
|
||||
|
||||
const restartScript = useCallback((conversation: DemoConversation) => {
|
||||
clearTimersFor(conversation.id);
|
||||
finishedScriptsRef.current.delete(conversation.id);
|
||||
if (conversation.clusterId === "email-latency") setIncidentTripped(false);
|
||||
setStates((prev) => ({ ...prev, [conversation.id]: initialStateFor(conversation) }));
|
||||
runScript(conversation);
|
||||
}, [clearTimersFor, runScript]);
|
||||
|
||||
const startScript = useCallback((conversationId: string) => {
|
||||
const conversation = DEMO_CONVERSATIONS.find((candidate) => candidate.id === conversationId);
|
||||
if (!conversation?.script) return undefined;
|
||||
if (finishedScriptsRef.current.has(conversationId)) return undefined;
|
||||
// Restart from the top on every (re)mount: dev StrictMode cancels the
|
||||
// first run's timers via the cleanup below, and mid-script tab switches
|
||||
// replay the intake from the start, which is what a demo wants anyway.
|
||||
restartScript(conversation);
|
||||
return () => clearTimersFor(conversationId);
|
||||
}, [restartScript, clearTimersFor]);
|
||||
|
||||
const replayScript = useCallback((conversationId: string) => {
|
||||
const conversation = DEMO_CONVERSATIONS.find((candidate) => candidate.id === conversationId);
|
||||
if (!conversation?.script) return;
|
||||
restartScript(conversation);
|
||||
}, [restartScript]);
|
||||
|
||||
const setDraft = useCallback((conversationId: string, draft: string) => {
|
||||
patchState(conversationId, () => ({ draft }));
|
||||
}, [patchState]);
|
||||
|
||||
const sendAgentReply = useCallback((conversationId: string, body: string) => {
|
||||
const trimmed = body.trim();
|
||||
if (trimmed === "") return;
|
||||
patchState(conversationId, (prev) => ({
|
||||
draft: "",
|
||||
messages: [...prev.messages, {
|
||||
id: `agent-${prev.messages.length}-${trimmed.length}`,
|
||||
sender: "agent",
|
||||
kind: "text",
|
||||
body: trimmed,
|
||||
at: nowLabel(),
|
||||
}],
|
||||
}));
|
||||
}, [patchState]);
|
||||
|
||||
const appendSystemMessage = useCallback((conversationId: string, body: string) => {
|
||||
patchState(conversationId, (prev) => ({
|
||||
messages: [...prev.messages, {
|
||||
id: `system-${prev.messages.length}-${body.length}`,
|
||||
sender: "system",
|
||||
kind: "status",
|
||||
body,
|
||||
at: nowLabel(),
|
||||
}],
|
||||
}));
|
||||
}, [patchState]);
|
||||
|
||||
const tagDevin = useCallback((conversationId: string) => {
|
||||
patchState(conversationId, (prev) => {
|
||||
if (prev.devinStage !== "idle") return {};
|
||||
return {
|
||||
devinStage: "working",
|
||||
messages: [...prev.messages, {
|
||||
id: "devin-start",
|
||||
sender: "system",
|
||||
kind: "status",
|
||||
body: "@Devin tagged — spinning up Safari 26, reproducing the sign-in failure",
|
||||
at: nowLabel(),
|
||||
}],
|
||||
};
|
||||
});
|
||||
schedule(conversationId, 3200, () => {
|
||||
patchState(conversationId, (prev) => {
|
||||
if (prev.devinStage !== "working") return {};
|
||||
return {
|
||||
devinStage: "done",
|
||||
messages: [...prev.messages, {
|
||||
id: "devin-video",
|
||||
sender: "system",
|
||||
kind: "devin-video",
|
||||
body: "Reproduced on Safari 26 with @hexclave/next 2.8.1 — TypeError on sign-in click, fixed on 2.8.3. Recording attached.",
|
||||
at: nowLabel(),
|
||||
}],
|
||||
};
|
||||
});
|
||||
});
|
||||
}, [patchState, schedule]);
|
||||
|
||||
const releaseHeldReplies = useCallback(() => {
|
||||
patchState("conv-magiclink", () => ({ repliesReleased: true }));
|
||||
}, [patchState]);
|
||||
|
||||
const stateFor = useCallback((conversationId: string): ConversationPlaybackState => {
|
||||
const state = states[conversationId] as ConversationPlaybackState | undefined;
|
||||
return state ?? {
|
||||
messages: [],
|
||||
typing: null,
|
||||
confidence: 0,
|
||||
revealedDossierFields: new Set(),
|
||||
draft: "",
|
||||
scriptStatus: "done",
|
||||
devinStage: "idle",
|
||||
repliesReleased: false,
|
||||
};
|
||||
}, [states]);
|
||||
|
||||
return { stateFor, incidentTripped, startScript, replayScript, setDraft, sendAgentReply, appendSystemMessage, tagDevin, releaseHeldReplies };
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user