feat: add text chat UI

This commit is contained in:
Abhishek Kumar 2026-05-18 14:38:51 +05:30
parent e313f2d235
commit f121147a42
14 changed files with 1465 additions and 294 deletions

View file

@ -5,37 +5,60 @@ Revises: 4c1f1e3e8ef2
Create Date: 2026-05-18 12:58:58.573381
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '2f638891cbb6'
down_revision: Union[str, None] = '4c1f1e3e8ef2'
revision: str = "2f638891cbb6"
down_revision: Union[str, None] = "4c1f1e3e8ef2"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('workflow_run_text_sessions',
sa.Column('workflow_run_id', sa.Integer(), nullable=False),
sa.Column('revision', sa.Integer(), server_default=sa.text('0'), nullable=False),
sa.Column('session_data', sa.JSON(), server_default=sa.text("'{}'::json"), nullable=False),
sa.Column('checkpoint', sa.JSON(), server_default=sa.text("'{}'::json"), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['workflow_run_id'], ['workflow_runs.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('workflow_run_id')
op.create_table(
"workflow_run_text_sessions",
sa.Column("workflow_run_id", sa.Integer(), nullable=False),
sa.Column(
"revision", sa.Integer(), server_default=sa.text("0"), nullable=False
),
sa.Column(
"session_data",
sa.JSON(),
server_default=sa.text("'{}'::json"),
nullable=False,
),
sa.Column(
"checkpoint",
sa.JSON(),
server_default=sa.text("'{}'::json"),
nullable=False,
),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(
["workflow_run_id"], ["workflow_runs.id"], ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("workflow_run_id"),
)
op.create_index(
"ix_workflow_run_text_sessions_updated_at",
"workflow_run_text_sessions",
["updated_at"],
unique=False,
)
op.create_index('ix_workflow_run_text_sessions_updated_at', 'workflow_run_text_sessions', ['updated_at'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('ix_workflow_run_text_sessions_updated_at', table_name='workflow_run_text_sessions')
op.drop_table('workflow_run_text_sessions')
op.drop_index(
"ix_workflow_run_text_sessions_updated_at",
table_name="workflow_run_text_sessions",
)
op.drop_table("workflow_run_text_sessions")
# ### end Alembic commands ###

View file

@ -541,9 +541,7 @@ class WorkflowRunTextSessionModel(Base):
onupdate=lambda: datetime.now(UTC),
)
__table_args__ = (
Index("ix_workflow_run_text_sessions_updated_at", "updated_at"),
)
__table_args__ = (Index("ix_workflow_run_text_sessions_updated_at", "updated_at"),)
class OrganizationUsageCycleModel(Base):

View file

@ -148,11 +148,15 @@ def _build_response_from_run_and_session(workflow_run, text_session):
)
def _validate_turn_cursor(session_data: Dict[str, Any], cursor_turn_id: str | None) -> None:
def _validate_turn_cursor(
session_data: Dict[str, Any], cursor_turn_id: str | None
) -> None:
if cursor_turn_id is None:
return
if not any(turn.get("id") == cursor_turn_id for turn in session_data["turns"]):
raise HTTPException(status_code=404, detail="Turn not found in text chat session")
raise HTTPException(
status_code=404, detail="Turn not found in text chat session"
)
def _truncate_future_turns(
@ -202,7 +206,9 @@ async def _load_text_session_or_404(
if text_session.workflow_run.workflow_id != workflow_id:
raise HTTPException(status_code=404, detail="Text chat session not found")
if text_session.workflow_run.mode != WorkflowRunMode.TEXTCHAT.value:
raise HTTPException(status_code=400, detail="Workflow run is not a text chat session")
raise HTTPException(
status_code=400, detail="Workflow run is not a text chat session"
)
return text_session

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

@ -14,6 +14,7 @@ import { createWorkflowDraftApiV1WorkflowWorkflowIdCreateDraftPost, getWorkflowV
import type { DocumentResponseSchema, RecordingResponseSchema, ToolResponse } from '@/client/types.gen';
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 { WorkflowConfigurations } from '@/types/workflow-configurations';
@ -23,6 +24,7 @@ import { GenericNode } from "../../../components/flow/nodes/GenericNode";
import { PhoneCallDialog } from './components/PhoneCallDialog';
import { VersionHistoryPanel, WorkflowVersion } from './components/VersionHistoryPanel';
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';
@ -65,6 +67,8 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
const router = useRouter();
const [isPhoneCallDialogOpen, setIsPhoneCallDialogOpen] = useState(false);
const [isVersionPanelOpen, setIsVersionPanelOpen] = useState(false);
const [isTesterRailOpen, setIsTesterRailOpen] = useState(true);
const [isTesterSheetOpen, setIsTesterSheetOpen] = useState(false);
const [versions, setVersions] = useState<WorkflowVersion[]>([]);
const [versionsLoading, setVersionsLoading] = useState(false);
const [versionsLoadingMore, setVersionsLoadingMore] = useState(false);
@ -86,6 +90,7 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
workflowName,
isDirty,
workflowValidationErrors,
templateContextVariables,
setNodes,
setEdges,
setIsDirty,
@ -97,7 +102,6 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
onConnect,
onEdgesChange,
onNodesChange,
onRun,
} = useWorkflowState({
initialWorkflowName,
workflowId,
@ -248,6 +252,27 @@ 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);
}, []);
// Fetch documents, tools, and recordings once for the entire workflow
useEffect(() => {
const fetchData = async () => {
@ -343,12 +368,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}
@ -359,158 +384,181 @@ 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={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"
/>
{/* 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}
onClose={() => setIsTesterRailOpen(false)}
/>
</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}
/>
</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,48 +385,25 @@ export const WorkflowEditorHeader = ({
</Popover>
)}
{/* Call button with dropdown (hidden when viewing history) */}
<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>
{!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>
<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>
)}
{/* Save button (only shown when editing the draft) */}

File diff suppressed because it is too large Load diff

View file

@ -13,8 +13,11 @@ import {
RealtimeFeedback,
WorkflowConfigErrorDialog
} from "./components";
import { TranscriptRailFrame } from "./components/shared/TranscriptRailFrame";
import { useWebSocketRTC } from "./hooks";
const RUN_SHELL_HEIGHT_CLASS = "h-[calc(100svh-49px)] min-h-[calc(100svh-49px)] max-h-[calc(100svh-49px)]";
const BrowserCall = ({ workflowId, workflowRunId, initialContextVariables }: {
workflowId: number,
workflowRunId: number,
@ -106,10 +109,9 @@ const BrowserCall = ({ workflowId, workflowRunId, initialContextVariables }: {
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">
<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="flex min-h-full items-center justify-center px-8 py-8">
<Card className="w-full max-w-xl">
<CardHeader>
<CardTitle>Call Voice Agent</CardTitle>
@ -151,14 +153,15 @@ const BrowserCall = ({ workflowId, workflowRunId, initialContextVariables }: {
</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 className="h-full min-h-0 w-[420px] shrink-0 border-l border-border bg-background p-5">
<TranscriptRailFrame className="h-full">
<RealtimeFeedback
mode="live"
messages={feedbackMessages}
isCallActive={connectionActive}
isCallCompleted={isCompleted}
/>
</TranscriptRailFrame>
</div>
</div>

View file

@ -42,31 +42,33 @@ export function TranscriptContainer({
const StatusIcon = statusConfig.icon;
return (
<div className="w-full h-full flex flex-col bg-background border-l border-border">
<div className="flex h-full min-h-0 w-full flex-col bg-background">
{/* 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 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 text-sm font-medium whitespace-nowrap">{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 items-center gap-1 rounded-full px-2 py-0.5 text-xs shrink-0",
statusConfig.className
)}>
<StatusIcon className="h-3 w-3" />
<span>{statusConfig.label}</span>
</div>
</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

@ -27,7 +27,7 @@ export function TranscriptMessage({ message, nextMessage }: TranscriptMessagePro
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">
<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">
{message.nodeName}
@ -98,7 +98,7 @@ export function TranscriptMessage({ message, nextMessage }: TranscriptMessagePro
<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">
<div className="inline-flex items-center gap-2 rounded-full border border-amber-500/20 bg-amber-500/10 px-3 py-1.5 text-xs">
<Wrench className="h-3 w-3 text-amber-500" />
<span className="font-mono text-amber-700 dark:text-amber-400">
{message.functionName}()
@ -120,7 +120,7 @@ export function TranscriptMessage({ message, nextMessage }: TranscriptMessagePro
"flex",
isUser ? "justify-end" : "justify-start"
)}>
<div className="flex flex-col gap-1 max-w-[85%]">
<div className="flex max-w-[85%] flex-col gap-1">
{/* 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">
@ -131,10 +131,10 @@ export function TranscriptMessage({ message, nextMessage }: TranscriptMessagePro
)}
<div
className={cn(
"px-3 py-2 rounded-2xl text-sm",
"rounded-2xl px-4 py-3 text-sm shadow-sm",
isUser
? "bg-primary text-primary-foreground rounded-br-md"
: "bg-muted rounded-bl-md",
: "bg-muted rounded-bl-md border border-slate-200/80",
!message.final && "opacity-70"
)}
>

View file

@ -0,0 +1,40 @@
'use client';
import { type ReactNode } from 'react';
import { cn } from '@/lib/utils';
interface TranscriptRailFrameProps {
children: ReactNode;
className?: string;
header?: ReactNode;
footer?: ReactNode;
}
export function TranscriptRailFrame({
children,
className,
header,
footer,
}: TranscriptRailFrameProps) {
return (
<div className={cn(
'min-h-0 flex h-full 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

@ -8,6 +8,7 @@ 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 { TranscriptRailFrame } from '@/app/workflow/[workflowId]/run/[runId]/components/shared/TranscriptRailFrame';
import WorkflowLayout from '@/app/workflow/WorkflowLayout';
import {
createWorkflowRunApiV1WorkflowWorkflowIdRunsPost,
@ -29,12 +30,83 @@ interface WorkflowRunResponse {
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);
@ -94,12 +166,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(() => {
@ -120,6 +186,7 @@ export default function WorkflowRunPage() {
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,
@ -181,10 +248,9 @@ export default function WorkflowRunPage() {
}
else if (workflowRun?.is_completed) {
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">
@ -285,6 +351,12 @@ export default function WorkflowRunPage() {
</CardContent>
</Card>
<RunMetricsSection
costInfo={workflowRun?.cost_info}
logs={workflowRun?.logs}
gatheredContext={workflowRun?.gathered_context}
/>
<div className="grid gap-6 md:grid-cols-2">
<ContextDisplay
title="Initial Context"
@ -305,33 +377,32 @@ 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">
<TranscriptRailFrame className="h-full">
<RealtimeFeedback mode="historical" logs={workflowRun?.logs} />
</TranscriptRailFrame>
</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
}
/>
</div>
<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
}
/>
}
return (

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 isWorkflowRunPage = /^\/workflow\/[^/]+\/run\/[^/]+(?:\/.*)?$/.test(pathname);
if (isWorkflowRunPage) {
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,6 +9,7 @@ export const WORKFLOW_RUN_MODES = {
CLOUDONIX: 'cloudonix',
WEBRTC: 'webrtc',
SMALL_WEBRTC: 'smallwebrtc',
TEXTCHAT: 'textchat',
ARI: 'ari',
TELNYX: 'telnyx',
PLIVO: 'plivo'