feat: add chat based testing for voice agent (#308)

* feat: add backend foundations

* feat: add text chat UI

* chore: simplify the reload behaviour

* fix: fix upgrade banner to be triggered after package upload

* feat: simplify TesterPanel design

* chore: fix formatting and generate client

* chore: fix tracing for text chat mode

* fix: fix revert and edit CTA

* refactor: refactor TesterPanel into smaller components

* feat: enable runtime transition of nodes

* fix: fix review comments
This commit is contained in:
Abhishek 2026-05-21 15:20:02 +05:30 committed by GitHub
parent 67479e98fd
commit d97d1d72cd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
96 changed files with 7630 additions and 1684 deletions

View file

@ -0,0 +1,77 @@
import { NextResponse } from "next/server";
const GHCR_IMAGES = ["dograh-hq/dograh-ui", "dograh-hq/dograh-api"] as const;
const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/;
const REVALIDATE_SECONDS = 60 * 60;
type Semver = [number, number, number];
function parseSemver(tag: string): Semver | null {
const m = tag.match(SEMVER_RE);
if (!m) return null;
return [Number(m[1]), Number(m[2]), Number(m[3])];
}
function compareSemver(a: Semver, b: Semver): number {
for (let i = 0; i < 3; i++) {
if (a[i] !== b[i]) return a[i] - b[i];
}
return 0;
}
async function fetchLatestTag(image: string): Promise<string | null> {
const tokenRes = await fetch(
`https://ghcr.io/token?scope=repository:${image}:pull&service=ghcr.io`,
{ next: { revalidate: REVALIDATE_SECONDS } },
);
if (!tokenRes.ok) return null;
const { token } = (await tokenRes.json()) as { token?: string };
if (!token) return null;
const tagsRes = await fetch(`https://ghcr.io/v2/${image}/tags/list`, {
headers: { Authorization: `Bearer ${token}` },
next: { revalidate: REVALIDATE_SECONDS },
});
if (!tagsRes.ok) return null;
const { tags } = (await tagsRes.json()) as { tags?: string[] };
let latest: { tag: string; parsed: Semver } | null = null;
for (const tag of tags ?? []) {
const parsed = parseSemver(tag);
if (!parsed) continue;
if (!latest || compareSemver(parsed, latest.parsed) > 0) {
latest = { tag, parsed };
}
}
return latest?.tag ?? null;
}
export async function GET() {
try {
const results = await Promise.all(GHCR_IMAGES.map(fetchLatestTag));
// Only advertise an update once every image has published a tag at that
// version — otherwise we'd nudge users to upgrade before the matching
// container actually exists.
let minLatest: { tag: string; parsed: Semver } | null = null;
for (const tag of results) {
if (!tag) return NextResponse.json({ latest: null }, { status: 200 });
const parsed = parseSemver(tag);
if (!parsed) return NextResponse.json({ latest: null }, { status: 200 });
if (!minLatest || compareSemver(parsed, minLatest.parsed) < 0) {
minLatest = { tag, parsed };
}
}
return NextResponse.json(
{ latest: minLatest?.tag ?? null },
{
headers: {
"Cache-Control": `public, max-age=${REVALIDATE_SECONDS}, s-maxage=${REVALIDATE_SECONDS}`,
},
},
);
} catch {
return NextResponse.json({ latest: null }, { status: 200 });
}
}

View file

@ -135,22 +135,3 @@
.animate-spin-slow {
animation: spin-slow 3s linear infinite;
}
/* Smaller Chatwoot bubble on workflow run pages */
body.chatwoot-compact .woot--bubble-holder {
transform: scale(0.7) !important;
transform-origin: bottom right !important;
right: 4px !important;
bottom: 4px !important;
}
body.chatwoot-compact .woot--bubble-holder .woot-widget-bubble {
width: 48px !important;
height: 48px !important;
}
body.chatwoot-compact #chatwoot_live_chat_widget {
bottom: 60px !important;
right: 4px !important;
}

View file

@ -1,6 +1,6 @@
"use client";
import { ChevronLeft, ChevronRight, Download, Globe } from 'lucide-react';
import { ArrowDownLeft, ArrowUpRight, ChevronLeft, ChevronRight, Download, Globe, MessageSquare, Phone } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useCallback, useEffect, useId, useState } from 'react';
import TimezoneSelect, { type ITimezoneOption } from 'react-timezone-select';
@ -23,6 +23,7 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useUserConfig } from '@/context/UserConfigContext';
import { useAuth } from '@/lib/auth';
import { usageFilterAttributes } from '@/lib/filterAttributes';
@ -32,6 +33,53 @@ import { ActiveFilter, DateRangeValue } from '@/types/filters';
// Get local timezone
const getLocalTimezone = () => Intl.DateTimeFormat().resolvedOptions().timeZone;
// Collapse a run's `mode` (from WorkflowRunMode in api/enums.py) into a coarse
// channel. Telephony providers (twilio, plivo, telnyx, vonage, vobiz, cloudonix,
// ari, ...) are phone calls; webrtc/smallwebrtc are browser web calls; textchat
// is a text conversation. Anything unknown falls back to "phone".
const WEB_CALL_MODES = new Set(['webrtc', 'smallwebrtc']);
const TEXT_CHAT_MODES = new Set(['textchat']);
const getCallChannel = (mode?: string | null): 'phone' | 'web' | 'chat' => {
if (mode && TEXT_CHAT_MODES.has(mode)) return 'chat';
if (mode && WEB_CALL_MODES.has(mode)) return 'web';
return 'phone';
};
// Render the call's channel (mode) and direction (call_type) as two compact
// icons in a single cell, with a tooltip spelling out the full label. The
// channel icon shows medium/how (phone / web / chat); the colored arrow shows
// direction (inbound = incoming/emerald, outbound = outgoing/blue).
const CallTypeCell = ({ mode, callType }: { mode?: string | null; callType?: string | null }) => {
if (!mode && !callType) {
return <span className="text-sm text-muted-foreground">-</span>;
}
const channel = getCallChannel(mode);
const ChannelIcon = channel === 'chat' ? MessageSquare : channel === 'web' ? Globe : Phone;
const channelLabel = channel === 'chat' ? 'Text chat' : channel === 'web' ? 'Web call' : 'Phone call';
const isInbound = callType === 'inbound';
const DirectionIcon = isInbound ? ArrowDownLeft : ArrowUpRight;
const directionLabel = isInbound ? 'Inbound' : 'Outbound';
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center gap-1">
<ChannelIcon className="h-4 w-4 text-muted-foreground" />
<DirectionIcon
className={`h-3.5 w-3.5 ${isInbound ? 'text-emerald-600' : 'text-blue-600'}`}
/>
</span>
</TooltipTrigger>
<TooltipContent sideOffset={4}>
{directionLabel} · {channelLabel}
</TooltipContent>
</Tooltip>
);
};
export default function UsagePage() {
const router = useRouter();
const searchParams = useSearchParams();
@ -534,13 +582,7 @@ export default function UsagePage() {
</TableCell>
<TableCell>{run.workflow_name || 'Unknown'}</TableCell>
<TableCell>
{run.call_type ? (
<Badge variant={run.call_type === 'inbound' ? "secondary" : "default"}>
{run.call_type === 'inbound' ? 'Inbound' : 'Outbound'}
</Badge>
) : (
<span className="text-sm text-muted-foreground">-</span>
)}
<CallTypeCell mode={run.mode} callType={run.call_type} />
</TableCell>
<TableCell className="text-sm">
{(run.call_type === 'inbound'

View file

@ -15,7 +15,9 @@ import type { DocumentResponseSchema, RecordingResponseSchema, ToolResponse } fr
import { useNodeSpecs } from "@/components/flow/renderer";
import { FlowEdge, FlowNode, NodeType } from "@/components/flow/types";
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent } from '@/components/ui/sheet';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useOnboarding } from '@/context/OnboardingContext';
import { WorkflowConfigurations } from '@/types/workflow-configurations';
import AddNodePanel from "../../../components/flow/AddNodePanel";
@ -23,7 +25,9 @@ import CustomEdge from "../../../components/flow/edges/CustomEdge";
import { GenericNode } from "../../../components/flow/nodes/GenericNode";
import { PhoneCallDialog } from './components/PhoneCallDialog';
import { VersionHistoryPanel, WorkflowVersion } from './components/VersionHistoryPanel';
import type { WorkflowRuntimeFocusMode, WorkflowRuntimeNodeTransition } from './components/workflow-tester/types';
import { WorkflowEditorHeader } from "./components/WorkflowEditorHeader";
import { WorkflowTesterPanel } from './components/WorkflowTesterPanel';
import { WorkflowProvider } from "./contexts/WorkflowContext";
import { useWorkflowState } from "./hooks/useWorkflowState";
import { layoutNodes } from './utils/layoutNodes';
@ -38,6 +42,8 @@ interface RenderWorkflowProps {
initialWorkflowName: string;
workflowId: number;
workflowUuid?: string;
initialTotalRuns?: number | null;
openTesterOnLoad?: boolean;
initialFlow?: {
nodes: FlowNode[];
edges: FlowEdge[];
@ -54,16 +60,33 @@ interface RenderWorkflowProps {
user: { id: string; email?: string };
}
function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations, initialVersionNumber, initialVersionStatus, user }: RenderWorkflowProps) {
function RenderWorkflow({
initialWorkflowName,
workflowId,
workflowUuid,
initialTotalRuns,
openTesterOnLoad = false,
initialFlow,
initialTemplateContextVariables,
initialWorkflowConfigurations,
initialVersionNumber,
initialVersionStatus,
user,
}: RenderWorkflowProps) {
const router = useRouter();
const { specs } = useNodeSpecs();
const { hasCompletedAction } = useOnboarding();
const [isPhoneCallDialogOpen, setIsPhoneCallDialogOpen] = useState(false);
const [isVersionPanelOpen, setIsVersionPanelOpen] = useState(false);
const [isTesterRailOpen, setIsTesterRailOpen] = useState(true);
const [isTesterSheetOpen, setIsTesterSheetOpen] = useState(false);
const [isDesktopViewport, setIsDesktopViewport] = useState(false);
const [versions, setVersions] = useState<WorkflowVersion[]>([]);
const [versionsLoading, setVersionsLoading] = useState(false);
const [versionsLoadingMore, setVersionsLoadingMore] = useState(false);
const [versionsHasMore, setVersionsHasMore] = useState(false);
const [activeVersionId, setActiveVersionId] = useState<number | null>(null);
const hasAutoOpenedTester = useRef(false);
// Version info that updates immediately from the GET/save/publish responses.
const [currentVersionNumber, setCurrentVersionNumber] = useState<number | null>(initialVersionNumber ?? null);
const [currentVersionStatus, setCurrentVersionStatus] = useState<string | null>(initialVersionStatus ?? null);
@ -71,6 +94,9 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
const [documents, setDocuments] = useState<DocumentResponseSchema[] | undefined>(undefined);
const [tools, setTools] = useState<ToolResponse[] | undefined>(undefined);
const [recordings, setRecordings] = useState<RecordingResponseSchema[]>([]);
const [runtimeFocusMode, setRuntimeFocusMode] = useState<WorkflowRuntimeFocusMode>("follow");
const [activeRuntimeNodeId, setActiveRuntimeNodeId] = useState<string | null>(null);
const [runtimePulseNonce, setRuntimePulseNonce] = useState(0);
const {
rfInstance,
@ -80,6 +106,7 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
workflowName,
isDirty,
workflowValidationErrors,
templateContextVariables,
setNodes,
setEdges,
setIsDirty,
@ -91,7 +118,6 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
onConnect,
onEdgesChange,
onNodesChange,
onRun,
} = useWorkflowState({
initialWorkflowName,
workflowId,
@ -204,6 +230,13 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
return true;
}, [activeVersionId, versions, hasDraft]);
useEffect(() => {
if (!isViewingHistoricalVersion) {
return;
}
setActiveRuntimeNodeId(null);
}, [isViewingHistoricalVersion]);
// Return to the draft version, creating one from published if needed
const handleBackToDraft = useCallback(async () => {
const existingDraft = versions.find((v) => v.status === "draft");
@ -258,6 +291,50 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
return undefined;
}, [activeVersionId, versions, currentVersionNumber, currentVersionStatus]);
const testerDisabledReason = useMemo(() => {
if (isViewingHistoricalVersion) {
return "Return to the draft before starting a new test session.";
}
if (isDirty) {
return "Save the latest draft before testing so the session uses the workflow you are looking at.";
}
if (workflowValidationErrors.length > 0) {
return "Resolve the current validation errors before starting another test.";
}
return null;
}, [isDirty, isViewingHistoricalVersion, workflowValidationErrors.length]);
const handleOpenTester = useCallback(() => {
if (window.innerWidth >= 1280) {
setIsTesterRailOpen(true);
return;
}
setIsTesterSheetOpen(true);
}, []);
const shouldShowWebCallOnboarding = useMemo(() => {
return (initialTotalRuns ?? 0) === 0 && !hasCompletedAction('web_call_started');
}, [hasCompletedAction, initialTotalRuns]);
useEffect(() => {
const syncViewport = () => {
setIsDesktopViewport(window.innerWidth >= 1280);
};
syncViewport();
window.addEventListener('resize', syncViewport);
return () => window.removeEventListener('resize', syncViewport);
}, []);
useEffect(() => {
if (hasAutoOpenedTester.current || !openTesterOnLoad || !shouldShowWebCallOnboarding || testerDisabledReason) {
return;
}
handleOpenTester();
hasAutoOpenedTester.current = true;
}, [handleOpenTester, openTesterOnLoad, shouldShowWebCallOnboarding, testerDisabledReason]);
// Fetch documents, tools, and recordings once for the entire workflow
useEffect(() => {
const fetchData = async () => {
@ -301,6 +378,48 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
type: "custom"
}), []);
const displayNodes = useMemo(
() =>
nodes.map((node) =>
node.id === activeRuntimeNodeId
? {
...node,
data: {
...node.data,
runtime_active: true,
runtime_pulse_nonce: runtimePulseNonce,
},
}
: node,
),
[activeRuntimeNodeId, nodes, runtimePulseNonce],
);
const handleRuntimeNodeTransition = useCallback(
(transition: WorkflowRuntimeNodeTransition) => {
const nodeId = transition.nodeId;
const instance = rfInstance.current;
if (!nodeId || !instance) {
return;
}
setActiveRuntimeNodeId(nodeId);
setRuntimePulseNonce((value) => value + 1);
if (runtimeFocusMode !== "follow" || !instance.viewportInitialized) {
return;
}
void instance.fitView({
nodes: [{ id: nodeId }],
duration: 350,
padding: 0.45,
maxZoom: 0.9,
});
},
[rfInstance, runtimeFocusMode],
);
// Guard saveWorkflow so it's a no-op when viewing a historical version.
// This is the single safety net that covers every save path: header button,
// Cmd+S, node edit dialogs, stale doc/tool cleanup, etc.
@ -372,12 +491,12 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
isDirty={isDirty}
workflowValidationErrors={workflowValidationErrors}
rfInstance={rfInstance}
onRun={onRun}
workflowId={workflowId}
workflowUuid={workflowUuid}
saveWorkflow={guardedSaveWorkflow}
user={user}
onPhoneCallClick={() => setIsPhoneCallDialogOpen(true)}
onTestAgentClick={handleOpenTester}
onHistoryClick={handleOpenVersionPanel}
activeVersionLabel={activeVersionLabel}
isViewingHistoricalVersion={isViewingHistoricalVersion}
@ -388,158 +507,191 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
/>
{/* Workflow Canvas */}
<div className="flex-1 relative">
<ReactFlow
key={activeVersionId ?? 'current'}
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onConnect={isViewingHistoricalVersion ? undefined : onConnect}
minZoom={0.4}
onInit={(instance) => {
rfInstance.current = instance;
// Center the workflow on load
setTimeout(() => {
instance.fitView({ padding: 0.2, duration: 200, maxZoom: 0.75 });
}, 0);
}}
defaultEdgeOptions={defaultEdgeOptions}
defaultViewport={initialFlow?.viewport}
nodesDraggable={!isViewingHistoricalVersion}
nodesConnectable={!isViewingHistoricalVersion}
edgesReconnectable={!isViewingHistoricalVersion}
zoomOnDoubleClick={false}
deleteKeyCode={isViewingHistoricalVersion ? null : "Backspace"}
>
<Background
variant={BackgroundVariant.Dots}
gap={16}
size={1}
color="#94a3b8"
/>
<div className="flex-1 min-h-0">
<div className="flex h-full min-w-0">
<div className="relative min-w-0 flex-1">
<ReactFlow
key={activeVersionId ?? 'current'}
nodes={displayNodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onConnect={isViewingHistoricalVersion ? undefined : onConnect}
minZoom={0.4}
onInit={(instance) => {
rfInstance.current = instance;
// Center the workflow on load
setTimeout(() => {
instance.fitView({ padding: 0.2, duration: 200, maxZoom: 0.75 });
}, 0);
}}
defaultEdgeOptions={defaultEdgeOptions}
defaultViewport={initialFlow?.viewport}
nodesDraggable={!isViewingHistoricalVersion}
nodesConnectable={!isViewingHistoricalVersion}
edgesReconnectable={!isViewingHistoricalVersion}
zoomOnDoubleClick={false}
deleteKeyCode={isViewingHistoricalVersion ? null : "Backspace"}
>
<Background
variant={BackgroundVariant.Dots}
gap={16}
size={1}
color="#94a3b8"
/>
{/* Top-right controls - vertical layout (hidden when viewing history) */}
{!isViewingHistoricalVersion && (
<Panel position="top-right">
{/* Top-right controls - vertical layout (hidden when viewing history) */}
{!isViewingHistoricalVersion && (
<Panel position="top-right">
<TooltipProvider>
<div className="flex flex-col gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="default"
size="icon"
onClick={() => setIsAddNodePanelOpen(true)}
className="shadow-md hover:shadow-lg"
>
<Plus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Add node</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => router.push(`/workflow/${workflowId}/settings`)}
className="bg-white shadow-sm hover:shadow-md"
>
<Settings className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Workflow settings</p>
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</Panel>
)}
</ReactFlow>
{/* Bottom-left controls - horizontal layout with custom buttons */}
<div className="absolute bottom-12 left-8 z-10 flex gap-2">
<TooltipProvider>
<div className="flex flex-col gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="default"
size="icon"
onClick={() => setIsAddNodePanelOpen(true)}
className="shadow-md hover:shadow-lg"
>
<Plus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Add node</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => rfInstance.current?.zoomIn()}
className="bg-white shadow-sm hover:shadow-md h-8 w-8"
>
<Plus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Zoom in</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => rfInstance.current?.zoomOut()}
className="bg-white shadow-sm hover:shadow-md h-8 w-8"
>
<Minus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Zoom out</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => rfInstance.current?.fitView()}
className="bg-white shadow-sm hover:shadow-md h-8 w-8"
>
<Maximize2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Fit view</p>
</TooltipContent>
</Tooltip>
{!isViewingHistoricalVersion && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => router.push(`/workflow/${workflowId}/settings`)}
className="bg-white shadow-sm hover:shadow-md"
onClick={() => {
setNodes(layoutNodes(nodes, edges, 'TB', rfInstance));
setIsDirty(true);
}}
className="bg-white shadow-sm hover:shadow-md h-8 w-8"
>
<Settings className="h-4 w-4" />
<BrushCleaning className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Workflow settings</p>
<TooltipContent side="top">
<p>Tidy Up</p>
</TooltipContent>
</Tooltip>
</div>
)}
</TooltipProvider>
</Panel>
</div>
</div>
{isTesterRailOpen && (
<aside className="hidden h-full w-[420px] shrink-0 border-l border-border xl:block">
<WorkflowTesterPanel
workflowId={workflowId}
initialContextVariables={templateContextVariables}
disabled={testerDisabledReason !== null}
disabledReason={testerDisabledReason}
showWebCallOnboarding={shouldShowWebCallOnboarding}
isVisible={isDesktopViewport}
onClose={() => setIsTesterRailOpen(false)}
runtimeFocusMode={runtimeFocusMode}
onRuntimeFocusModeChange={setRuntimeFocusMode}
onRuntimeNodeTransition={handleRuntimeNodeTransition}
/>
</aside>
)}
</ReactFlow>
{/* Bottom-left controls - horizontal layout with custom buttons */}
<div className="absolute bottom-12 left-8 z-10 flex gap-2">
<TooltipProvider>
{/* Zoom In */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => rfInstance.current?.zoomIn()}
className="bg-white shadow-sm hover:shadow-md h-8 w-8"
>
<Plus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Zoom in</p>
</TooltipContent>
</Tooltip>
{/* Zoom Out */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => rfInstance.current?.zoomOut()}
className="bg-white shadow-sm hover:shadow-md h-8 w-8"
>
<Minus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Zoom out</p>
</TooltipContent>
</Tooltip>
{/* Fit View */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => rfInstance.current?.fitView()}
className="bg-white shadow-sm hover:shadow-md h-8 w-8"
>
<Maximize2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Fit view</p>
</TooltipContent>
</Tooltip>
{/* Tidy/Arrange Nodes (hidden when viewing history) */}
{!isViewingHistoricalVersion && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => {
setNodes(layoutNodes(nodes, edges, 'TB', rfInstance));
setIsDirty(true);
}}
className="bg-white shadow-sm hover:shadow-md h-8 w-8"
>
<BrushCleaning className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Tidy Up</p>
</TooltipContent>
</Tooltip>
)}
</TooltipProvider>
</div>
<Sheet open={isTesterSheetOpen} onOpenChange={setIsTesterSheetOpen}>
<SheetContent side="right" className="w-full max-w-none p-0 sm:max-w-xl xl:hidden">
<WorkflowTesterPanel
workflowId={workflowId}
initialContextVariables={templateContextVariables}
disabled={testerDisabledReason !== null}
disabledReason={testerDisabledReason}
showWebCallOnboarding={shouldShowWebCallOnboarding}
isVisible={isTesterSheetOpen}
runtimeFocusMode={runtimeFocusMode}
onRuntimeFocusModeChange={setRuntimeFocusMode}
onRuntimeNodeTransition={handleRuntimeNodeTransition}
/>
</SheetContent>
</Sheet>
</div>
<AddNodePanel

