(mock) support app

This commit is contained in:
mantrakp04 2026-07-08 17:23:01 -07:00
parent de492a3ca3
commit 5e2e5bb5ac
17 changed files with 2058 additions and 12 deletions

View File

@ -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);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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:3710: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 510 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 510 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:1010: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:4009: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:4009: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:0207: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;
}

View File

@ -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);
}
},
};
}

View File

@ -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>
);
}

View File

@ -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 />;
}

View File

@ -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 };
}