View file

@ -1,9 +1,8 @@
"use client";
import { ReactFlowInstance } from "@xyflow/react";
import { AlertCircle, ArrowLeft, ChevronDown, Clipboard, Copy, Download, Eye, History, LoaderCircle, Menu, MoreVertical, Pencil, Phone, Rocket } from "lucide-react";
import { AlertCircle, ArrowLeft, Bot, Clipboard, Copy, Download, Eye, History, LoaderCircle, Menu, MoreVertical, Pencil, Phone, Rocket } from "lucide-react";
import { useRouter } from "next/navigation";
import posthog from "posthog-js";
import { useRef, useState } from "react";
import { toast } from "sonner";
@ -28,20 +27,18 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { useSidebar } from "@/components/ui/sidebar";
import { PostHogEvent } from "@/constants/posthog-events";
import { WORKFLOW_RUN_MODES } from "@/constants/workflowRunModes";
interface WorkflowEditorHeaderProps {
workflowName: string;
isDirty: boolean;
workflowValidationErrors: WorkflowError[];
rfInstance: React.RefObject<ReactFlowInstance<FlowNode, FlowEdge> | null>;
onRun: (mode: string) => Promise<void>;
workflowId: number;
workflowUuid?: string;
saveWorkflow: (updateWorkflowDefinition?: boolean) => Promise<void>;
user: { id: string; email?: string };
onPhoneCallClick: () => void;
onTestAgentClick: () => void;
onHistoryClick: () => void;
activeVersionLabel?: string;
isViewingHistoricalVersion: boolean;
@ -57,8 +54,8 @@ export const WorkflowEditorHeader = ({
workflowValidationErrors,
rfInstance,
saveWorkflow,
onRun,
onPhoneCallClick,
onTestAgentClick,
onHistoryClick,
activeVersionLabel,
isViewingHistoricalVersion,
@ -301,7 +298,7 @@ export const WorkflowEditorHeader = ({
</div>
</div>
{/* Right section: Version + Unsaved indicator + Call button + Save button */}
{/* Right section: Version + status + tester/call actions + save */}
<div className="flex items-center gap-3">
{/* Read-only banner when viewing a historical version */}
{isViewingHistoricalVersion && (
@ -388,68 +385,6 @@ export const WorkflowEditorHeader = ({
</Popover>
)}
{/* Call button with dropdown (hidden when viewing history) */}
{!isViewingHistoricalVersion && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="flex items-center gap-2 bg-transparent border-[#3a3a3a] hover:bg-[#2a2a2a] text-white"
disabled={isCallDisabled}
>
<Phone className="w-4 h-4" />
Call
<ChevronDown className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-[#1a1a1a] border-[#3a3a3a]">
<DropdownMenuItem
onClick={() => {
posthog.capture(PostHogEvent.WEB_CALL_INITIATED, {
workflow_id: workflowId,
workflow_name: workflowName,
});
onRun(WORKFLOW_RUN_MODES.SMALL_WEBRTC);
}}
className="text-white hover:bg-[#2a2a2a] cursor-pointer"
>
<Phone className="w-4 h-4 mr-2" />
Web Call
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
// Delay opening dialog to next event cycle to allow DropdownMenu
// to clean up first, preventing pointer-events: none stuck on body
// See: https://github.com/radix-ui/primitives/issues/1241
setTimeout(onPhoneCallClick, 0);
}}
className="text-white hover:bg-[#2a2a2a] cursor-pointer"
>
<Phone className="w-4 h-4 mr-2" />
Phone Call
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Save button (only shown when editing the draft) */}
{!isViewingHistoricalVersion && (
<Button
onClick={handleSave}
disabled={!isDirty || savingWorkflow}
className="bg-teal-600 hover:bg-teal-700 text-white px-4"
>
{savingWorkflow ? (
<>
<LoaderCircle className="w-4 h-4 mr-2 animate-spin" />
Saving...
</>
) : (
"Save"
)}
</Button>
)}
{/* Publish button (only when on draft with no unsaved changes) */}
{!isViewingHistoricalVersion && hasDraft && (
<Button
@ -472,6 +407,45 @@ export const WorkflowEditorHeader = ({
</Button>
)}
{!isViewingHistoricalVersion && (
<Button
variant="outline"
className="flex items-center gap-2 bg-transparent border-[#3a3a3a] hover:bg-[#2a2a2a] text-white"
disabled={isCallDisabled}
onClick={onPhoneCallClick}
>
<Phone className="w-4 h-4" />
Phone Call
</Button>
)}
<Button
variant="outline"
className="flex items-center gap-2 bg-transparent border-[#3a3a3a] hover:bg-[#2a2a2a] text-white"
onClick={onTestAgentClick}
>
<Bot className="w-4 h-4" />
Test Agent
</Button>
{/* Save button (only shown when editing the draft) */}
{!isViewingHistoricalVersion && (
<Button
onClick={handleSave}
disabled={!isDirty || savingWorkflow}
className="bg-teal-600 hover:bg-teal-700 text-white px-4"
>
{savingWorkflow ? (
<>
<LoaderCircle className="w-4 h-4 mr-2 animate-spin" />
Saving...
</>
) : (
"Save"
)}
</Button>
)}
{/* More options dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>

View file

@ -0,0 +1,288 @@
"use client";
import { Loader2, MessageSquareText, Mic, Phone, RefreshCw, X } from "lucide-react";
import posthog from "posthog-js";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { createWorkflowRunApiV1WorkflowWorkflowIdRunsPost } from "@/client/sdk.gen";
import { OnboardingTooltip } from "@/components/onboarding/OnboardingTooltip";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { PostHogEvent } from "@/constants/posthog-events";
import { WORKFLOW_RUN_MODES } from "@/constants/workflowRunModes";
import { useOnboarding } from "@/context/OnboardingContext";
import { useAuth } from "@/lib/auth";
import { cn, getRandomId } from "@/lib/utils";
import { AiSimulatorPlaceholder } from "./workflow-tester/AiSimulatorPlaceholder";
import { EmbeddedVoiceTester } from "./workflow-tester/EmbeddedVoiceTester";
import { ManualTextChatPanel } from "./workflow-tester/ManualTextChatPanel";
import { ChatModeToggle, DisabledNotice, EmptyState, RuntimeFocusToggle } from "./workflow-tester/shared";
import type { WorkflowRuntimeFocusMode, WorkflowRuntimeNodeTransition } from "./workflow-tester/types";
import { extractSdkErrorMessage, getErrorMessage } from "./workflow-tester/utils";
interface WorkflowTesterPanelProps {
workflowId: number;
initialContextVariables?: Record<string, string>;
disabled: boolean;
disabledReason: string | null;
showWebCallOnboarding?: boolean;
isVisible?: boolean;
className?: string;
onClose?: () => void;
runtimeFocusMode: WorkflowRuntimeFocusMode;
onRuntimeFocusModeChange: (mode: WorkflowRuntimeFocusMode) => void;
onRuntimeNodeTransition?: (transition: WorkflowRuntimeNodeTransition) => void;
}
export function WorkflowTesterPanel({
workflowId,
initialContextVariables,
disabled,
disabledReason,
showWebCallOnboarding = false,
isVisible = true,
className,
onClose,
runtimeFocusMode,
onRuntimeFocusModeChange,
onRuntimeNodeTransition,
}: WorkflowTesterPanelProps) {
const auth = useAuth();
const { hasSeenTooltip, markTooltipSeen, markActionCompleted } = useOnboarding();
const { isAuthenticated, loading: authLoading, getAccessToken } = auth;
const [accessToken, setAccessToken] = useState<string | null>(null);
const [activeMode, setActiveMode] = useState<"audio" | "text">("audio");
const [chatMode, setChatMode] = useState<"manual" | "simulated">("manual");
const [chatSessionKey, setChatSessionKey] = useState(0);
const [chatActive, setChatActive] = useState(false);
const [voiceRunId, setVoiceRunId] = useState<number | null>(null);
const [creatingVoiceRun, setCreatingVoiceRun] = useState(false);
const [tokenReady, setTokenReady] = useState(false);
const runTestButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
let ignore = false;
const hydrateAccessToken = async () => {
if (!isAuthenticated || authLoading) return;
try {
const token = await getAccessToken();
if (!ignore) {
setAccessToken(token);
}
} catch (error) {
if (!ignore) {
toast.error(getErrorMessage(error));
}
} finally {
if (!ignore) {
setTokenReady(true);
}
}
};
if (authLoading) {
return;
}
if (!isAuthenticated) {
setTokenReady(true);
return;
}
hydrateAccessToken();
return () => {
ignore = true;
};
}, [authLoading, getAccessToken, isAuthenticated]);
const createVoiceRun = useCallback(async () => {
if (!accessToken || disabled) return;
setCreatingVoiceRun(true);
try {
const response = await createWorkflowRunApiV1WorkflowWorkflowIdRunsPost({
path: { workflow_id: workflowId },
body: {
mode: WORKFLOW_RUN_MODES.SMALL_WEBRTC,
name: `WR-${getRandomId()}`,
},
});
if (response.error || !response.data?.id) {
throw new Error(extractSdkErrorMessage(response.error, "Failed to create browser test run"));
}
markActionCompleted("web_call_started");
markTooltipSeen("web_call");
posthog.capture(PostHogEvent.WEB_CALL_INITIATED, {
workflow_id: workflowId,
workflow_run_id: response.data.id,
source: "workflow_editor",
});
setVoiceRunId(response.data.id);
setActiveMode("audio");
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setCreatingVoiceRun(false);
}
}, [accessToken, disabled, markActionCompleted, markTooltipSeen, workflowId]);
const authUnavailableReason = tokenReady && !accessToken
? "Authentication is required before testing can start."
: null;
const effectiveDisabledReason = disabledReason ?? authUnavailableReason;
const testerBlocked = disabled || authUnavailableReason !== null;
const showRunTestTooltip =
showWebCallOnboarding &&
isVisible &&
activeMode === "audio" &&
!voiceRunId &&
tokenReady &&
!!accessToken &&
!testerBlocked &&
!hasSeenTooltip("web_call");
return (
<div className={cn("flex h-full min-h-0 flex-col bg-background", className)}>
<Tabs
value={activeMode}
onValueChange={(value) => setActiveMode(value as "audio" | "text")}
className="min-h-0 flex-1 gap-0"
>
<div className="border-b border-border/70 px-4 py-3">
<div className="flex items-center gap-3">
<TabsList className="grid h-9 flex-1 grid-cols-2 rounded-lg bg-muted/60 p-1">
<TabsTrigger value="audio" className="rounded-md text-sm">
<Mic className="h-4 w-4" />
Test Audio
</TabsTrigger>
<TabsTrigger value="text" className="rounded-md text-sm">
<MessageSquareText className="h-4 w-4" />
Test Chat
</TabsTrigger>
</TabsList>
{onClose ? (
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="shrink-0 text-muted-foreground hover:text-foreground"
aria-label="Close tester panel"
>
<X className="h-4 w-4" />
</Button>
) : null}
</div>
<div className="mt-3 flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">Canvas sync</p>
<RuntimeFocusToggle
value={runtimeFocusMode}
onChange={onRuntimeFocusModeChange}
/>
</div>
</div>
<TabsContent value="audio" className="min-h-0 flex-1 px-4 py-4">
<div className="flex h-full min-h-0 flex-col gap-3">
{!tokenReady ? (
<div className="space-y-4">
<Skeleton className="h-14 rounded-xl" />
<Skeleton className="h-80 rounded-xl" />
</div>
) : !accessToken ? (
<DisabledNotice
reason={authUnavailableReason ?? "Authentication is required before browser tests can start."}
/>
) : voiceRunId ? (
<EmbeddedVoiceTester
workflowId={workflowId}
workflowRunId={voiceRunId}
initialContextVariables={initialContextVariables}
accessToken={accessToken}
onReset={() => setVoiceRunId(null)}
onNodeTransition={onRuntimeNodeTransition}
/>
) : (
<>
{effectiveDisabledReason ? <DisabledNotice reason={effectiveDisabledReason} /> : null}
<EmptyState
icon={<Phone className="h-7 w-7" />}
title="Call this agent in the browser"
description="Test the agent over a voice call. Some telephony-only tools, like call transfer, are not yet supported here."
action={
<Button
ref={runTestButtonRef}
onClick={createVoiceRun}
disabled={creatingVoiceRun || testerBlocked}
>
{creatingVoiceRun ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Starting test...
</>
) : (
<>
<Phone className="h-4 w-4" />
Run Test
</>
)}
</Button>
}
/>
</>
)}
</div>
</TabsContent>
<TabsContent value="text" className="min-h-0 flex-1 px-4 py-3">
<div className="flex h-full min-h-0 flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<ChatModeToggle value={chatMode} onChange={setChatMode} />
{chatMode === "manual" && chatActive ? (
<Button
variant="ghost"
size="sm"
onClick={() => setChatSessionKey((value) => value + 1)}
disabled={testerBlocked}
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
>
<RefreshCw className="h-3.5 w-3.5" />
Reset
</Button>
) : null}
</div>
{chatMode === "manual" ? (
<ManualTextChatPanel
key={chatSessionKey}
workflowId={workflowId}
ready={tokenReady && !!accessToken}
initialContextVariables={initialContextVariables}
disabled={testerBlocked}
disabledReason={effectiveDisabledReason}
onActiveChange={setChatActive}
onNodeTransition={onRuntimeNodeTransition}
/>
) : (
<AiSimulatorPlaceholder disabledReason={effectiveDisabledReason} />
)}
</div>
</TabsContent>
</Tabs>
<OnboardingTooltip
targetRef={runTestButtonRef}
title="Try Your First Web Call"
message="Start a browser call here to hear the agent, inspect the transcript, and validate the workflow before you customize it further."
onDismiss={() => markTooltipSeen("web_call")}
showNext={false}
isVisible={showRunTestTooltip}
/>
</div>
);
}

View file

@ -0,0 +1,38 @@
"use client";
import { Sparkles } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { DisabledNotice } from "./shared";
export function AiSimulatorPlaceholder({
disabledReason,
}: {
disabledReason: string | null;
}) {
const [simulatorPrompt, setSimulatorPrompt] = useState(
"Act like a skeptical prospect. Push on pricing, ask about integrations, and end the chat if the assistant becomes repetitive.",
);
return (
<div className="flex min-h-0 flex-1 flex-col gap-3">
{disabledReason ? <DisabledNotice reason={disabledReason} /> : null}
<p className="text-sm text-muted-foreground">
Drive multi-turn, agent-vs-agent tests with a persona prompt.
</p>
<Textarea
value={simulatorPrompt}
onChange={(event) => setSimulatorPrompt(event.target.value)}
placeholder="Describe the simulated user..."
className="min-h-32 resize-none text-sm leading-6"
/>
<Button size="sm" disabled className="self-start">
<Sparkles className="h-4 w-4" />
Coming soon
</Button>
</div>
);
}

View file

@ -0,0 +1,82 @@
"use client";
import { Loader2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
interface ChatComposerProps {
composerId: string;
draft: string;
ready: boolean;
editing: boolean;
sendingMessage: boolean;
inputDisabled: boolean;
onDraftChange: (value: string) => void;
onCancelEditing: () => void;
onSubmit: () => Promise<void> | void;
}
export function ChatComposer({
composerId,
draft,
ready,
editing,
sendingMessage,
inputDisabled,
onDraftChange,
onCancelEditing,
onSubmit,
}: ChatComposerProps) {
return (
<div className="pt-3">
{editing ? (
<div className="mb-2 flex items-center justify-between gap-2 rounded-lg border border-border/70 bg-muted/35 px-3 py-2 text-xs text-muted-foreground">
<span>Edit the selected user message, then press Enter to rerun from that point.</span>
<button
type="button"
onClick={onCancelEditing}
className="inline-flex items-center gap-1 rounded text-foreground hover:text-foreground/80 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<X className="h-3.5 w-3.5" />
Cancel
</button>
</div>
) : null}
<div className="relative">
<Textarea
id={composerId}
value={draft}
onChange={(event) => onDraftChange(event.target.value)}
placeholder={ready ? (editing ? "Edit and rerun this message..." : "Send a message...") : "Preparing chat..."}
rows={1}
className="min-h-11! resize-none pr-20 text-sm leading-6"
disabled={inputDisabled}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
if (sendingMessage) return;
void onSubmit();
}
}}
/>
<Button
type="button"
size="sm"
onClick={() => void onSubmit()}
disabled={inputDisabled || sendingMessage || !draft.trim()}
className="absolute bottom-1.5 right-1.5 h-8 px-4"
>
{sendingMessage ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{editing ? "Rerunning" : "Sending"}
</>
) : (
editing ? "Rerun" : "Send"
)}
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,162 @@
"use client";
import { Loader2, Phone, RefreshCw } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import { RealtimeFeedback } from "@/components/workflow/conversation";
import { ApiKeyErrorDialog, ConnectionStatus, WorkflowConfigErrorDialog } from "../../run/[runId]/components";
import { useWebSocketRTC } from "../../run/[runId]/hooks";
import type { WorkflowRuntimeNodeTransition } from "./types";
interface EmbeddedVoiceTesterProps {
workflowId: number;
workflowRunId: number;
initialContextVariables?: Record<string, string>;
accessToken: string;
onReset: () => void;
onNodeTransition?: (transition: WorkflowRuntimeNodeTransition) => void;
}
export function EmbeddedVoiceTester({
workflowId,
workflowRunId,
initialContextVariables,
accessToken,
onReset,
onNodeTransition,
}: EmbeddedVoiceTesterProps) {
const router = useRouter();
const {
audioRef,
connectionActive,
permissionError,
isCompleted,
apiKeyModalOpen,
setApiKeyModalOpen,
apiKeyError,
apiKeyErrorCode,
workflowConfigError,
workflowConfigModalOpen,
setWorkflowConfigModalOpen,
connectionStatus,
start,
stop,
isStarting,
feedbackMessages,
} = useWebSocketRTC({
workflowId,
workflowRunId,
accessToken,
initialContextVariables,
onNodeTransition,
});
const autoStartedRef = useRef(false);
useEffect(() => {
if (autoStartedRef.current) {
return;
}
autoStartedRef.current = true;
void start();
}, [start]);
const endButtonLabel = connectionActive
? "End Call"
: isCompleted
? "Start Another Test"
: connectionStatus === "failed"
? "Retry Call"
: "Starting Test...";
const handleFooterAction = async () => {
if (connectionActive) {
stop();
return;
}
if (isCompleted) {
onReset();
return;
}
if (connectionStatus === "failed") {
await start();
}
};
return (
<>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-border/70 bg-background">
<div className="min-h-0 flex-1 overflow-hidden bg-muted/15">
<RealtimeFeedback
mode="live"
messages={feedbackMessages}
isCallActive={connectionActive}
isCallCompleted={isCompleted}
/>
</div>
<div className="border-t border-border/70 bg-background px-4 py-3">
<div className="flex flex-col gap-3">
<ConnectionStatus connectionStatus={connectionStatus} />
{permissionError ? (
<p className="text-center text-sm text-destructive">{permissionError}</p>
) : null}
<Button
onClick={handleFooterAction}
disabled={isStarting && connectionStatus !== "failed"}
variant={connectionActive ? "destructive" : "default"}
className="w-full"
>
{isStarting && connectionStatus !== "failed" ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Starting Test...
</>
) : connectionActive ? (
<>
<Phone className="h-4 w-4" />
{endButtonLabel}
</>
) : connectionStatus === "failed" ? (
<>
<RefreshCw className="h-4 w-4" />
{endButtonLabel}
</>
) : isCompleted ? (
<>
<RefreshCw className="h-4 w-4" />
{endButtonLabel}
</>
) : (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{endButtonLabel}
</>
)}
</Button>
</div>
</div>
<audio ref={audioRef} autoPlay playsInline className="hidden" />
</div>
<ApiKeyErrorDialog
open={apiKeyModalOpen}
onOpenChange={setApiKeyModalOpen}
error={apiKeyError}
errorCode={apiKeyErrorCode}
onNavigateToCredits={() => router.push("/api-keys")}
onNavigateToModelConfig={() => router.push("/model-configurations")}
/>
<WorkflowConfigErrorDialog
open={workflowConfigModalOpen}
onOpenChange={setWorkflowConfigModalOpen}
error={workflowConfigError}
onNavigateToWorkflow={() => router.push(`/workflow/${workflowId}`)}
/>
</>
);
}

View file

@ -0,0 +1,145 @@
"use client";
import { Skeleton } from "@/components/ui/skeleton";
import type { ConversationItem } from "@/components/workflow/conversation";
import { ConversationTimeline } from "@/components/workflow/conversation";
import { ChatComposer } from "./ChatComposer";
import { DisabledNotice, ManualChatEmptyState, TypingIndicator } from "./shared";
import { TurnMessageActions } from "./TurnMessageActions";
import type { WorkflowRuntimeNodeTransition } from "./types";
import { useTextChatSession } from "./useTextChatSession";
interface ManualTextChatPanelProps {
workflowId: number;
ready: boolean;
initialContextVariables?: Record<string, string>;
disabled: boolean;
disabledReason: string | null;
onActiveChange?: (active: boolean) => void;
onNodeTransition?: (transition: WorkflowRuntimeNodeTransition) => void;
}
export function ManualTextChatPanel({
workflowId,
ready,
initialContextVariables,
disabled,
disabledReason,
onActiveChange,
onNodeTransition,
}: ManualTextChatPanelProps) {
const {
session,
started,
draft,
turns,
editingTurn,
editingTurnId,
creatingSession,
sendingMessage,
activeTurnAction,
composerId,
inputDisabled,
conversationItems,
setDraft,
startSession,
rewindTurn,
startEditingTurn,
cancelEditingTurn,
submitComposer,
} = useTextChatSession({
workflowId,
ready,
initialContextVariables,
disabled,
onActiveChange,
onNodeTransition,
});
if (!started && !session) {
return (
<div className="flex h-full min-h-0 flex-col gap-3">
{disabledReason ? <DisabledNotice reason={disabledReason} /> : null}
<ManualChatEmptyState disabled={disabled} ready={ready} onStart={startSession} />
</div>
);
}
return (
<div className="flex min-h-0 flex-1 flex-col">
{disabledReason ? (
<div className="pb-3">
<DisabledNotice reason={disabledReason} />
</div>
) : null}
<div className="flex min-h-0 flex-1 flex-col">
{creatingSession && !session ? (
<div className="space-y-3 py-1">
<Skeleton className="ml-auto h-9 w-2/3 rounded-2xl" />
<Skeleton className="h-12 w-3/4 rounded-2xl" />
</div>
) : turns.length === 0 ? (
<div className="flex h-full items-center justify-center px-4 py-10 text-center">
<p className="text-sm text-muted-foreground">
{disabled
? (disabledReason ?? "Testing is paused.")
: "Send a message to start the conversation."}
</p>
</div>
) : (
<ConversationTimeline
items={conversationItems}
autoScroll={true}
scrollBehavior="smooth"
emptyState={{
title: "No conversation recorded",
subtitle: "Send a message to start the conversation.",
}}
pendingIndicator={sendingMessage ? <TypingIndicator /> : null}
className="py-1"
renderItemActions={(item: ConversationItem) => {
if (item.kind !== "message" || item.role !== "user" || !item.turnId) {
return null;
}
const turn = turns.find((candidate) => candidate.id === item.turnId);
if (!turn?.user_message) {
return null;
}
const rewindingThisTurn =
activeTurnAction?.turnId === turn.id && activeTurnAction.type === "rewind";
const rerunningEditedTurn =
activeTurnAction?.turnId === turn.id && activeTurnAction.type === "edit";
return (
<TurnMessageActions
disabled={disabled || sendingMessage}
editing={editingTurnId === turn.id}
rewinding={rewindingThisTurn}
rerunningEdit={rerunningEditedTurn}
onRewind={() => void rewindTurn(turn)}
onEdit={() => startEditingTurn(turn)}
/>
);
}}
/>
)}
</div>
<ChatComposer
composerId={composerId}
draft={draft}
ready={ready}
editing={!!editingTurn}
sendingMessage={sendingMessage}
inputDisabled={inputDisabled}
onDraftChange={setDraft}
onCancelEditing={cancelEditingTurn}
onSubmit={submitComposer}
/>
</div>
);
}

View file

@ -0,0 +1,59 @@
"use client";
import { Loader2, Pencil, RotateCcw } from "lucide-react";
import { cn } from "@/lib/utils";
interface TurnMessageActionsProps {
disabled: boolean;
editing: boolean;
rewinding: boolean;
rerunningEdit: boolean;
onRewind: () => void;
onEdit: () => void;
}
export function TurnMessageActions({
disabled,
editing,
rewinding,
rerunningEdit,
onRewind,
onEdit,
}: TurnMessageActionsProps) {
return (
<>
<button
type="button"
onClick={onRewind}
disabled={disabled}
aria-label="Rerun this turn"
title="Rerun this turn"
className="inline-flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
>
{rewinding ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RotateCcw className="h-3.5 w-3.5" />
)}
</button>
<button
type="button"
onClick={onEdit}
disabled={disabled}
aria-label="Edit and rerun this turn"
title="Edit and rerun this turn"
className={cn(
"inline-flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50",
editing && "bg-muted text-foreground",
)}
>
{rerunningEdit ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Pencil className="h-3.5 w-3.5" />
)}
</button>
</>
);
}

View file

@ -0,0 +1,156 @@
"use client";
import { AlertCircle, MessageSquareText } from "lucide-react";
import type { ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export function DisabledNotice({ reason }: { reason: string }) {
return (
<div className="rounded-lg border border-amber-200/80 bg-amber-50/80 px-3 py-2.5 text-sm text-amber-900 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-200">
<div className="flex items-start gap-3">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<div className="space-y-0.5">
<p className="font-medium">Testing is paused</p>
<p className="text-amber-800/90 dark:text-amber-300">{reason}</p>
</div>
</div>
</div>
);
}
export function EmptyState({
icon,
title,
description,
action,
}: {
icon: ReactNode;
title: string;
description: string;
action?: ReactNode;
}) {
return (
<div className="flex flex-1 flex-col justify-center rounded-xl border border-border/70 bg-background px-5 py-6 text-left">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted text-muted-foreground">
{icon}
</div>
<div className="mt-4 space-y-1.5">
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
<p className="text-sm leading-6 text-muted-foreground">{description}</p>
</div>
{action ? <div className="mt-5">{action}</div> : null}
</div>
);
}
export function ChatModeToggle({
value,
onChange,
}: {
value: "manual" | "simulated";
onChange: (next: "manual" | "simulated") => void;
}) {
const options: Array<{ id: "manual" | "simulated"; label: string }> = [
{ id: "manual", label: "Manual" },
{ id: "simulated", label: "Simulated" },
];
return (
<div className="inline-flex items-center gap-0.5 rounded-md border border-border/70 bg-muted/40 p-0.5">
{options.map((option) => {
const active = option.id === value;
return (
<button
key={option.id}
type="button"
onClick={() => onChange(option.id)}
className={cn(
"rounded-[5px] px-2.5 py-1 text-xs font-medium transition",
active
? "bg-background text-foreground shadow-xs"
: "text-muted-foreground hover:text-foreground",
)}
>
{option.label}
</button>
);
})}
</div>
);
}
export function RuntimeFocusToggle({
value,
onChange,
}: {
value: "pulse" | "follow";
onChange: (next: "pulse" | "follow") => void;
}) {
const options: Array<{ id: "pulse" | "follow"; label: string }> = [
{ id: "pulse", label: "Pulse" },
{ id: "follow", label: "Follow" },
];
return (
<div className="inline-flex items-center gap-0.5 rounded-md border border-border/70 bg-muted/40 p-0.5">
{options.map((option) => {
const active = option.id === value;
return (
<button
key={option.id}
type="button"
onClick={() => onChange(option.id)}
className={cn(
"rounded-[5px] px-2.5 py-1 text-xs font-medium transition",
active
? "bg-background text-foreground shadow-xs"
: "text-muted-foreground hover:text-foreground",
)}
>
{option.label}
</button>
);
})}
</div>
);
}
export function TypingIndicator() {
return (
<div className="flex justify-start">
<div className="rounded-2xl rounded-bl-md bg-muted px-3.5 py-3">
<div className="flex items-center gap-1">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.3s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.15s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground/60" />
</div>
</div>
</div>
);
}
export function ManualChatEmptyState({
disabled,
ready,
onStart,
}: {
disabled: boolean;
ready: boolean;
onStart: () => void;
}) {
return (
<EmptyState
icon={<MessageSquareText className="h-7 w-7" />}
title="Chat with this agent"
description="Test the agent over a text conversation. Send messages and see how it responds, with tool calls, transitions, and rewind support."
action={
<Button onClick={onStart} disabled={disabled || !ready}>
<MessageSquareText className="h-4 w-4" />
Start Test
</Button>
}
/>
);
}

View file

@ -0,0 +1,62 @@
import type { WorkflowRunTextSessionResponse } from "@/client/types.gen";
import type { ConversationNodeTransitionItem } from "@/components/workflow/conversation";
export interface TextChatMessage {
text: string;
created_at: string;
}
export interface TextChatTurn {
id: string;
status: string;
created_at: string;
user_message: TextChatMessage | null;
assistant_message: TextChatMessage | null;
events: Array<Record<string, unknown>>;
usage: Record<string, unknown>;
}
export interface TextChatSessionData {
version: number;
status: string;
cursor_turn_id: string | null;
turns: TextChatTurn[];
discarded_future: Array<Record<string, unknown>>;
simulator: {
enabled: boolean;
config: Record<string, unknown>;
};
}
export interface TextChatCheckpoint {
version: number;
anchor_turn_id: string | null;
current_node_id: string | null;
messages: Array<Record<string, unknown>>;
gathered_context: Record<string, unknown>;
tool_state: Record<string, unknown>;
}
export type TextChatSession = Omit<WorkflowRunTextSessionResponse, "session_data" | "checkpoint"> & {
session_data: TextChatSessionData;
checkpoint: TextChatCheckpoint;
};
export interface TurnActionState {
turnId: string;
type: "rewind" | "edit";
}
export type WorkflowRuntimeFocusMode = "pulse" | "follow";
export type WorkflowRuntimeNodeTransition = ConversationNodeTransitionItem;
export const EMPTY_TEXT_CHAT_TURNS: TextChatTurn[] = [];
export function toTextChatSession(response: WorkflowRunTextSessionResponse): TextChatSession {
return {
...response,
session_data: response.session_data as unknown as TextChatSessionData,
checkpoint: response.checkpoint as unknown as TextChatCheckpoint,
};
}

View file

@ -0,0 +1,234 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import {
appendTextChatMessageApiV1WorkflowWorkflowIdTextChatSessionsRunIdMessagesPost,
createTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsPost,
rewindTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdRewindPost,
} from "@/client/sdk.gen";
import { conversationItemsFromTextChatTurns } from "@/components/workflow/conversation/adapters/fromTextChatTurns";
import {
EMPTY_TEXT_CHAT_TURNS,
type TextChatSession,
type TextChatTurn,
toTextChatSession,
type TurnActionState,
type WorkflowRuntimeNodeTransition,
} from "./types";
import { extractSdkErrorMessage, getErrorMessage, getReplayCursorTurnId } from "./utils";
interface UseTextChatSessionProps {
workflowId: number;
ready: boolean;
initialContextVariables?: Record<string, string>;
disabled: boolean;
onActiveChange?: (active: boolean) => void;
onNodeTransition?: (transition: WorkflowRuntimeNodeTransition) => void;
}
export function useTextChatSession({
workflowId,
ready,
initialContextVariables,
disabled,
onActiveChange,
onNodeTransition,
}: UseTextChatSessionProps) {
const [session, setSession] = useState<TextChatSession | null>(null);
const [started, setStarted] = useState(false);
const [draft, setDraft] = useState("");
const [creatingSession, setCreatingSession] = useState(false);
const [sendingMessage, setSendingMessage] = useState(false);
const [editingTurnId, setEditingTurnId] = useState<string | null>(null);
const [activeTurnAction, setActiveTurnAction] = useState<TurnActionState | null>(null);
const lastNotifiedNodeTransitionIdRef = useRef<string | null>(null);
const turns = session?.session_data.turns ?? EMPTY_TEXT_CHAT_TURNS;
const editingTurn = editingTurnId
? turns.find((turn) => turn.id === editingTurnId) ?? null
: null;
const composerId = `workflow-tester-compose-${workflowId}`;
const conversationItems = conversationItemsFromTextChatTurns(turns);
const createSession = useCallback(async () => {
if (disabled) return;
setCreatingSession(true);
try {
const response = await createTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsPost({
path: { workflow_id: workflowId },
body: {
initial_context: initialContextVariables ?? {},
annotations: {
tester: {
source: "workflow_editor",
modality: "text",
ui_mode: "manual_text",
},
},
},
});
if (response.error || !response.data) {
throw new Error(extractSdkErrorMessage(response.error, "Failed to create chat session"));
}
setSession(toTextChatSession(response.data));
setDraft("");
} catch (error) {
setSession(null);
setStarted(false);
toast.error(getErrorMessage(error));
} finally {
setCreatingSession(false);
}
}, [disabled, initialContextVariables, workflowId]);
useEffect(() => {
if (!started || creatingSession || session || !ready || disabled) {
return;
}
void createSession();
}, [createSession, creatingSession, disabled, ready, session, started]);
useEffect(() => {
onActiveChange?.(started);
}, [onActiveChange, started]);
useEffect(() => {
const latestNodeTransition = [...conversationItems]
.reverse()
.find(
(item): item is WorkflowRuntimeNodeTransition =>
item.kind === "node-transition" && !!item.nodeId,
);
if (!latestNodeTransition?.nodeId) {
return;
}
if (lastNotifiedNodeTransitionIdRef.current === latestNodeTransition.id) {
return;
}
lastNotifiedNodeTransitionIdRef.current = latestNodeTransition.id;
onNodeTransition?.(latestNodeTransition);
}, [conversationItems, onNodeTransition]);
useEffect(() => {
if (!editingTurnId) {
return;
}
if (!turns.some((turn) => turn.id === editingTurnId)) {
setEditingTurnId(null);
setDraft("");
}
}, [editingTurnId, turns]);
const submitMessage = useCallback(async (messageText: string, replayOptions?: TurnActionState) => {
const trimmedText = messageText.trim();
if (!session || !trimmedText || disabled) return;
setSendingMessage(true);
if (replayOptions) {
setActiveTurnAction(replayOptions);
}
try {
let activeSession = session;
if (replayOptions) {
const rewindResponse = await rewindTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdRewindPost({
path: { workflow_id: workflowId, run_id: activeSession.workflow_run_id },
body: {
cursor_turn_id: getReplayCursorTurnId(activeSession.session_data.turns, replayOptions.turnId),
expected_revision: activeSession.revision,
},
});
if (rewindResponse.error || !rewindResponse.data) {
throw new Error(extractSdkErrorMessage(rewindResponse.error, "Failed to rewind session"));
}
activeSession = toTextChatSession(rewindResponse.data);
setSession(activeSession);
}
const response = await appendTextChatMessageApiV1WorkflowWorkflowIdTextChatSessionsRunIdMessagesPost({
path: { workflow_id: workflowId, run_id: activeSession.workflow_run_id },
body: {
text: trimmedText,
expected_revision: activeSession.revision,
},
});
if (response.error || !response.data) {
throw new Error(extractSdkErrorMessage(response.error, "Failed to send message"));
}
setSession(toTextChatSession(response.data));
setDraft("");
setEditingTurnId(null);
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setSendingMessage(false);
setActiveTurnAction(null);
}
}, [disabled, session, workflowId]);
const rewindTurn = useCallback(async (turn: TextChatTurn) => {
if (!turn.user_message) return;
await submitMessage(turn.user_message.text, { turnId: turn.id, type: "rewind" });
}, [submitMessage]);
const startEditingTurn = useCallback((turn: TextChatTurn) => {
if (!turn.user_message) return;
const nextText = turn.user_message.text;
setEditingTurnId(turn.id);
setDraft(nextText);
requestAnimationFrame(() => {
const textarea = document.getElementById(composerId) as HTMLTextAreaElement | null;
textarea?.focus();
textarea?.setSelectionRange(nextText.length, nextText.length);
});
}, [composerId]);
const cancelEditingTurn = useCallback(() => {
setEditingTurnId(null);
setDraft("");
}, []);
const submitComposer = useCallback(async () => {
if (editingTurnId) {
await submitMessage(draft, { turnId: editingTurnId, type: "edit" });
return;
}
await submitMessage(draft);
}, [draft, editingTurnId, submitMessage]);
return {
session,
started,
draft,
turns,
editingTurn,
editingTurnId,
creatingSession,
sendingMessage,
activeTurnAction,
composerId,
inputDisabled: disabled || !session,
conversationItems,
setDraft,
startSession: () => setStarted(true),
rewindTurn,
startEditingTurn,
cancelEditingTurn,
submitComposer,
};
}

View file

@ -0,0 +1,29 @@
export function getErrorMessage(error: unknown) {
if (error instanceof Error) return error.message;
return "Something went wrong";
}
export function extractSdkErrorMessage(error: unknown, fallback: string) {
if (!error) return fallback;
if (typeof error === "string") return error;
if (typeof error === "object") {
const detail = (error as { detail?: unknown }).detail;
if (typeof detail === "string") return detail;
if (
detail &&
typeof detail === "object" &&
typeof (detail as { message?: unknown }).message === "string"
) {
return (detail as { message: string }).message;
}
}
return fallback;
}
export function getReplayCursorTurnId(turns: Array<{ id: string }>, turnId: string) {
const turnIndex = turns.findIndex((turn) => turn.id === turnId);
if (turnIndex < 0) {
throw new Error("Turn not found");
}
return turns[turnIndex - 1]?.id ?? null;
}

View file

@ -1,6 +1,6 @@
'use client';
import { useParams } from 'next/navigation';
import { useParams, useSearchParams } from 'next/navigation';
import posthog from 'posthog-js';
import { useEffect, useMemo, useState } from 'react';
@ -18,6 +18,7 @@ import WorkflowLayout from '../WorkflowLayout';
export default function WorkflowDetailPage() {
const params = useParams();
const searchParams = useSearchParams();
const [workflow, setWorkflow] = useState<WorkflowResponse | undefined>(undefined);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -59,6 +60,7 @@ export default function WorkflowDetailPage() {
}, [params.workflowId, user]);
const stableUser = useMemo(() => user, [user]);
const openTesterOnLoad = searchParams.get('onboarding') === 'web_call';
if (loading) {
return (
@ -82,6 +84,8 @@ export default function WorkflowDetailPage() {
initialWorkflowName={workflow.name}
workflowId={workflow.id}
workflowUuid={workflow.workflow_uuid ?? undefined}
initialTotalRuns={workflow.total_runs ?? 0}
openTesterOnLoad={openTesterOnLoad}
initialFlow={{
nodes: workflow.workflow_definition.nodes as FlowNode[],
edges: workflow.workflow_definition.edges as FlowEdge[],

View file

@ -1,184 +0,0 @@
import { Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { getWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGet } from "@/client/sdk.gen";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useAuth } from "@/lib/auth";
import {
ApiKeyErrorDialog,
AudioControls,
ConnectionStatus,
RealtimeFeedback,
WorkflowConfigErrorDialog
} from "./components";
import { useWebSocketRTC } from "./hooks";
const BrowserCall = ({ workflowId, workflowRunId, initialContextVariables }: {
workflowId: number,
workflowRunId: number,
initialContextVariables?: Record<string, string> | null
}) => {
const router = useRouter();
const auth = useAuth();
const [accessToken, setAccessToken] = useState<string | null>(null);
const [checkingForRecording, setCheckingForRecording] = useState(false);
// Get access token for WebSocket connection (non-SDK usage)
useEffect(() => {
if (auth.isAuthenticated && !auth.loading) {
auth.getAccessToken().then(setAccessToken);
}
}, [auth]);
const {
audioRef,
audioInputs,
selectedAudioInput,
setSelectedAudioInput,
connectionActive,
permissionError,
isCompleted,
apiKeyModalOpen,
setApiKeyModalOpen,
apiKeyError,
apiKeyErrorCode,
workflowConfigError,
workflowConfigModalOpen,
setWorkflowConfigModalOpen,
connectionStatus,
start,
stop,
isStarting,
getAudioInputDevices,
feedbackMessages,
} = useWebSocketRTC({ workflowId, workflowRunId, accessToken, initialContextVariables });
// Poll for recording availability after call ends
useEffect(() => {
if (!isCompleted || !auth.isAuthenticated) return;
setCheckingForRecording(true);
const intervalId = setInterval(async () => {
try {
const response = await getWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGet({
path: {
workflow_id: workflowId,
run_id: workflowRunId,
},
});
if (response.data?.transcript_url || response.data?.recording_url) {
setCheckingForRecording(false);
clearInterval(intervalId);
// Refresh the page to show the recording
window.location.reload();
}
} catch (error) {
console.error('Error checking for recording:', error);
}
}, 5000); // Check every 5 seconds
// Clean up after 2 minutes
const timeoutId = setTimeout(() => {
clearInterval(intervalId);
setCheckingForRecording(false);
}, 120000);
return () => {
clearInterval(intervalId);
clearTimeout(timeoutId);
};
}, [isCompleted, auth.isAuthenticated, workflowId, workflowRunId]);
const navigateToCredits = () => {
router.push('/api-keys');
};
const navigateToModelConfig = () => {
router.push('/model-configurations');
};
const navigateToWorkflow = () => {
router.push(`/workflow/${workflowId}`)
}
return (
<>
<div className="flex h-screen w-full overflow-hidden">
{/* Main content - 2/3 width when panel visible, full width otherwise */}
<div className="w-2/3 h-full overflow-y-auto">
<div className="flex justify-center items-center h-full px-8">
<Card className="w-full max-w-xl">
<CardHeader>
<CardTitle>Call Voice Agent</CardTitle>
</CardHeader>
<CardContent>
{isCompleted && checkingForRecording ? (
<div className="flex flex-col items-center justify-center space-y-4 p-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<div className="text-center space-y-2">
<p className="text-foreground font-medium">Processing your call</p>
<p className="text-sm text-muted-foreground">Fetching transcript and recording...</p>
</div>
</div>
) : (
<>
<AudioControls
audioInputs={audioInputs}
selectedAudioInput={selectedAudioInput}
setSelectedAudioInput={setSelectedAudioInput}
isCompleted={isCompleted}
connectionActive={connectionActive}
permissionError={permissionError}
start={start}
stop={stop}
isStarting={isStarting}
getAudioInputDevices={getAudioInputDevices}
/>
<ConnectionStatus
connectionStatus={connectionStatus}
/>
</>
)}
</CardContent>
<audio ref={audioRef} autoPlay playsInline className="hidden" />
</Card>
</div>
</div>
{/* Show transcript panel */}
<div className="w-1/3 h-full shrink-0 overflow-hidden">
<RealtimeFeedback
mode="live"
messages={feedbackMessages}
isCallActive={connectionActive}
isCallCompleted={isCompleted}
/>
</div>
</div>
<ApiKeyErrorDialog
open={apiKeyModalOpen}
onOpenChange={setApiKeyModalOpen}
error={apiKeyError}
errorCode={apiKeyErrorCode}
onNavigateToCredits={navigateToCredits}
onNavigateToModelConfig={navigateToModelConfig}
/>
<WorkflowConfigErrorDialog
open={workflowConfigModalOpen}
onOpenChange={setWorkflowConfigModalOpen}
error={workflowConfigError}
onNavigateToWorkflow={navigateToWorkflow}
/>
</>
);
};
export default BrowserCall;

View file

@ -1,174 +0,0 @@
'use client';
import { FeedbackMessage } from '../hooks/useWebSocketRTC';
import { processLiveMessages, processTranscriptEvents, TranscriptEvent } from '../utils/processTranscriptEvents';
import { UnifiedTranscript } from './UnifiedTranscript';
// Historical log event format from the backend
interface RealtimeFeedbackEvent {
type: string;
payload: {
text?: string;
final?: boolean;
user_id?: string;
timestamp?: string;
function_name?: string;
tool_call_id?: string;
result?: string;
node_name?: string;
previous_node?: string;
allow_interrupt?: boolean;
ttfb_seconds?: number;
processor?: string;
model?: string;
error?: string;
fatal?: boolean;
};
timestamp: string;
turn: number;
}
export interface WorkflowRunLogs {
realtime_feedback_events?: RealtimeFeedbackEvent[];
}
// Props for live mode (WebSocket messages)
interface LiveModeProps {
mode: 'live';
messages: FeedbackMessage[];
isCallActive: boolean;
isCallCompleted: boolean;
}
// Props for historical mode (API logs)
interface HistoricalModeProps {
mode: 'historical';
logs: WorkflowRunLogs | null;
}
type RealtimeFeedbackProps = LiveModeProps | HistoricalModeProps;
/**
* Convert backend log events to unified TranscriptEvent format
*/
function convertLogEventsToTranscriptEvents(events: RealtimeFeedbackEvent[]): TranscriptEvent[] {
return events.map(event => {
let type: TranscriptEvent['type'];
let status: TranscriptEvent['status'];
switch (event.type) {
case 'rtf-user-transcription':
type = 'user-transcription';
break;
case 'rtf-bot-text':
type = 'bot-text';
break;
case 'rtf-function-call-start':
type = 'function-call';
status = 'running';
break;
case 'rtf-function-call-end':
type = 'function-call';
status = 'completed';
break;
case 'rtf-node-transition':
type = 'node-transition';
break;
case 'rtf-ttfb-metric':
type = 'ttfb-metric';
break;
case 'rtf-pipeline-error':
type = 'pipeline-error';
break;
case 'rtf-interrupt-warning':
type = 'interrupt-warning';
break;
default:
type = 'bot-text';
}
return {
type,
text: event.payload.text || event.payload.error || event.payload.result || event.payload.function_name || event.payload.node_name || '',
final: event.payload.final,
timestamp: event.timestamp,
turn: event.turn,
functionName: event.payload.function_name,
status,
nodeName: event.payload.node_name,
previousNode: event.payload.previous_node,
allowInterrupt: event.payload.allow_interrupt,
ttfbSeconds: event.payload.ttfb_seconds,
processor: event.payload.processor,
model: event.payload.model,
fatal: event.payload.fatal,
};
});
}
/**
* Convert live WebSocket messages to unified TranscriptEvent format
*/
function convertLiveMessagesToTranscriptEvents(messages: FeedbackMessage[]): TranscriptEvent[] {
return messages.map(msg => ({
type: msg.type,
text: msg.text,
final: msg.final,
timestamp: msg.timestamp,
functionName: msg.functionName,
status: msg.status,
nodeName: msg.nodeName,
previousNode: msg.previousNode,
allowInterrupt: msg.allowInterrupt,
ttfbSeconds: msg.ttfbSeconds,
processor: msg.processor,
model: msg.model,
fatal: msg.fatal,
}));
}
/**
* Single unified component that handles both live WebSocket messages
* and historical logs from the API.
*/
export const RealtimeFeedback = (props: RealtimeFeedbackProps) => {
if (props.mode === 'historical') {
// Historical mode - process logs from API
const rawEvents = props.logs?.realtime_feedback_events;
const messages = rawEvents
? processTranscriptEvents(convertLogEventsToTranscriptEvents(rawEvents))
: [];
return (
<UnifiedTranscript
messages={messages}
status="ended"
title="Call Transcript"
emptyState={{
title: "No conversation recorded",
subtitle: "Real-time feedback events were not captured for this call"
}}
/>
);
}
// Live mode - process WebSocket messages (optimized - messages already accumulated)
const { messages, isCallActive, isCallCompleted } = props;
const status = isCallActive ? 'live' : isCallCompleted ? 'ended' : 'ready';
const processedMessages = processLiveMessages(convertLiveMessagesToTranscriptEvents(messages));
return (
<UnifiedTranscript
messages={processedMessages}
status={status}
title="Live Transcript"
autoScroll={true}
emptyState={{
title: "No messages yet",
subtitle: isCallActive
? "Start speaking to see the transcript"
: "Start the call to begin the conversation"
}}
/>
);
};

View file

@ -1,98 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
import { ProcessedMessage } from "../utils/processTranscriptEvents";
import { TranscriptContainer } from "./shared/TranscriptContainer";
import { TranscriptEmptyState } from "./shared/TranscriptEmptyState";
import { TranscriptMessage, TranscriptMessageData } from "./shared/TranscriptMessage";
interface UnifiedTranscriptProps {
messages: ProcessedMessage[];
status: 'ready' | 'live' | 'ended';
title?: string;
autoScroll?: boolean;
emptyState?: {
title: string;
subtitle: string;
};
}
export const UnifiedTranscript = ({
messages,
status,
title,
autoScroll = false,
emptyState
}: UnifiedTranscriptProps) => {
const scrollRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom when new messages arrive (for live mode)
useEffect(() => {
if (autoScroll && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages, autoScroll]);
// Calculate message count (exclude system messages like function calls, node transitions, TTFB)
const messageCount = messages.filter(
m => m.type === 'user-transcription' || m.type === 'bot-text'
).length;
// Convert ProcessedMessage to TranscriptMessageData
const transcriptMessages: TranscriptMessageData[] = messages.map(msg => ({
id: msg.id,
type: msg.type,
text: msg.text,
final: msg.final,
functionName: msg.functionName,
status: msg.status,
nodeName: msg.nodeName,
allowInterrupt: msg.allowInterrupt,
ttfbSeconds: msg.ttfbSeconds,
fatal: msg.fatal,
}));
// Default empty state
const defaultEmptyState = {
title: status === 'live' ? "No messages yet" : "No conversation recorded",
subtitle: status === 'live'
? "Start speaking to see the transcript"
: "Real-time feedback events were not captured"
};
const emptyStateToShow = emptyState || defaultEmptyState;
return (
<TranscriptContainer
title={title || (status === 'live' ? 'Live Transcript' : 'Call Transcript')}
status={status}
messageCount={messageCount > 0 ? messageCount : undefined}
>
<div ref={scrollRef} className="flex-1 overflow-y-auto">
{messages.length === 0 ? (
<TranscriptEmptyState
title={emptyStateToShow.title}
subtitle={emptyStateToShow.subtitle}
/>
) : (
<div className="space-y-3 p-4">
{transcriptMessages.map((msg, index) => {
// Skip standalone TTFB metrics (they're rendered inline with bot text)
if (msg.type === 'ttfb-metric') {
return null;
}
return (
<TranscriptMessage
key={`${msg.id}-${index}`}
message={msg}
nextMessage={transcriptMessages[index + 1]}
/>
);
})}
</div>
)}
</div>
</TranscriptContainer>
);
};

View file

@ -2,5 +2,4 @@ export * from './ApiKeyErrorDialog';
export * from './AudioControls';
export * from './ConnectionStatus';
export * from './ContextDisplay';
export * from './RealtimeFeedback';
export * from './WorkflowConfigErrorDialog';

View file

@ -1,72 +0,0 @@
'use client';
import { MessageSquare, Mic, MicOff } from 'lucide-react';
import { ReactNode } from 'react';
import { cn } from '@/lib/utils';
type CallStatus = 'ready' | 'live' | 'ended';
interface TranscriptContainerProps {
title: string;
status: CallStatus;
children: ReactNode;
messageCount?: number;
}
const STATUS_CONFIG = {
ready: {
icon: MicOff,
label: 'Ready',
className: 'bg-muted text-muted-foreground',
},
live: {
icon: Mic,
label: 'Live',
className: 'bg-green-500/10 text-green-600 dark:text-green-400',
},
ended: {
icon: MicOff,
label: 'Ended',
className: 'bg-muted text-muted-foreground',
},
};
export function TranscriptContainer({
title,
status,
children,
messageCount
}: TranscriptContainerProps) {
const statusConfig = STATUS_CONFIG[status];
const StatusIcon = statusConfig.icon;
return (
<div className="w-full h-full flex flex-col bg-background border-l border-border">
{/* Header */}
<div className="px-4 py-3 border-b border-border shrink-0">
<div className="flex items-center justify-center gap-2">
<MessageSquare className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="font-medium text-sm whitespace-nowrap">{title}</span>
<div className={cn(
"flex items-center gap-1 text-xs px-2 py-0.5 rounded-full shrink-0",
statusConfig.className
)}>
<StatusIcon className="h-3 w-3" />
<span>{statusConfig.label}</span>
</div>
</div>
</div>
{/* Content */}
{children}
{/* Footer with message count */}
{messageCount !== undefined && messageCount > 0 && (
<div className="px-4 py-2 border-t border-border text-xs text-muted-foreground shrink-0">
{messageCount} messages
</div>
)}
</div>
);
}

View file

@ -1,20 +0,0 @@
'use client';
import { MessageSquare } from 'lucide-react';
interface TranscriptEmptyStateProps {
title: string;
subtitle: string;
}
export function TranscriptEmptyState({ title, subtitle }: TranscriptEmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground text-sm">
<MessageSquare className="h-10 w-10 mb-4 opacity-30" />
<p className="font-medium">{title}</p>
<p className="text-xs mt-1 text-center px-4">
{subtitle}
</p>
</div>
);
}

View file

@ -1,154 +0,0 @@
'use client';
import { AlertTriangle, Brain, ExternalLink, GitBranch, MicOff, Wrench } from 'lucide-react';
import { cn } from '@/lib/utils';
export interface TranscriptMessageData {
id: string;
type: 'user-transcription' | 'bot-text' | 'function-call' | 'node-transition' | 'ttfb-metric' | 'pipeline-error' | 'interrupt-warning';
text: string;
final?: boolean;
functionName?: string;
nodeName?: string;
allowInterrupt?: boolean;
ttfbSeconds?: number;
fatal?: boolean;
}
interface TranscriptMessageProps {
message: TranscriptMessageData;
nextMessage?: TranscriptMessageData;
}
export function TranscriptMessage({ message, nextMessage }: TranscriptMessageProps) {
// Node transition - show as section divider
if (message.type === 'node-transition') {
return (
<div className="flex items-center gap-2 py-2">
<div className="flex-1 h-px bg-border"></div>
<div className="px-2 py-1 rounded-md text-xs bg-blue-500/10 border border-blue-500/20 inline-flex items-center gap-1.5">
<GitBranch className="h-3 w-3 text-blue-500" />
<span className="font-medium text-blue-700 dark:text-blue-400">
{message.nodeName}
</span>
</div>
<div className="flex-1 h-px bg-border"></div>
</div>
);
}
// Interrupt warning - show as an amber alert (one-time)
if (message.type === 'interrupt-warning') {
return (
<div className="flex items-start gap-2 px-3 py-2 rounded-lg bg-amber-500/10 border border-amber-500/20">
<MicOff className="h-4 w-4 text-amber-500 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-amber-700 dark:text-amber-400">
Interruption Disabled
</div>
<div className="text-sm text-amber-600 dark:text-amber-300 mt-0.5">
{message.text}
</div>
<a
href="https://docs.dograh.com/configurations/interruption"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400 hover:underline mt-1"
>
Learn more <ExternalLink className="h-3 w-3" />
</a>
</div>
</div>
);
}
// Pipeline error - show as a red alert
if (message.type === 'pipeline-error') {
return (
<div className="flex items-start gap-2 px-3 py-2 rounded-lg bg-red-500/10 border border-red-500/20">
<AlertTriangle className="h-4 w-4 text-red-500 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-red-700 dark:text-red-400">
{message.fatal ? 'Fatal Pipeline Error' : 'Pipeline Error'}
</div>
<div className="text-sm text-red-600 dark:text-red-300 mt-0.5 break-words">
{message.text}
</div>
</div>
</div>
);
}
// TTFB metric - don't render standalone, it'll be shown with bot messages and function calls
if (message.type === 'ttfb-metric') {
return null;
}
// Function call message - centered with TTFB if present
if (message.type === 'function-call') {
const ttfbMetric = nextMessage?.type === 'ttfb-metric' ? nextMessage : null;
return (
<div className="flex flex-col items-center gap-1">
{/* Show TTFB metric above function call */}
{ttfbMetric && ttfbMetric.ttfbSeconds !== undefined && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Brain className="h-3 w-3" />
<span className="font-medium">Reasoning Delay:</span>
<span>{(ttfbMetric.ttfbSeconds * 1000).toFixed(0)}ms</span>
</div>
)}
<div className="px-3 py-1.5 rounded-full text-xs bg-amber-500/10 border border-amber-500/20 inline-flex items-center gap-2">
<Wrench className="h-3 w-3 text-amber-500" />
<span className="font-mono text-amber-700 dark:text-amber-400">
{message.functionName}()
</span>
</div>
</div>
);
}
const isUser = message.type === 'user-transcription';
const isBot = message.type === 'bot-text';
// Check if next message is a TTFB metric (for bot messages)
const ttfbMetric = isBot && nextMessage?.type === 'ttfb-metric' ? nextMessage : null;
// User messages on right, bot messages on left
return (
<div className={cn(
"flex",
isUser ? "justify-end" : "justify-start"
)}>
<div className="flex flex-col gap-1 max-w-[85%]">
{/* Show TTFB metric above bot messages */}
{ttfbMetric && ttfbMetric.ttfbSeconds !== undefined && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground px-1">
<Brain className="h-3 w-3" />
<span className="font-medium">Reasoning Delay:</span>
<span>{(ttfbMetric.ttfbSeconds * 1000).toFixed(0)}ms</span>
</div>
)}
<div
className={cn(
"px-3 py-2 rounded-2xl text-sm",
isUser
? "bg-primary text-primary-foreground rounded-br-md"
: "bg-muted rounded-bl-md",
!message.final && "opacity-70"
)}
>
<div className="whitespace-pre-wrap leading-relaxed">{message.text}</div>
{!message.final && (
<div className={cn(
"text-[10px] mt-1 italic",
isUser ? "text-primary-foreground/70" : "text-muted-foreground"
)}>
speaking...
</div>
)}
</div>
</div>
</div>
);
}

View file

@ -4,6 +4,7 @@ import { client } from "@/client/client.gen";
import { getTurnCredentialsApiV1TurnCredentialsGet, validateUserConfigurationsApiV1UserConfigurationsUserValidateGet, validateWorkflowApiV1WorkflowWorkflowIdValidatePost } from "@/client/sdk.gen";
import { TurnCredentialsResponse } from "@/client/types.gen";
import { WorkflowValidationError } from "@/components/flow/types";
import type { ConversationNodeTransitionItem, RealtimeFeedbackMessage as FeedbackMessage } from "@/components/workflow/conversation";
import { useAppConfig } from "@/context/AppConfigContext";
import logger from '@/lib/logger';
@ -15,29 +16,10 @@ interface UseWebSocketRTCProps {
workflowRunId: number;
accessToken: string | null;
initialContextVariables?: Record<string, string> | null;
onNodeTransition?: (transition: ConversationNodeTransitionItem) => void;
}
export interface FeedbackMessage {
id: string;
type: 'user-transcription' | 'bot-text' | 'function-call' | 'node-transition' | 'ttfb-metric' | 'pipeline-error' | 'interrupt-warning';
text: string;
final?: boolean;
timestamp: string;
functionName?: string;
status?: 'running' | 'completed';
// Node transition fields
nodeName?: string;
previousNode?: string;
allowInterrupt?: boolean;
// TTFB metric fields
ttfbSeconds?: number;
processor?: string;
model?: string;
// Pipeline error fields
fatal?: boolean;
}
export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initialContextVariables }: UseWebSocketRTCProps) => {
export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initialContextVariables, onNodeTransition }: UseWebSocketRTCProps) => {
const [connectionStatus, setConnectionStatus] = useState<'idle' | 'connecting' | 'connected' | 'failed'>('idle');
const [connectionActive, setConnectionActive] = useState(false);
const [isCompleted, setIsCompleted] = useState(false);
@ -72,6 +54,11 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
const pcRef = useRef<RTCPeerConnection | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const timeStartRef = useRef<number | null>(null);
const onNodeTransitionRef = useRef(onNodeTransition);
useEffect(() => {
onNodeTransitionRef.current = onNodeTransition;
}, [onNodeTransition]);
// Generate a cryptographically secure unique ID
const generateSecureId = () => {
@ -379,18 +366,22 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
}
case 'rtf-function-call-start': {
const { function_name, tool_call_id } = message.payload;
const { function_name, tool_call_id, arguments: toolArguments } = message.payload;
setFeedbackMessages(prev => {
// Check if we already have this function call
const existingId = `func-${tool_call_id}`;
const existingId = tool_call_id
? `func-${tool_call_id}`
: `func-${Date.now()}`;
if (prev.some(msg => msg.id === existingId)) {
return prev;
}
return [...prev, {
id: existingId,
type: 'function-call',
text: function_name,
functionName: function_name,
text: function_name ?? 'tool',
functionName: function_name ?? 'tool',
toolCallId: tool_call_id,
arguments: toolArguments,
status: 'running',
timestamp: new Date().toISOString(),
}];
@ -402,24 +393,44 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
const { tool_call_id, result } = message.payload;
setFeedbackMessages(prev => prev.map(msg =>
msg.id === `func-${tool_call_id}`
? { ...msg, status: 'completed' as const, text: result || msg.text }
? { ...msg, status: 'completed' as const, text: result || msg.text, result }
: msg
));
break;
}
case 'rtf-node-transition': {
const { node_name, previous_node_name, allow_interrupt } = message.payload;
const {
node_id,
node_name,
previous_node_id,
previous_node_name,
allow_interrupt,
} = message.payload;
currentAllowInterruptRef.current = allow_interrupt;
setFeedbackMessages(prev => [...prev, {
const transitionTimestamp = new Date().toISOString();
const transition: ConversationNodeTransitionItem = {
kind: 'node-transition',
id: `node-${Date.now()}`,
timestamp: transitionTimestamp,
nodeId: node_id,
nodeName: node_name ?? 'Node',
previousNodeId: previous_node_id,
previousNodeName: previous_node_name,
allowInterrupt: allow_interrupt,
};
setFeedbackMessages(prev => [...prev, {
id: transition.id,
type: 'node-transition',
text: node_name,
nodeName: node_name,
text: transition.nodeName,
nodeId: transition.nodeId,
nodeName: transition.nodeName,
previousNodeId: transition.previousNodeId,
previousNode: previous_node_name,
allowInterrupt: allow_interrupt,
timestamp: new Date().toISOString(),
timestamp: transitionTimestamp,
}]);
onNodeTransitionRef.current?.(transition);
break;
}

View file

@ -1,40 +1,107 @@
'use client';
import { Check, Copy, ExternalLink, FileText, LoaderCircle, Phone, Video } from 'lucide-react';
import { Check, Copy, ExternalLink, FileText, Video } from 'lucide-react';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import { useParams } from 'next/navigation';
import posthog from 'posthog-js';
import { useEffect, useRef, useState } from 'react';
import BrowserCall from '@/app/workflow/[workflowId]/run/[runId]/BrowserCall';
import { RealtimeFeedback, WorkflowRunLogs } from '@/app/workflow/[workflowId]/run/[runId]/components/RealtimeFeedback';
import WorkflowLayout from '@/app/workflow/WorkflowLayout';
import {
createWorkflowRunApiV1WorkflowWorkflowIdRunsPost,
getWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGet,
} from '@/client/sdk.gen';
import { getWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGet } from '@/client/sdk.gen';
import { MediaPreviewButton, MediaPreviewDialog } from '@/components/MediaPreviewDialog';
import { OnboardingTooltip } from '@/components/onboarding/OnboardingTooltip';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { ConversationRailFrame, RealtimeFeedback, WorkflowRunLogs } from '@/components/workflow/conversation';
import { PostHogEvent } from '@/constants/posthog-events';
import { WORKFLOW_RUN_MODES } from '@/constants/workflowRunModes';
import { useOnboarding } from '@/context/OnboardingContext';
import { useAuth } from '@/lib/auth';
import { downloadFile } from '@/lib/files';
import { getRandomId } from '@/lib/utils';
interface WorkflowRunResponse {
mode: string;
is_completed: boolean;
transcript_url: string | null;
recording_url: string | null;
cost_info: {
dograh_token_usage?: number | null;
call_duration_seconds?: number | null;
} | null;
initial_context: Record<string, string | number | boolean | object> | null;
gathered_context: Record<string, string | number | boolean | object> | null;
logs: WorkflowRunLogs | null;
annotations: Record<string, unknown> | null;
}
const RUN_SHELL_HEIGHT_CLASS = "h-[calc(100svh-49px)] min-h-[calc(100svh-49px)] max-h-[calc(100svh-49px)]";
function formatDuration(seconds?: number | null) {
if (seconds == null || Number.isNaN(seconds)) return 'N/A';
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
if (mins === 0) return `${secs}s`;
return `${mins}m ${secs}s`;
}
function getTranscriptMetrics(logs: WorkflowRunLogs | null, gatheredContext: Record<string, string | number | boolean | object> | null) {
const events = logs?.realtime_feedback_events ?? [];
const userTurns = events.filter((event) => event.type === 'rtf-user-transcription' && event.payload.final).length;
const botTurns = events.filter((event) => event.type === 'rtf-bot-text').length;
const toolCalls = events.filter((event) => event.type === 'rtf-function-call-end').length;
const nodeNames = new Set(
events
.map((event) => event.payload.node_name)
.filter((nodeName): nodeName is string => Boolean(nodeName))
);
const visitedNodes = Array.isArray(gatheredContext?.nodes_visited)
? gatheredContext.nodes_visited.length
: nodeNames.size;
return { userTurns, botTurns, toolCalls, visitedNodes };
}
function MetricCard({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-xl border border-border bg-muted/40 px-4 py-3">
<p className="text-xs font-medium uppercase tracking-[0.14em] text-muted-foreground">{label}</p>
<p className="mt-2 text-lg font-semibold text-foreground">{value}</p>
</div>
);
}
function RunMetricsSection({
costInfo,
logs,
gatheredContext,
}: {
costInfo: WorkflowRunResponse['cost_info'];
logs: WorkflowRunLogs | null;
gatheredContext: Record<string, string | number | boolean | object> | null;
}) {
const metrics = getTranscriptMetrics(logs, gatheredContext);
return (
<Card className="border-border">
<CardHeader className="pb-3">
<CardTitle className="text-lg">Run Metrics</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<MetricCard label="Duration" value={formatDuration(costInfo?.call_duration_seconds)} />
<MetricCard
label="Token Usage"
value={costInfo?.dograh_token_usage != null ? costInfo.dograh_token_usage.toLocaleString() : 'N/A'}
/>
<MetricCard label="User Turns" value={String(metrics.userTurns)} />
<MetricCard label="Bot Turns" value={String(metrics.botTurns)} />
<MetricCard label="Tool Calls" value={String(metrics.toolCalls)} />
<MetricCard label="Nodes Visited" value={String(metrics.visitedNodes)} />
</CardContent>
</Card>
);
}
function ContextDisplay({ title, context }: { title: string; context: Record<string, string | number | boolean | object> | null }) {
const [copied, setCopied] = useState(false);
@ -79,9 +146,7 @@ function ContextDisplay({ title, context }: { title: string; context: Record<str
export default function WorkflowRunPage() {
const params = useParams();
const router = useRouter();
const [isLoading, setIsLoading] = useState(true);
const [startingCall, setStartingCall] = useState(false);
const auth = useAuth();
const [workflowRun, setWorkflowRun] = useState<WorkflowRunResponse | null>(null);
const { hasSeenTooltip, markTooltipSeen } = useOnboarding();
@ -94,12 +159,6 @@ export default function WorkflowRunPage() {
}
}, [auth]);
// Shrink and reposition Chatwoot bubble on this page
useEffect(() => {
document.body.classList.add('chatwoot-compact');
return () => document.body.classList.remove('chatwoot-compact');
}, []);
const { openPreview, dialog } = MediaPreviewDialog();
useEffect(() => {
@ -117,9 +176,11 @@ export default function WorkflowRunPage() {
});
setIsLoading(false);
const runData = {
mode: response.data?.mode ?? '',
is_completed: response.data?.is_completed ?? false,
transcript_url: response.data?.transcript_url ?? null,
recording_url: response.data?.recording_url ?? null,
cost_info: response.data?.cost_info ?? null,
initial_context: response.data?.initial_context as Record<string, string> | null ?? null,
gathered_context: response.data?.gathered_context as Record<string, string> | null ?? null,
logs: response.data?.logs as WorkflowRunLogs | null ?? null,
@ -137,25 +198,9 @@ export default function WorkflowRunPage() {
fetchWorkflowRun();
}, [params.workflowId, params.runId, auth]);
const handleTestAgain = async () => {
if (startingCall) return;
setStartingCall(true);
try {
const workflowId = Number(params.workflowId);
const workflowRunName = `WR-${getRandomId()}`;
const response = await createWorkflowRunApiV1WorkflowWorkflowIdRunsPost({
path: { workflow_id: workflowId },
body: { mode: WORKFLOW_RUN_MODES.SMALL_WEBRTC, name: workflowRunName },
});
if (response.data?.id) {
router.push(`/workflow/${workflowId}/run/${response.data.id}`);
}
} finally {
setStartingCall(false);
}
};
let returnValue = null;
const isTextChatRun = workflowRun?.mode === WORKFLOW_RUN_MODES.TEXTCHAT;
const showRunDetailsView = Boolean(workflowRun?.is_completed || isTextChatRun);
if (isLoading) {
returnValue = (
@ -179,36 +224,28 @@ export default function WorkflowRunPage() {
</div>
);
}
else if (workflowRun?.is_completed) {
else if (showRunDetailsView) {
returnValue = (
<div className="flex h-screen w-full overflow-hidden">
{/* Main content - 2/3 width */}
<div className="w-2/3 h-full overflow-y-auto">
<div className="w-full max-w-4xl space-y-6 p-6">
<div className={`flex ${RUN_SHELL_HEIGHT_CLASS} min-h-0 w-full overflow-hidden bg-background`}>
<div className="min-w-0 flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-4xl space-y-6 p-6">
<Card className="border-border">
<CardHeader className="flex flex-row items-center justify-between">
<div className="flex items-center gap-4">
<CardTitle className="text-2xl">Agent Run Completed</CardTitle>
<div className="h-8 w-8 bg-emerald-500/20 rounded-full flex items-center justify-center">
<svg className="h-5 w-5 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
</svg>
<CardTitle className="text-2xl">
{isTextChatRun ? 'Text Chat Session' : 'Agent Run Completed'}
</CardTitle>
<div className={`h-8 w-8 rounded-full flex items-center justify-center ${isTextChatRun ? 'bg-sky-500/15' : 'bg-emerald-500/20'}`}>
{isTextChatRun ? (
<FileText className="h-5 w-5 text-sky-500" />
) : (
<svg className="h-5 w-5 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
</svg>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Button
onClick={handleTestAgain}
disabled={startingCall}
variant="outline"
className="gap-2"
>
{startingCall ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<Phone className="h-4 w-4" />
)}
{startingCall ? 'Starting...' : 'Test Again'}
</Button>
<Link href={`/workflow/${params.workflowId}`}>
<Button
ref={customizeButtonRef}
@ -228,41 +265,49 @@ export default function WorkflowRunPage() {
</div>
</CardHeader>
<CardContent>
<p className="text-muted-foreground mb-8">Your voice agent run has been completed successfully. You can preview or download the transcript and recording.</p>
<p className="text-muted-foreground mb-8">
{isTextChatRun
? 'Review the conversation history, metrics, and context captured for this text session.'
: 'Your voice agent run has been completed successfully. You can preview or download the transcript and recording.'}
</p>
<div className="flex flex-wrap gap-4">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Preview:</span>
<MediaPreviewButton
recordingUrl={workflowRun?.recording_url}
transcriptUrl={workflowRun?.transcript_url}
runId={Number(params.runId)}
onOpenPreview={openPreview}
/>
</div>
<div className="flex items-center gap-2 border-l border-border pl-4">
<span className="text-sm text-muted-foreground">Download:</span>
<Button
onClick={() => downloadFile(workflowRun?.transcript_url)}
disabled={!workflowRun?.transcript_url || !auth.isAuthenticated}
size="sm"
className="gap-2"
>
<FileText className="h-4 w-4" />
Transcript
</Button>
<Button
onClick={() => downloadFile(workflowRun?.recording_url)}
disabled={!workflowRun?.recording_url || !auth.isAuthenticated}
size="sm"
className="gap-2"
>
<Video className="h-4 w-4" />
Recording
</Button>
</div>
{!isTextChatRun && (
<>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Preview:</span>
<MediaPreviewButton
recordingUrl={workflowRun?.recording_url}
transcriptUrl={workflowRun?.transcript_url}
runId={Number(params.runId)}
onOpenPreview={openPreview}
/>
</div>
<div className="flex items-center gap-2 border-l border-border pl-4">
<span className="text-sm text-muted-foreground">Download:</span>
<Button
onClick={() => downloadFile(workflowRun?.transcript_url ?? null)}
disabled={!workflowRun?.transcript_url || !auth.isAuthenticated}
size="sm"
className="gap-2"
>
<FileText className="h-4 w-4" />
Transcript
</Button>
<Button
onClick={() => downloadFile(workflowRun?.recording_url ?? null)}
disabled={!workflowRun?.recording_url || !auth.isAuthenticated}
size="sm"
className="gap-2"
>
<Video className="h-4 w-4" />
Recording
</Button>
</div>
</>
)}
{workflowRun?.gathered_context?.trace_url && (
<div className="flex items-center gap-2 border-l border-border pl-4">
<div className={`flex items-center gap-2 ${isTextChatRun ? '' : 'border-l border-border pl-4'}`}>
<span className="text-sm text-muted-foreground">Trace:</span>
<Button
asChild
@ -285,14 +330,20 @@ export default function WorkflowRunPage() {
</CardContent>
</Card>
<RunMetricsSection
costInfo={workflowRun?.cost_info ?? null}
logs={workflowRun?.logs ?? null}
gatheredContext={workflowRun?.gathered_context ?? null}
/>
<div className="grid gap-6 md:grid-cols-2">
<ContextDisplay
title="Initial Context"
context={workflowRun?.initial_context}
context={workflowRun?.initial_context ?? null}
/>
<ContextDisplay
title="Gathered Context"
context={workflowRun?.gathered_context}
context={workflowRun?.gathered_context ?? null}
/>
</div>
@ -305,33 +356,34 @@ export default function WorkflowRunPage() {
</div>
</div>
{/* Transcript panel - 1/3 width */}
<div className="w-1/3 h-full shrink-0 overflow-hidden">
<RealtimeFeedback mode="historical" logs={workflowRun?.logs} />
<div className="h-full min-h-0 w-[420px] shrink-0 border-l border-border bg-background p-5">
<ConversationRailFrame className="h-full">
<RealtimeFeedback mode="historical" logs={workflowRun?.logs ?? null} />
</ConversationRailFrame>
</div>
</div>
);
}
else {
returnValue =
<div className="h-full flex items-center justify-center">
<BrowserCall
workflowId={Number(params.workflowId)}
workflowRunId={Number(params.runId)}
initialContextVariables={
workflowRun?.initial_context
? Object.fromEntries(
Object.entries(workflowRun.initial_context).map(([key, value]) => [
key,
typeof value === 'object' && value !== null
? JSON.stringify(value)
: String(value)
])
)
: null
}
/>
returnValue = (
<div className="flex h-full items-center justify-center p-6">
<Card className="w-full max-w-xl border-border">
<CardHeader className="space-y-2">
<CardTitle className="text-2xl">Run Details Unavailable</CardTitle>
<p className="text-sm text-muted-foreground">
This run does not have a details view yet. Go back to the workflow to continue testing or make changes.
</p>
</CardHeader>
<CardFooter>
<Button asChild className="gap-2">
<Link href={`/workflow/${params.workflowId}`}>
Customize Agent
</Link>
</Button>
</CardFooter>
</Card>
</div>
);
}
return (
@ -340,7 +392,7 @@ export default function WorkflowRunPage() {
{dialog}
{/* Onboarding Tooltip for Customize Workflow */}
{workflowRun?.is_completed && (
{showRunDetailsView && (
<OnboardingTooltip
title='Customize Your Workflow'
targetRef={customizeButtonRef}

View file

@ -1,153 +0,0 @@
/**
* Utility to process realtime feedback events into a unified transcript format.
* Used by both live WebSocket messages and post-call logs.
*/
export interface TranscriptEvent {
type: 'user-transcription' | 'bot-text' | 'function-call' | 'node-transition' | 'ttfb-metric' | 'pipeline-error' | 'interrupt-warning';
text: string;
final?: boolean;
timestamp: string;
turn?: number;
functionName?: string;
status?: 'running' | 'completed';
nodeName?: string;
previousNode?: string;
allowInterrupt?: boolean;
ttfbSeconds?: number;
processor?: string;
model?: string;
fatal?: boolean;
}
export interface ProcessedMessage {
id: string;
type: TranscriptEvent['type'];
text: string;
final?: boolean;
timestamp: string;
functionName?: string;
status?: 'running' | 'completed';
nodeName?: string;
allowInterrupt?: boolean;
ttfbSeconds?: number;
fatal?: boolean;
}
/**
* Process transcript events (both live and historical).
* Combines consecutive bot-text by turn and associates TTFB metrics.
*/
export function processTranscriptEvents(events: TranscriptEvent[]): ProcessedMessage[] {
// Filter out interim transcriptions and function-call-start events
const filteredEvents = events.filter(event => {
if (event.type === 'user-transcription' && !event.final) return false;
if (event.type === 'function-call' && event.status === 'running') return false;
return true;
});
const processed: ProcessedMessage[] = [];
let currentBotText: { event: TranscriptEvent; text: string } | null = null;
let pendingTtfb: TranscriptEvent | null = null;
const flushBotText = () => {
if (!currentBotText) return;
processed.push(convertToProcessedMessage(currentBotText.event, currentBotText.text));
// Add the pending TTFB metric if it exists
if (pendingTtfb) {
processed.push(convertToProcessedMessage(pendingTtfb));
pendingTtfb = null;
}
currentBotText = null;
};
for (const event of filteredEvents) {
if (event.type === 'ttfb-metric') {
// Store TTFB to associate with the next bot-text or function-call
pendingTtfb = event;
} else if (event.type === 'bot-text') {
// Combine consecutive bot-text from the same turn
if (currentBotText && currentBotText.event.turn === event.turn) {
currentBotText.text = currentBotText.text + ' ' + event.text;
} else {
flushBotText();
currentBotText = { event, text: event.text };
}
} else {
// Handle other events (user-transcription, function-call, node-transition)
flushBotText();
processed.push(convertToProcessedMessage(event));
// Add pending TTFB after function calls
if (event.type === 'function-call' && pendingTtfb) {
processed.push(convertToProcessedMessage(pendingTtfb));
pendingTtfb = null;
}
}
}
// Flush any remaining bot text
flushBotText();
return processed;
}
/**
* Process live messages - optimized version.
*
* Optimizations rely on useWebSocketRTC.tsx already handling:
* - Bot text accumulation (consecutive chunks combined with spaces)
* - Interim transcription filtering (only final transcriptions kept)
* - Function call status (start events filtered, only completed kept)
*
* This function only needs to:
* - Associate TTFB metrics with the preceding bot-text or function-call
* - Convert to ProcessedMessage format
*/
export function processLiveMessages(messages: TranscriptEvent[]): ProcessedMessage[] {
const processed: ProcessedMessage[] = [];
let pendingTtfb: TranscriptEvent | null = null;
for (const msg of messages) {
if (msg.type === 'ttfb-metric') {
// Store TTFB to associate with next message
pendingTtfb = msg;
} else {
// Add the message
processed.push(convertToProcessedMessage(msg));
// Add pending TTFB after final bot-text or completed function calls
if ((msg.type === 'bot-text' && msg.final) ||
(msg.type === 'function-call' && msg.status === 'completed')) {
if (pendingTtfb) {
processed.push(convertToProcessedMessage(pendingTtfb));
pendingTtfb = null;
}
}
}
}
return processed;
}
// Alias for backward compatibility
export const processHistoricalEvents = processTranscriptEvents;
function convertToProcessedMessage(event: TranscriptEvent, overrideText?: string): ProcessedMessage {
return {
id: `${event.type}-${event.timestamp}`,
type: event.type,
text: overrideText ?? event.text,
final: event.final ?? true,
timestamp: event.timestamp,
functionName: event.functionName,
status: event.status,
nodeName: event.nodeName,
allowInterrupt: event.allowInterrupt,
ttfbSeconds: event.ttfbSeconds,
fatal: event.fatal,
};
}

View file

@ -3,7 +3,7 @@
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { createWorkflowFromTemplateApiV1WorkflowCreateTemplatePost, createWorkflowRunApiV1WorkflowWorkflowIdRunsPost } from '@/client/sdk.gen';
import { createWorkflowFromTemplateApiV1WorkflowCreateTemplatePost } from '@/client/sdk.gen';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
@ -18,10 +18,8 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { WORKFLOW_RUN_MODES } from '@/constants/workflowRunModes';
import { useAuth } from '@/lib/auth';
import logger from '@/lib/logger';
import { getRandomId } from '@/lib/utils';
export default function CreateWorkflowPage() {
const router = useRouter();
@ -76,36 +74,9 @@ export default function CreateWorkflowPage() {
}
};
const handleModalContinue = async () => {
if (!workflowId || !user) return;
try {
const accessToken = await getAccessToken();
const workflowRunName = `WR-${getRandomId()}`;
// Create a workflow run
const response = await createWorkflowRunApiV1WorkflowWorkflowIdRunsPost({
path: {
workflow_id: Number(workflowId),
},
body: {
mode: WORKFLOW_RUN_MODES.SMALL_WEBRTC, // Same mode as "Web Call" button
name: workflowRunName
},
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
// Navigate to the workflow run page
if (response.data?.id) {
router.push(`/workflow/${workflowId}/run/${response.data.id}`);
}
} catch (err) {
logger.error(`Error creating workflow run: ${err}`);
// Fallback to workflow page if run creation fails
router.push(`/workflow/${workflowId}`);
}
const handleModalContinue = () => {
if (!workflowId) return;
router.push(`/workflow/${workflowId}?onboarding=web_call`);
};
return (
@ -233,7 +204,7 @@ export default function CreateWorkflowPage() {
The voice bot is pre-set to communicate in English with an American accent.
</p>
<p>
Next steps would be to test the voice bot using web call, and then modify it to suit your use case.
Next steps would be to test the voice bot in the editor, and then modify it to suit your use case.
</p>
</div>
</DialogDescription>
@ -243,7 +214,7 @@ export default function CreateWorkflowPage() {
onClick={handleModalContinue}
className="w-full"
>
Start Web Call
Open and Test Agent
</Button>
</DialogFooter>
</DialogContent>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -178,6 +178,20 @@ export type AmbientNoiseUploadResponse = {
storage_backend: string;
};
/**
* AppendTextChatMessageRequest
*/
export type AppendTextChatMessageRequest = {
/**
* Text
*/
text: string;
/**
* Expected Revision
*/
expected_revision?: number | null;
};
/**
* AuthResponse
*/
@ -890,6 +904,28 @@ export type CreateServiceKeyResponse = {
expires_at?: string | null;
};
/**
* CreateTextChatSessionRequest
*/
export type CreateTextChatSessionRequest = {
/**
* Name
*/
name?: string | null;
/**
* Initial Context
*/
initial_context?: {
[key: string]: unknown;
} | null;
/**
* Annotations
*/
annotations?: {
[key: string]: unknown;
} | null;
};
/**
* CreateToolRequest
*
@ -2932,6 +2968,20 @@ export type RetryConfigResponse = {
retry_on_voicemail: boolean;
};
/**
* RewindTextChatSessionRequest
*/
export type RewindTextChatSessionRequest = {
/**
* Cursor Turn Id
*/
cursor_turn_id?: string | null;
/**
* Expected Revision
*/
expected_revision?: number | null;
};
/**
* S3SignedUrlResponse
*/
@ -4493,6 +4543,78 @@ export type WorkflowRunResponseSchema = {
} | null;
};
/**
* WorkflowRunTextSessionResponse
*/
export type WorkflowRunTextSessionResponse = {
/**
* Workflow Run Id
*/
workflow_run_id: number;
/**
* Workflow Id
*/
workflow_id: number;
/**
* Name
*/
name: string;
/**
* Mode
*/
mode: string;
/**
* State
*/
state: string;
/**
* Is Completed
*/
is_completed: boolean;
/**
* Revision
*/
revision: number;
/**
* Initial Context
*/
initial_context?: {
[key: string]: unknown;
} | null;
/**
* Gathered Context
*/
gathered_context?: {
[key: string]: unknown;
} | null;
/**
* Annotations
*/
annotations?: {
[key: string]: unknown;
} | null;
/**
* Session Data
*/
session_data: {
[key: string]: unknown;
};
/**
* Checkpoint
*/
checkpoint: {
[key: string]: unknown;
};
/**
* Created At
*/
created_at: string;
/**
* Updated At
*/
updated_at?: string | null;
};
/**
* WorkflowRunUsageResponse
*/
@ -4553,6 +4675,10 @@ export type WorkflowRunUsageResponse = {
* Call Type
*/
call_type?: string | null;
/**
* Mode
*/
mode?: string | null;
/**
* Disposition
*/
@ -6245,6 +6371,194 @@ export type GetAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPostRespon
export type GetAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPostResponse = GetAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPostResponses[keyof GetAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPostResponses];
export type CreateTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsPostData = {
body: CreateTextChatSessionRequest;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
/**
* X-Api-Key
*/
'X-API-Key'?: string | null;
};
path: {
/**
* Workflow Id
*/
workflow_id: number;
};
query?: never;
url: '/api/v1/workflow/{workflow_id}/text-chat/sessions';
};
export type CreateTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsPostErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type CreateTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsPostError = CreateTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsPostErrors[keyof CreateTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsPostErrors];
export type CreateTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsPostResponses = {
/**
* Successful Response
*/
200: WorkflowRunTextSessionResponse;
};
export type CreateTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsPostResponse = CreateTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsPostResponses[keyof CreateTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsPostResponses];
export type GetTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdGetData = {
body?: never;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
/**
* X-Api-Key
*/
'X-API-Key'?: string | null;
};
path: {
/**
* Workflow Id
*/
workflow_id: number;
/**
* Run Id
*/
run_id: number;
};
query?: never;
url: '/api/v1/workflow/{workflow_id}/text-chat/sessions/{run_id}';
};
export type GetTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdGetErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdGetError = GetTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdGetErrors[keyof GetTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdGetErrors];
export type GetTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdGetResponses = {
/**
* Successful Response
*/
200: WorkflowRunTextSessionResponse;
};
export type GetTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdGetResponse = GetTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdGetResponses[keyof GetTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdGetResponses];
export type AppendTextChatMessageApiV1WorkflowWorkflowIdTextChatSessionsRunIdMessagesPostData = {
body: AppendTextChatMessageRequest;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
/**
* X-Api-Key
*/
'X-API-Key'?: string | null;
};
path: {
/**
* Workflow Id
*/
workflow_id: number;
/**
* Run Id
*/
run_id: number;
};
query?: never;
url: '/api/v1/workflow/{workflow_id}/text-chat/sessions/{run_id}/messages';
};
export type AppendTextChatMessageApiV1WorkflowWorkflowIdTextChatSessionsRunIdMessagesPostErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type AppendTextChatMessageApiV1WorkflowWorkflowIdTextChatSessionsRunIdMessagesPostError = AppendTextChatMessageApiV1WorkflowWorkflowIdTextChatSessionsRunIdMessagesPostErrors[keyof AppendTextChatMessageApiV1WorkflowWorkflowIdTextChatSessionsRunIdMessagesPostErrors];
export type AppendTextChatMessageApiV1WorkflowWorkflowIdTextChatSessionsRunIdMessagesPostResponses = {
/**
* Successful Response
*/
200: WorkflowRunTextSessionResponse;
};
export type AppendTextChatMessageApiV1WorkflowWorkflowIdTextChatSessionsRunIdMessagesPostResponse = AppendTextChatMessageApiV1WorkflowWorkflowIdTextChatSessionsRunIdMessagesPostResponses[keyof AppendTextChatMessageApiV1WorkflowWorkflowIdTextChatSessionsRunIdMessagesPostResponses];
export type RewindTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdRewindPostData = {
body: RewindTextChatSessionRequest;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
/**
* X-Api-Key
*/
'X-API-Key'?: string | null;
};
path: {
/**
* Workflow Id
*/
workflow_id: number;
/**
* Run Id
*/
run_id: number;
};
query?: never;
url: '/api/v1/workflow/{workflow_id}/text-chat/sessions/{run_id}/rewind';
};
export type RewindTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdRewindPostErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type RewindTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdRewindPostError = RewindTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdRewindPostErrors[keyof RewindTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdRewindPostErrors];
export type RewindTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdRewindPostResponses = {
/**
* Successful Response
*/
200: WorkflowRunTextSessionResponse;
};
export type RewindTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdRewindPostResponse = RewindTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdRewindPostResponses[keyof RewindTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdRewindPostResponses];
export type GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetData = {
body?: never;
path?: never;

View file

@ -1,5 +1,6 @@
"use client";
import { usePathname } from "next/navigation";
import { useEffect } from "react";
declare global {
@ -22,7 +23,22 @@ const CHATWOOT_BASE_URL = process.env.NEXT_PUBLIC_CHATWOOT_URL;
const CHATWOOT_WEBSITE_TOKEN = process.env.NEXT_PUBLIC_CHATWOOT_TOKEN;
export default function ChatwootWidget() {
const pathname = usePathname();
useEffect(() => {
const isWorkflowPage = /^\/workflow\/[^/]+(?:\/.*)?$/.test(pathname);
if (isWorkflowPage) {
document.getElementById("cw-widget-holder")?.remove();
document.getElementById("cw-bubble-holder")?.remove();
document.getElementById("cw-widget-styles")?.remove();
document
.querySelector(`script[src="${CHATWOOT_BASE_URL}/packs/js/sdk.js"]`)
?.remove();
delete window.chatwootSettings;
return;
}
// Don't initialize if environment variables are not set
if (!CHATWOOT_BASE_URL || !CHATWOOT_WEBSITE_TOKEN) {
console.warn("Chatwoot not configured: Missing NEXT_PUBLIC_CHATWOOT_URL or NEXT_PUBLIC_CHATWOOT_TOKEN");
@ -72,7 +88,7 @@ export default function ChatwootWidget() {
};
document.body.appendChild(script);
}, []);
}, [pathname]);
return null;
}

View file

@ -9,8 +9,10 @@ export const BaseNode = forwardRef<
invalid?: boolean;
selected_through_edge?: boolean;
hovered_through_edge?: boolean;
runtimeActive?: boolean;
runtimePulseNonce?: number;
}
>(({ className, selected, invalid, selected_through_edge, hovered_through_edge, ...props }, ref) => (
>(({ children, className, selected, invalid, selected_through_edge, hovered_through_edge, runtimeActive, runtimePulseNonce, ...props }, ref) => (
<div
ref={ref}
className={cn(
@ -26,11 +28,22 @@ export const BaseNode = forwardRef<
// Hovered through edge takes precedence over selected through edge
hovered_through_edge ? "ring-2 ring-primary/60 shadow-[0_0_12px_rgba(96,165,250,0.3)]" : "",
!hovered_through_edge && selected_through_edge ? "ring-1 ring-primary/50 shadow-[0_0_8px_rgba(59,130,246,0.2)]" : "",
runtimeActive ? "ring-2 ring-sky-400/60 shadow-[0_0_0_1px_rgba(56,189,248,0.18),0_0_24px_rgba(14,165,233,0.18)]" : "",
!selected_through_edge && !hovered_through_edge && "hover:border-muted-foreground/50",
)}
tabIndex={0}
{...props}
/>
>
{runtimeActive ? (
<span
key={`runtime-pulse-${runtimePulseNonce ?? 0}`}
className="pointer-events-none absolute -inset-2 rounded-[18px] border-2 border-sky-400/55"
aria-hidden="true"
style={{ animation: "ping 900ms ease-out 2" }}
/>
) : null}
{children}
</div>
));
BaseNode.displayName = "BaseNode";

View file

@ -608,6 +608,8 @@ export const GenericNode = memo(({ data, selected, id, type }: GenericNodeProps)
invalid={data.invalid}
selected_through_edge={data.selected_through_edge}
hovered_through_edge={data.hovered_through_edge}
runtimeActive={data.runtime_active}
runtimePulseNonce={data.runtime_pulse_nonce}
title={data.name || fallbackTitle}
icon={<Icon />}
badgeLabel={badge.label}

View file

@ -10,6 +10,8 @@ interface NodeContentProps {
invalid?: boolean;
selected_through_edge?: boolean;
hovered_through_edge?: boolean;
runtimeActive?: boolean;
runtimePulseNonce?: number;
title: string;
icon: ReactNode;
badgeLabel?: string;
@ -31,6 +33,8 @@ export const NodeContent = ({
invalid,
selected_through_edge,
hovered_through_edge,
runtimeActive,
runtimePulseNonce,
title,
icon,
badgeLabel,
@ -54,6 +58,8 @@ export const NodeContent = ({
invalid={invalid}
selected_through_edge={selected_through_edge}
hovered_through_edge={hovered_through_edge}
runtimeActive={runtimeActive}
runtimePulseNonce={runtimePulseNonce}
className={`p-0 ${className}`}
onDoubleClick={onDoubleClick}
>

View file

@ -17,6 +17,8 @@ export type FlowNodeData = {
validationMessage?: string | null;
selected_through_edge?: boolean;
hovered_through_edge?: boolean;
runtime_active?: boolean;
runtime_pulse_nonce?: number;
allow_interrupt?: boolean;
extraction_enabled?: boolean;
extraction_prompt?: string;

View file

@ -0,0 +1,71 @@
"use client";
import { MessageSquare, Mic, MicOff } from "lucide-react";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import type { ConversationStatus } from "./types";
interface ConversationContainerProps {
title: string;
status: ConversationStatus;
children: ReactNode;
messageCount?: number;
}
const STATUS_CONFIG = {
ready: {
icon: MicOff,
label: "Ready",
className: "bg-muted text-muted-foreground",
},
live: {
icon: Mic,
label: "Live",
className: "bg-green-500/10 text-green-600 dark:text-green-400",
},
ended: {
icon: MicOff,
label: "Ended",
className: "bg-muted text-muted-foreground",
},
} satisfies Record<ConversationStatus, { icon: typeof Mic; label: string; className: string }>;
export function ConversationContainer({
title,
status,
children,
messageCount,
}: ConversationContainerProps) {
const statusConfig = STATUS_CONFIG[status];
const StatusIcon = statusConfig.icon;
return (
<div className="flex h-full min-h-0 w-full flex-col bg-background">
<div className="shrink-0 border-b border-border px-4 py-3">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<MessageSquare className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate whitespace-nowrap text-sm font-medium">{title}</span>
</div>
<div className="flex shrink-0 items-center gap-2">
{messageCount !== undefined && messageCount > 0 ? (
<span className="text-xs text-muted-foreground">{messageCount} messages</span>
) : null}
<div
className={cn(
"flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-xs",
statusConfig.className,
)}
>
<StatusIcon className="h-3 w-3" />
<span>{statusConfig.label}</span>
</div>
</div>
</div>
</div>
{children}
</div>
);
}

View file

@ -0,0 +1,15 @@
"use client";
import { MessageSquare } from "lucide-react";
import type { ConversationEmptyStateData } from "./types";
export function ConversationEmptyState({ title, subtitle }: ConversationEmptyStateData) {
return (
<div className="flex h-full flex-col items-center justify-center text-sm text-muted-foreground">
<MessageSquare className="mb-4 h-10 w-10 opacity-30" />
<p className="font-medium">{title}</p>
<p className="mt-1 px-4 text-center text-xs">{subtitle}</p>
</div>
);
}

View file

@ -0,0 +1,61 @@
"use client";
import type { ReactNode } from "react";
import { MessageBubble } from "./MessageBubble";
import { NodeTransitionMarker } from "./NodeTransitionMarker";
import { NoticeCard } from "./NoticeCard";
import { ToolCallCard } from "./ToolCallCard";
import type { ConversationItem } from "./types";
interface ConversationItemViewProps {
item: ConversationItem;
actions?: ReactNode;
}
export function ConversationItemView({ item, actions }: ConversationItemViewProps) {
if (item.kind === "message") {
return (
<div className="group space-y-1">
<MessageBubble
role={item.role}
text={item.text}
final={item.final}
tone={item.tone}
reasoningDurationMs={item.reasoningDurationMs}
/>
{actions ? (
<div className="flex h-5 items-center justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
{actions}
</div>
) : null}
</div>
);
}
if (item.kind === "tool-call") {
return (
<ToolCallCard
functionName={item.functionName}
status={item.status}
argumentsValue={item.arguments}
resultValue={item.result}
reasoningDurationMs={item.reasoningDurationMs}
/>
);
}
if (item.kind === "node-transition") {
return <NodeTransitionMarker nodeName={item.nodeName} />;
}
return (
<NoticeCard
tone={item.tone}
title={item.title}
text={item.text}
linkHref={item.linkHref}
linkLabel={item.linkLabel}
/>
);
}

View file

@ -0,0 +1,32 @@
"use client";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
interface ConversationRailFrameProps {
children: ReactNode;
className?: string;
header?: ReactNode;
footer?: ReactNode;
}
export function ConversationRailFrame({
children,
className,
header,
footer,
}: ConversationRailFrameProps) {
return (
<div
className={cn(
"flex h-full min-h-0 flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-sm",
className,
)}
>
{header ? <div className="shrink-0 border-b border-border px-4 py-3">{header}</div> : null}
<div className="min-h-0 flex-1 overflow-hidden">{children}</div>
{footer ? <div className="shrink-0 border-t border-border px-4 py-3">{footer}</div> : null}
</div>
);
}

View file

@ -0,0 +1,60 @@
"use client";
import type { ReactNode } from "react";
import { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import { ConversationEmptyState } from "./ConversationEmptyState";
import { ConversationItemView } from "./ConversationItemView";
import type { ConversationEmptyStateData, ConversationItem } from "./types";
interface ConversationTimelineProps {
items: ConversationItem[];
autoScroll?: boolean;
scrollBehavior?: ScrollBehavior;
emptyState: ConversationEmptyStateData;
pendingIndicator?: ReactNode;
renderItemActions?: (item: ConversationItem) => ReactNode;
className?: string;
}
export function ConversationTimeline({
items,
autoScroll = false,
scrollBehavior = "auto",
emptyState,
pendingIndicator,
renderItemActions,
className,
}: ConversationTimelineProps) {
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const scrollEndRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!autoScroll) {
return;
}
scrollEndRef.current?.scrollIntoView({ behavior: scrollBehavior, block: "end" });
}, [autoScroll, items, pendingIndicator, scrollBehavior]);
return (
<div ref={scrollContainerRef} className={cn("flex-1 overflow-y-auto", className)}>
{items.length === 0 && !pendingIndicator ? (
<ConversationEmptyState title={emptyState.title} subtitle={emptyState.subtitle} />
) : (
<div className="space-y-3 p-4">
{items.map((item) => (
<ConversationItemView
key={item.id}
item={item}
actions={renderItemActions?.(item)}
/>
))}
{pendingIndicator}
<div ref={scrollEndRef} />
</div>
)}
</div>
);
}

View file

@ -0,0 +1,61 @@
"use client";
import { Brain } from "lucide-react";
import { cn } from "@/lib/utils";
interface MessageBubbleProps {
role: "user" | "assistant";
text: string;
final?: boolean;
tone?: "default" | "muted";
reasoningDurationMs?: number;
}
export function MessageBubble({
role,
text,
final = true,
tone = "default",
reasoningDurationMs,
}: MessageBubbleProps) {
const isUser = role === "user";
const isMuted = tone === "muted";
return (
<div className={cn("flex", isUser ? "justify-end" : "justify-start")}>
<div className="flex max-w-[85%] flex-col gap-1">
{!isUser && reasoningDurationMs !== undefined ? (
<div className="flex items-center gap-1.5 px-1 text-xs text-muted-foreground">
<Brain className="h-3 w-3" />
<span className="font-medium">Reasoning Delay:</span>
<span>{Math.round(reasoningDurationMs)}ms</span>
</div>
) : null}
<div
className={cn(
"whitespace-pre-wrap break-words rounded-2xl px-4 py-3 text-sm leading-relaxed shadow-sm",
isUser
? "rounded-br-md bg-primary text-primary-foreground"
: isMuted
? "rounded-bl-md border border-dashed border-border bg-background text-muted-foreground"
: "rounded-bl-md border border-slate-200/80 bg-muted text-foreground",
!final && "opacity-70",
)}
>
<div>{text}</div>
{!final ? (
<div
className={cn(
"mt-1 text-[10px] italic",
isUser ? "text-primary-foreground/70" : "text-muted-foreground",
)}
>
speaking...
</div>
) : null}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,20 @@
"use client";
import { GitBranch } from "lucide-react";
interface NodeTransitionMarkerProps {
nodeName: string;
}
export function NodeTransitionMarker({ nodeName }: NodeTransitionMarkerProps) {
return (
<div className="flex items-center gap-2 py-2">
<div className="h-px flex-1 bg-border" />
<div className="inline-flex items-center gap-1.5 rounded-full border border-blue-500/20 bg-blue-500/10 px-3 py-1 text-xs">
<GitBranch className="h-3 w-3 text-blue-500" />
<span className="font-medium text-blue-700 dark:text-blue-400">{nodeName}</span>
</div>
<div className="h-px flex-1 bg-border" />
</div>
);
}

View file

@ -0,0 +1,73 @@
"use client";
import { AlertTriangle, ExternalLink, MicOff } from "lucide-react";
import { cn } from "@/lib/utils";
interface NoticeCardProps {
tone: "warning" | "error";
title: string;
text: string;
linkHref?: string;
linkLabel?: string;
}
export function NoticeCard({
tone,
title,
text,
linkHref,
linkLabel,
}: NoticeCardProps) {
const isWarning = tone === "warning";
const Icon = isWarning ? MicOff : AlertTriangle;
return (
<div
className={cn(
"flex items-start gap-2 rounded-lg border px-3 py-2",
isWarning
? "border-amber-500/20 bg-amber-500/10"
: "border-red-500/20 bg-red-500/10",
)}
>
<Icon
className={cn(
"mt-0.5 h-4 w-4 shrink-0",
isWarning ? "text-amber-500" : "text-red-500",
)}
/>
<div className="min-w-0 flex-1">
<div
className={cn(
"text-xs font-medium",
isWarning ? "text-amber-700 dark:text-amber-400" : "text-red-700 dark:text-red-400",
)}
>
{title}
</div>
<div
className={cn(
"mt-0.5 break-words text-sm",
isWarning ? "text-amber-600 dark:text-amber-300" : "text-red-600 dark:text-red-300",
)}
>
{text}
</div>
{linkHref && linkLabel ? (
<a
href={linkHref}
target="_blank"
rel="noopener noreferrer"
className={cn(
"mt-1 inline-flex items-center gap-1 text-xs hover:underline",
isWarning ? "text-amber-600 dark:text-amber-400" : "text-red-600 dark:text-red-400",
)}
>
{linkLabel} <ExternalLink className="h-3 w-3" />
</a>
) : null}
</div>
</div>
);
}

View file

@ -0,0 +1,73 @@
"use client";
import {
conversationItemsFromLiveFeedback,
conversationItemsFromRealtimeFeedbackEvents,
} from "./adapters/fromRealtimeFeedback";
import { ConversationContainer } from "./ConversationContainer";
import { ConversationTimeline } from "./ConversationTimeline";
import type {
ConversationStatus,
RealtimeFeedbackMessage,
WorkflowRunLogs,
} from "./types";
import { countConversationMessages } from "./utils";
interface LiveModeProps {
mode: "live";
messages: RealtimeFeedbackMessage[];
isCallActive: boolean;
isCallCompleted: boolean;
}
interface HistoricalModeProps {
mode: "historical";
logs: WorkflowRunLogs | null;
}
type RealtimeFeedbackProps = LiveModeProps | HistoricalModeProps;
export function RealtimeFeedback(props: RealtimeFeedbackProps) {
let items;
let status: ConversationStatus;
let title: string;
let emptyState: { title: string; subtitle: string };
let autoScroll = false;
if (props.mode === "historical") {
items = props.logs?.realtime_feedback_events
? conversationItemsFromRealtimeFeedbackEvents(props.logs.realtime_feedback_events)
: [];
status = "ended";
title = "Call Transcript";
emptyState = {
title: "No conversation recorded",
subtitle: "Real-time feedback events were not captured for this call",
};
} else {
items = conversationItemsFromLiveFeedback(props.messages);
status = props.isCallActive ? "live" : props.isCallCompleted ? "ended" : "ready";
title = "Live Transcript";
emptyState = {
title: "No messages yet",
subtitle: props.isCallActive
? "Start speaking to see the transcript"
: "Start the call to begin the conversation",
};
autoScroll = true;
}
return (
<ConversationContainer
title={title}
status={status}
messageCount={countConversationMessages(items) || undefined}
>
<ConversationTimeline
items={items}
autoScroll={autoScroll}
emptyState={emptyState}
/>
</ConversationContainer>
);
}

View file

@ -0,0 +1,116 @@
"use client";
import { Brain, ChevronRight, Wrench } from "lucide-react";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { formatConversationValue } from "./utils";
interface ToolCallCardProps {
functionName: string;
status: "running" | "completed";
argumentsValue?: unknown;
resultValue?: unknown;
reasoningDurationMs?: number;
}
export function ToolCallCard({
functionName,
status,
argumentsValue,
resultValue,
reasoningDurationMs,
}: ToolCallCardProps) {
const [open, setOpen] = useState(false);
const hasArguments = argumentsValue !== undefined;
const hasResult = resultValue !== undefined;
const hasDetails = hasArguments || hasResult;
return (
<div className="flex justify-center">
<div className="flex w-full max-w-[85%] flex-col gap-1">
{reasoningDurationMs !== undefined ? (
<div className="flex items-center justify-center gap-1.5 text-xs text-muted-foreground">
<Brain className="h-3 w-3" />
<span className="font-medium">Reasoning Delay:</span>
<span>{Math.round(reasoningDurationMs)}ms</span>
</div>
) : null}
<Collapsible
open={hasDetails ? open : false}
onOpenChange={hasDetails ? setOpen : undefined}
className="rounded-2xl border border-amber-500/20 bg-amber-500/10"
>
<div className="flex items-start gap-2 px-3.5 py-3 text-sm">
<Wrench className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs text-amber-700 dark:text-amber-400">
{functionName}()
</span>
<Badge
variant="outline"
className={cn(
"h-5 px-1.5 text-[10px] uppercase tracking-[0.14em]",
status === "running"
? "border-amber-400/60 text-amber-700 dark:text-amber-300"
: "border-emerald-500/30 text-emerald-700 dark:text-emerald-300",
)}
>
{status === "running" ? "Running" : "Completed"}
</Badge>
</div>
{hasDetails ? (
<div className="mt-2">
<CollapsibleTrigger asChild>
<button
type="button"
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<ChevronRight
className={cn(
"h-3.5 w-3.5 transition-transform",
open && "rotate-90",
)}
/>
Details
</button>
</CollapsibleTrigger>
</div>
) : null}
</div>
</div>
{hasDetails ? (
<CollapsibleContent className="border-t border-amber-500/20 px-3.5 py-3">
<div className="space-y-3">
{hasArguments ? (
<div className="space-y-1">
<p className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Arguments
</p>
<pre className="overflow-x-auto rounded-xl bg-background/70 p-3 text-xs leading-5 text-foreground">
{formatConversationValue(argumentsValue)}
</pre>
</div>
) : null}
{hasResult ? (
<div className="space-y-1">
<p className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Result
</p>
<pre className="overflow-x-auto rounded-xl bg-background/70 p-3 text-xs leading-5 text-foreground">
{formatConversationValue(resultValue)}
</pre>
</div>
) : null}
</div>
</CollapsibleContent>
) : null}
</Collapsible>
</div>
</div>
);
}

View file

@ -0,0 +1,283 @@
import type {
ConversationItem,
RealtimeFeedbackEvent,
RealtimeFeedbackMessage,
} from "../types";
function feedbackEventText(event: RealtimeFeedbackEvent) {
return (
event.payload.text ??
event.payload.error ??
(typeof event.payload.result === "string" ? event.payload.result : undefined) ??
event.payload.function_name ??
event.payload.node_name ??
""
);
}
function liveFeedbackItem(message: RealtimeFeedbackMessage, reasoningDurationMs?: number): ConversationItem | null {
if (message.type === "ttfb-metric") {
return null;
}
if (message.type === "user-transcription") {
return {
kind: "message",
id: message.id,
timestamp: message.timestamp,
role: "user",
text: message.text,
final: message.final,
};
}
if (message.type === "bot-text") {
return {
kind: "message",
id: message.id,
timestamp: message.timestamp,
role: "assistant",
text: message.text,
final: message.final,
reasoningDurationMs,
};
}
if (message.type === "function-call") {
return {
kind: "tool-call",
id: message.id,
timestamp: message.timestamp,
functionName: message.functionName ?? "tool",
toolCallId: message.toolCallId,
arguments: message.arguments,
result: message.result,
status: message.status ?? "completed",
reasoningDurationMs,
};
}
if (message.type === "node-transition") {
return {
kind: "node-transition",
id: message.id,
timestamp: message.timestamp,
nodeId: message.nodeId,
nodeName: message.nodeName ?? message.text,
previousNodeId: message.previousNodeId,
previousNodeName: message.previousNode,
allowInterrupt: message.allowInterrupt,
};
}
if (message.type === "interrupt-warning") {
return {
kind: "notice",
id: message.id,
timestamp: message.timestamp,
tone: "warning",
title: "Interruption Disabled",
text: message.text,
linkHref: "https://docs.dograh.com/configurations/interruption",
linkLabel: "Learn more",
};
}
if (message.type === "pipeline-error") {
return {
kind: "notice",
id: message.id,
timestamp: message.timestamp,
tone: "error",
title: message.fatal ? "Fatal Pipeline Error" : "Pipeline Error",
text: message.text,
fatal: message.fatal,
};
}
return null;
}
export function conversationItemsFromLiveFeedback(messages: RealtimeFeedbackMessage[]) {
const items: ConversationItem[] = [];
let pendingReasoningDurationMs: number | undefined;
messages.forEach((message) => {
if (message.type === "ttfb-metric") {
if (message.ttfbSeconds !== undefined) {
pendingReasoningDurationMs = message.ttfbSeconds * 1000;
}
return;
}
const item = liveFeedbackItem(message, pendingReasoningDurationMs);
if (!item) {
return;
}
items.push(item);
if (item.kind === "message" || item.kind === "tool-call") {
pendingReasoningDurationMs = undefined;
}
});
return items;
}
export function conversationItemsFromRealtimeFeedbackEvents(events: RealtimeFeedbackEvent[]) {
const items: ConversationItem[] = [];
const toolCallIndexById = new Map<string, number>();
let pendingReasoningDurationMs: number | undefined;
let currentBotItemIndex: number | null = null;
let currentBotTurn: number | null = null;
events.forEach((event, index) => {
if (event.type === "rtf-ttfb-metric") {
if (event.payload.ttfb_seconds !== undefined) {
pendingReasoningDurationMs = event.payload.ttfb_seconds * 1000;
}
return;
}
if (event.type === "rtf-user-transcription") {
currentBotItemIndex = null;
currentBotTurn = null;
items.push({
kind: "message",
id: `user-${event.turn}-${index}`,
timestamp: event.timestamp,
role: "user",
text: feedbackEventText(event),
final: event.payload.final,
});
return;
}
if (event.type === "rtf-bot-text") {
const text = feedbackEventText(event);
const lastItem = currentBotItemIndex !== null ? items[currentBotItemIndex] : null;
if (
currentBotItemIndex !== null &&
currentBotTurn === event.turn &&
lastItem?.kind === "message" &&
lastItem.role === "assistant"
) {
items[currentBotItemIndex] = {
...lastItem,
text: `${lastItem.text} ${text}`.trim(),
};
return;
}
items.push({
kind: "message",
id: `bot-${event.turn}-${index}`,
timestamp: event.timestamp,
role: "assistant",
text,
final: event.payload.final,
reasoningDurationMs: pendingReasoningDurationMs,
});
currentBotItemIndex = items.length - 1;
currentBotTurn = event.turn;
pendingReasoningDurationMs = undefined;
return;
}
currentBotItemIndex = null;
currentBotTurn = null;
if (event.type === "rtf-function-call-start") {
const toolCallId = event.payload.tool_call_id;
items.push({
kind: "tool-call",
id: toolCallId ?? `tool-${event.turn}-${index}`,
timestamp: event.timestamp,
functionName: event.payload.function_name ?? "tool",
toolCallId,
arguments: event.payload.arguments,
status: "running",
reasoningDurationMs: pendingReasoningDurationMs,
});
if (toolCallId) {
toolCallIndexById.set(toolCallId, items.length - 1);
}
pendingReasoningDurationMs = undefined;
return;
}
if (event.type === "rtf-function-call-end") {
const toolCallId = event.payload.tool_call_id;
const existingIndex = toolCallId ? toolCallIndexById.get(toolCallId) : undefined;
if (existingIndex !== undefined) {
const existingItem = items[existingIndex];
if (existingItem?.kind === "tool-call") {
items[existingIndex] = {
...existingItem,
status: "completed",
result: event.payload.result,
};
}
return;
}
items.push({
kind: "tool-call",
id: toolCallId ?? `tool-result-${event.turn}-${index}`,
timestamp: event.timestamp,
functionName: event.payload.function_name ?? "tool",
toolCallId,
result: event.payload.result,
status: "completed",
reasoningDurationMs: pendingReasoningDurationMs,
});
pendingReasoningDurationMs = undefined;
return;
}
if (event.type === "rtf-node-transition") {
items.push({
kind: "node-transition",
id: `node-${event.turn}-${index}`,
timestamp: event.timestamp,
nodeId: event.payload.node_id,
nodeName: event.payload.node_name ?? feedbackEventText(event) ?? "Node",
previousNodeId: event.payload.previous_node_id,
previousNodeName: event.payload.previous_node_name ?? event.payload.previous_node,
allowInterrupt: event.payload.allow_interrupt,
});
return;
}
if (event.type === "rtf-interrupt-warning") {
items.push({
kind: "notice",
id: `warning-${event.turn}-${index}`,
timestamp: event.timestamp,
tone: "warning",
title: "Interruption Disabled",
text: feedbackEventText(event),
linkHref: "https://docs.dograh.com/configurations/interruption",
linkLabel: "Learn more",
});
return;
}
if (event.type === "rtf-pipeline-error") {
items.push({
kind: "notice",
id: `error-${event.turn}-${index}`,
timestamp: event.timestamp,
tone: "error",
title: event.payload.fatal ? "Fatal Pipeline Error" : "Pipeline Error",
text: feedbackEventText(event),
fatal: event.payload.fatal,
});
}
});
return items;
}

View file

@ -0,0 +1,180 @@
import type { ConversationItem } from "../types";
interface TextChatMessageLike {
text?: string;
created_at?: string;
}
interface TextChatEventLike {
type?: unknown;
payload?: unknown;
created_at?: unknown;
}
interface TextChatTurnLike {
id: string;
status?: string;
created_at?: string;
user_message?: TextChatMessageLike | null;
assistant_message?: TextChatMessageLike | null;
events?: Array<Record<string, unknown>>;
}
function asRecord(value: unknown) {
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
}
function asString(value: unknown) {
return typeof value === "string" ? value : undefined;
}
function conversationItemsFromTextChatEvents(
events: Array<Record<string, unknown>>,
turnId: string,
fallbackTimestamp?: string,
) {
const items: ConversationItem[] = [];
const toolCallIndexById = new Map<string, number>();
events.forEach((rawEvent, index) => {
const event = rawEvent as TextChatEventLike;
const eventType = asString(event.type);
const payload = asRecord(event.payload);
if (!eventType || !payload) {
return;
}
const timestamp = asString(event.created_at) ?? fallbackTimestamp;
if (eventType === "node_transition") {
const nodeName = asString(payload.node_name) ?? "Node";
items.push({
kind: "node-transition",
id: `${turnId}-node-${index}`,
turnId,
timestamp,
nodeId: asString(payload.node_id),
nodeName,
previousNodeId: asString(payload.previous_node_id),
previousNodeName: asString(payload.previous_node_name),
allowInterrupt: typeof payload.allow_interrupt === "boolean" ? payload.allow_interrupt : undefined,
});
return;
}
if (eventType === "execution_error") {
items.push({
kind: "notice",
id: `${turnId}-error-${index}`,
turnId,
timestamp,
tone: "error",
title: "Execution Error",
text: asString(payload.message) ?? "Execution error",
fatal: true,
});
return;
}
if (eventType === "tool_call_started") {
const functionName = asString(payload.function_name) ?? "tool";
const toolCallId = asString(payload.tool_call_id);
items.push({
kind: "tool-call",
id: toolCallId ?? `${turnId}-tool-${index}`,
turnId,
timestamp,
functionName,
toolCallId,
status: "running",
arguments: payload.arguments,
});
if (toolCallId) {
toolCallIndexById.set(toolCallId, items.length - 1);
}
return;
}
if (eventType === "tool_call_result") {
const functionName = asString(payload.function_name) ?? "tool";
const toolCallId = asString(payload.tool_call_id);
const existingIndex = toolCallId ? toolCallIndexById.get(toolCallId) : undefined;
if (existingIndex !== undefined) {
const existingItem = items[existingIndex];
if (existingItem?.kind === "tool-call") {
items[existingIndex] = {
...existingItem,
status: "completed",
result: payload.result,
};
}
return;
}
items.push({
kind: "tool-call",
id: toolCallId ?? `${turnId}-tool-result-${index}`,
turnId,
timestamp,
functionName,
toolCallId,
status: "completed",
result: payload.result,
});
}
});
return items;
}
export function conversationItemsFromTextChatTurns(turns: TextChatTurnLike[]) {
const items: ConversationItem[] = [];
turns.forEach((turn) => {
if (turn.user_message?.text) {
items.push({
kind: "message",
id: `${turn.id}-user`,
turnId: turn.id,
timestamp: turn.user_message.created_at ?? turn.created_at,
role: "user",
text: turn.user_message.text,
});
}
items.push(
...conversationItemsFromTextChatEvents(
turn.events ?? [],
turn.id,
turn.created_at,
),
);
if (turn.assistant_message?.text) {
items.push({
kind: "message",
id: `${turn.id}-assistant`,
turnId: turn.id,
timestamp: turn.assistant_message.created_at ?? turn.created_at,
role: "assistant",
text: turn.assistant_message.text,
});
return;
}
if (turn.status === "failed") {
items.push({
kind: "message",
id: `${turn.id}-assistant-failed`,
turnId: turn.id,
timestamp: turn.created_at,
role: "assistant",
text: "Agent turn failed",
tone: "muted",
});
}
});
return items;
}

View file

@ -0,0 +1,5 @@
export * from "./ConversationContainer";
export * from "./ConversationRailFrame";
export * from "./ConversationTimeline";
export * from "./RealtimeFeedback";
export * from "./types";

View file

@ -0,0 +1,117 @@
export type ConversationStatus = "ready" | "live" | "ended";
export type RealtimeFeedbackMessageType =
| "user-transcription"
| "bot-text"
| "function-call"
| "node-transition"
| "ttfb-metric"
| "pipeline-error"
| "interrupt-warning";
export interface RealtimeFeedbackMessage {
id: string;
type: RealtimeFeedbackMessageType;
text: string;
final?: boolean;
timestamp: string;
functionName?: string;
toolCallId?: string;
arguments?: unknown;
result?: unknown;
status?: "running" | "completed";
nodeId?: string;
nodeName?: string;
previousNodeId?: string;
previousNode?: string;
allowInterrupt?: boolean;
ttfbSeconds?: number;
processor?: string;
model?: string;
fatal?: boolean;
}
export interface RealtimeFeedbackEvent {
type: string;
payload: {
text?: string;
final?: boolean;
user_id?: string;
timestamp?: string;
function_name?: string;
tool_call_id?: string;
arguments?: unknown;
result?: unknown;
node_id?: string;
node_name?: string;
previous_node_id?: string;
previous_node?: string;
previous_node_name?: string;
allow_interrupt?: boolean;
ttfb_seconds?: number;
processor?: string;
model?: string;
error?: string;
fatal?: boolean;
};
timestamp: string;
turn: number;
}
export interface WorkflowRunLogs {
realtime_feedback_events?: RealtimeFeedbackEvent[];
}
interface ConversationItemBase {
id: string;
timestamp?: string;
turnId?: string;
reasoningDurationMs?: number;
}
export interface ConversationMessageItem extends ConversationItemBase {
kind: "message";
role: "user" | "assistant";
text: string;
final?: boolean;
tone?: "default" | "muted";
}
export interface ConversationToolCallItem extends ConversationItemBase {
kind: "tool-call";
functionName: string;
toolCallId?: string;
status: "running" | "completed";
arguments?: unknown;
result?: unknown;
}
export interface ConversationNodeTransitionItem extends ConversationItemBase {
kind: "node-transition";
nodeId?: string;
nodeName: string;
previousNodeId?: string;
previousNodeName?: string;
allowInterrupt?: boolean;
}
export interface ConversationNoticeItem extends ConversationItemBase {
kind: "notice";
tone: "warning" | "error";
title: string;
text: string;
fatal?: boolean;
linkHref?: string;
linkLabel?: string;
}
export type ConversationItem =
| ConversationMessageItem
| ConversationToolCallItem
| ConversationNodeTransitionItem
| ConversationNoticeItem;
export interface ConversationEmptyStateData {
title: string;
subtitle: string;
}

View file

@ -0,0 +1,21 @@
import type { ConversationItem } from "./types";
export function formatConversationValue(value: unknown) {
if (value == null) {
return "None";
}
if (typeof value === "string") {
return value;
}
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
export function countConversationMessages(items: ConversationItem[]) {
return items.filter(
(item) => item.kind === "message" && item.tone !== "muted",
).length;
}

View file

@ -9,6 +9,7 @@ export const WORKFLOW_RUN_MODES = {
CLOUDONIX: 'cloudonix',
WEBRTC: 'webrtc',
SMALL_WEBRTC: 'smallwebrtc',
TEXTCHAT: 'textchat',
ARI: 'ari',
TELNYX: 'telnyx',
PLIVO: 'plivo'

View file

@ -2,15 +2,19 @@
import { createContext, useContext, useEffect, useState } from 'react';
export type TooltipKey = 'web_call' | 'customize_workflow'; // Add more tooltip keys as needed
export type TooltipKey = 'web_call' | 'customize_workflow';
export type OnboardingActionKey = 'web_call_started';
interface OnboardingState {
seenTooltips: TooltipKey[];
completedActions: OnboardingActionKey[];
}
interface OnboardingContextType {
hasSeenTooltip: (key: TooltipKey) => boolean;
markTooltipSeen: (key: TooltipKey) => void;
hasCompletedAction: (key: OnboardingActionKey) => boolean;
markActionCompleted: (key: OnboardingActionKey) => void;
resetOnboarding: () => void;
}
@ -18,6 +22,7 @@ const ONBOARDING_STORAGE_KEY = 'dograh_onboarding_state';
const defaultState: OnboardingState = {
seenTooltips: [],
completedActions: [],
};
const OnboardingContext = createContext<OnboardingContextType | undefined>(undefined);
@ -59,6 +64,19 @@ export const OnboardingProvider = ({ children }: { children: React.ReactNode })
}));
};
const hasCompletedAction = (key: OnboardingActionKey): boolean => {
return onboardingState.completedActions.includes(key);
};
const markActionCompleted = (key: OnboardingActionKey) => {
setOnboardingState(prev => ({
...prev,
completedActions: prev.completedActions.includes(key)
? prev.completedActions
: [...prev.completedActions, key]
}));
};
const resetOnboarding = () => {
setOnboardingState(defaultState);
localStorage.removeItem(ONBOARDING_STORAGE_KEY);
@ -69,6 +87,8 @@ export const OnboardingProvider = ({ children }: { children: React.ReactNode })
value={{
hasSeenTooltip,
markTooltipSeen,
hasCompletedAction,
markActionCompleted,
resetOnboarding
}}
>

View file

@ -2,13 +2,9 @@
import { createContext, ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react';
import { client } from '@/client/client.gen';
import { getTelephonyConfigWarningsApiV1OrganizationsTelephonyConfigWarningsGet } from '@/client/sdk.gen';
import { useAuth } from '@/lib/auth';
interface TelephonyConfigWarningsResponse {
telnyx_missing_webhook_public_key_count: number;
}
interface TelephonyConfigWarningsContextType {
telnyxMissingWebhookPublicKeyCount: number;
refresh: () => Promise<void>;
@ -34,11 +30,8 @@ export function TelephonyConfigWarningsProvider({ children }: { children: ReactN
const doFetch = useCallback(async () => {
setLoading(true);
try {
const res = await client.get<TelephonyConfigWarningsResponse>({
url: '/api/v1/organizations/telephony-config-warnings',
});
const data = res.data as TelephonyConfigWarningsResponse | undefined;
setCount(data?.telnyx_missing_webhook_public_key_count ?? 0);
const res = await getTelephonyConfigWarningsApiV1OrganizationsTelephonyConfigWarningsGet();
setCount(res.data?.telnyx_missing_webhook_public_key_count ?? 0);
} catch {
setCount(0);
} finally {

View file

@ -14,7 +14,7 @@ interface Result {
const CACHE_KEY = "dograh-latest-release";
const CACHE_TTL_MS = 6 * 60 * 60 * 1000;
const SEMVER_RE = /^(?:[a-z][a-z0-9-]*-)?v?(\d+)\.(\d+)\.(\d+)$/i;
const SEMVER_RE = /^v?(\d+)\.(\d+)\.(\d+)$/;
function parseSemver(tag: string): [number, number, number] | null {
const m = tag.match(SEMVER_RE);
@ -56,11 +56,11 @@ export function useLatestReleaseVersion(
}
let cancelled = false;
fetch("https://api.github.com/repos/dograh-hq/dograh/releases/latest")
fetch("/api/config/latest-version")
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (cancelled || !data?.tag_name) return;
const tag = data.tag_name as string;
if (cancelled || !data?.latest) return;
const tag = data.latest as string;
try {
localStorage.setItem(
CACHE_KEY,
@ -72,7 +72,7 @@ export function useLatestReleaseVersion(
setLatest(tag);
})
.catch(() => {
// silent — don't break the sidebar if GitHub is unreachable
// silent — don't break the sidebar if the lookup fails
});
return () => {
@ -80,19 +80,13 @@ export function useLatestReleaseVersion(
};
}, [enabled, currentVersion]);
const normalizedCurrent = currentVersion
? currentVersion.startsWith("v")
? currentVersion
: `v${currentVersion}`
: null;
const currentParsed = normalizedCurrent ? parseSemver(normalizedCurrent) : null;
const currentParsed = currentVersion ? parseSemver(currentVersion) : null;
const latestParsed = latest ? parseSemver(latest) : null;
const isBehind = !!(
normalizedCurrent &&
currentVersion &&
latest &&
isOlder(normalizedCurrent, latest)
isOlder(currentVersion, latest)
);
const isLatest = !!(

View file

@ -131,9 +131,7 @@ export async function getServerAccessToken(): Promise<string | null> {
}
} else if (authProvider === 'local') {
// Get token from cookies (created by middleware)
const oss_token = await getOSSToken();
logger.debug(`oss_token: ${oss_token}`);
return oss_token;
return await getOSSToken();
}
return null;