From f121147a423f13bbcc001043485501edb6a38d81 Mon Sep 17 00:00:00 2001 From: Abhishek Kumar Date: Mon, 18 May 2026 14:38:51 +0530 Subject: [PATCH] feat: add text chat UI --- ...8891cbb6_add_workflow_run_text_sessions.py | 55 +- api/db/models.py | 4 +- api/routes/workflow_text_chat.py | 12 +- ui/src/app/globals.css | 19 - .../workflow/[workflowId]/RenderWorkflow.tsx | 324 +++--- .../components/WorkflowEditorHeader.tsx | 70 +- .../components/WorkflowTesterPanel.tsx | 1008 +++++++++++++++++ .../[workflowId]/run/[runId]/BrowserCall.tsx | 27 +- .../components/shared/TranscriptContainer.tsx | 38 +- .../components/shared/TranscriptMessage.tsx | 10 +- .../components/shared/TranscriptRailFrame.tsx | 40 + .../[workflowId]/run/[runId]/page.tsx | 133 ++- ui/src/components/ChatwootWidget.tsx | 18 +- ui/src/constants/workflowRunModes.ts | 1 + 14 files changed, 1465 insertions(+), 294 deletions(-) create mode 100644 ui/src/app/workflow/[workflowId]/components/WorkflowTesterPanel.tsx create mode 100644 ui/src/app/workflow/[workflowId]/run/[runId]/components/shared/TranscriptRailFrame.tsx diff --git a/api/alembic/versions/2f638891cbb6_add_workflow_run_text_sessions.py b/api/alembic/versions/2f638891cbb6_add_workflow_run_text_sessions.py index bcf0f14..7e1b112 100644 --- a/api/alembic/versions/2f638891cbb6_add_workflow_run_text_sessions.py +++ b/api/alembic/versions/2f638891cbb6_add_workflow_run_text_sessions.py @@ -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 ### diff --git a/api/db/models.py b/api/db/models.py index d4843be..2771025 100644 --- a/api/db/models.py +++ b/api/db/models.py @@ -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): diff --git a/api/routes/workflow_text_chat.py b/api/routes/workflow_text_chat.py index 3344570..8800f6d 100644 --- a/api/routes/workflow_text_chat.py +++ b/api/routes/workflow_text_chat.py @@ -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 diff --git a/ui/src/app/globals.css b/ui/src/app/globals.css index 0f252c8..0c7bdf9 100644 --- a/ui/src/app/globals.css +++ b/ui/src/app/globals.css @@ -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; -} - diff --git a/ui/src/app/workflow/[workflowId]/RenderWorkflow.tsx b/ui/src/app/workflow/[workflowId]/RenderWorkflow.tsx index afc91be..506e802 100644 --- a/ui/src/app/workflow/[workflowId]/RenderWorkflow.tsx +++ b/ui/src/app/workflow/[workflowId]/RenderWorkflow.tsx @@ -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([]); 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 */} -
- { - 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"} - > - +
+
+
+ { + 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"} + > + - {/* Top-right controls - vertical layout (hidden when viewing history) */} - {!isViewingHistoricalVersion && ( - + {/* Top-right controls - vertical layout (hidden when viewing history) */} + {!isViewingHistoricalVersion && ( + + +
+ + + + + +

Add node

+
+
+ + + + + + +

Workflow settings

+
+
+
+
+
+ )} +
+ + {/* Bottom-left controls - horizontal layout with custom buttons */} +
-
- - - - - -

Add node

-
-
+ + + + + +

Zoom in

+
+
+ + + + + +

Zoom out

+
+
+ + + + + + +

Fit view

+
+
+ + {!isViewingHistoricalVersion && ( - -

Workflow settings

+ +

Tidy Up

-
+ )}
- +
+
+ + {isTesterRailOpen && ( + )} - - - {/* Bottom-left controls - horizontal layout with custom buttons */} -
- - {/* Zoom In */} - - - - - -

Zoom in

-
-
- - {/* Zoom Out */} - - - - - -

Zoom out

-
-
- - {/* Fit View */} - - - - - -

Fit view

-
-
- - {/* Tidy/Arrange Nodes (hidden when viewing history) */} - {!isViewingHistoricalVersion && ( - - - - - -

Tidy Up

-
-
- )} -
+ + + + + +
| null>; - onRun: (mode: string) => Promise; workflowId: number; workflowUuid?: string; saveWorkflow: (updateWorkflowDefinition?: boolean) => Promise; 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 = ({
- {/* Right section: Version + Unsaved indicator + Call button + Save button */} + {/* Right section: Version + status + tester/call actions + save */}
{/* Read-only banner when viewing a historical version */} {isViewingHistoricalVersion && ( @@ -388,48 +385,25 @@ export const WorkflowEditorHeader = ({ )} - {/* Call button with dropdown (hidden when viewing history) */} + + {!isViewingHistoricalVersion && ( - - - - - - { - 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" - > - - Web Call - - { - // 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 Call - - - + )} {/* Save button (only shown when editing the draft) */} diff --git a/ui/src/app/workflow/[workflowId]/components/WorkflowTesterPanel.tsx b/ui/src/app/workflow/[workflowId]/components/WorkflowTesterPanel.tsx new file mode 100644 index 0000000..90ba5e7 --- /dev/null +++ b/ui/src/app/workflow/[workflowId]/components/WorkflowTesterPanel.tsx @@ -0,0 +1,1008 @@ +"use client"; + +import { AlertCircle, ArrowLeft, ArrowUpRight, Bot, Loader2, MessageSquareText, Mic, Phone, RefreshCw, RotateCcw, Sparkles, X } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; + +import { client } from "@/client/client.gen"; +import { createWorkflowRunApiV1WorkflowWorkflowIdRunsPost } from "@/client/sdk.gen"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Textarea } from "@/components/ui/textarea"; +import { WORKFLOW_RUN_MODES } from "@/constants/workflowRunModes"; +import { useAuth } from "@/lib/auth"; +import { cn, getRandomId } from "@/lib/utils"; + +import { ApiKeyErrorDialog, ConnectionStatus, RealtimeFeedback, WorkflowConfigErrorDialog } from "../run/[runId]/components"; +import { useWebSocketRTC } from "../run/[runId]/hooks"; + +const TEXT_SESSION_STORAGE_PREFIX = "workflow-text-chat-session"; + +interface WorkflowTesterPanelProps { + workflowId: number; + initialContextVariables?: Record; + disabled: boolean; + disabledReason: string | null; + className?: string; + onClose?: () => void; +} + +interface TextChatMessage { + text: string; + created_at: string; +} + +interface TextChatTurn { + id: string; + status: string; + created_at: string; + user_message: TextChatMessage; + assistant_message: TextChatMessage | null; + events: Array>; + usage: Record; +} + +interface TextChatSessionData { + version: number; + status: string; + cursor_turn_id: string | null; + turns: TextChatTurn[]; + discarded_future: Array>; + simulator: { + enabled: boolean; + config: Record; + }; +} + +interface TextChatCheckpoint { + version: number; + anchor_turn_id: string | null; + current_node_id: string | null; + messages: Array>; + gathered_context: Record; + tool_state: Record; +} + +interface TextChatSessionResponse { + workflow_run_id: number; + workflow_id: number; + name: string; + mode: string; + state: string; + is_completed: boolean; + revision: number; + initial_context: Record | null; + gathered_context: Record | null; + annotations: Record | null; + session_data: TextChatSessionData; + checkpoint: TextChatCheckpoint; + created_at: string; + updated_at: string | null; +} + +function getTextSessionStorageKey(workflowId: number) { + return `${TEXT_SESSION_STORAGE_PREFIX}:${workflowId}`; +} + +function formatTimestamp(timestamp: string | null | undefined) { + if (!timestamp) return ""; + const parsed = new Date(timestamp); + if (Number.isNaN(parsed.getTime())) return ""; + return parsed.toLocaleString([], { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + +function getErrorMessage(error: unknown) { + if (error instanceof Error) return error.message; + return "Something went wrong"; +} + +async function readErrorMessage(response: Response) { + const payload = await response.json().catch(() => null) as + | { detail?: string | { message?: string } } + | null; + if (typeof payload?.detail === "string") return payload.detail; + if (typeof payload?.detail === "object" && payload.detail?.message) { + return payload.detail.message; + } + return `Request failed with ${response.status}`; +} + +function DisabledNotice({ reason }: { reason: string }) { + return ( +
+
+ +
+

Testing is paused

+

{reason}

+
+
+
+ ); +} + +function EmptyState({ + icon, + title, + description, + action, +}: { + icon: ReactNode; + title: string; + description: string; + action?: ReactNode; +}) { + return ( +
+
+ {icon} +
+
+

{title}

+

{description}

+
+ {action ?
{action}
: null} +
+ ); +} + +function TextChatModeCard({ + title, + description, + preview, + actionLabel, + icon, + onClick, +}: { + title: string; + description: string; + preview: ReactNode; + actionLabel: string; + icon: ReactNode; + onClick: () => void; +}) { + return ( + + ); +} + +function TextChatLanding({ + onSelectManual, + onSelectSimulated, +}: { + onSelectManual: () => void; + onSelectSimulated: () => void; +}) { + return ( +
+ } + onClick={onSelectManual} + preview={ +
+
+
+ How are you doing? +
+
+
+
+ I am doing well. +
+
+
+ } + /> + } + onClick={onSelectSimulated} + preview={ +
+
+ User Prompt +
+
+ You are a customer who wants to return a package... +
+
+ } + /> +
+ ); +} + +function EmbeddedVoiceTester({ + workflowId, + workflowRunId, + initialContextVariables, + accessToken, + onReset, +}: { + workflowId: number; + workflowRunId: number; + initialContextVariables?: Record; + accessToken: string; + onReset: () => void; +}) { + 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, + }); + 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 ( + <> +
+
+
+
+ + Run {workflowRunId} + + + Browser Voice Test + +
+ + The call starts as soon as this test run is created. + +
+ +
+ +
+ +
+ +
+
+ + {permissionError ? ( +

{permissionError}

+ ) : null} + +
+
+ +
+ + router.push("/api-keys")} + onNavigateToModelConfig={() => router.push("/model-configurations")} + /> + + router.push(`/workflow/${workflowId}`)} + /> + + ); +} + +function ManualTextChat({ + workflowId, + accessToken, + initialContextVariables, + disabled, + disabledReason, + onBack, +}: { + workflowId: number; + accessToken: string | null; + initialContextVariables?: Record; + disabled: boolean; + disabledReason: string | null; + onBack: () => void; +}) { + const [session, setSession] = useState(null); + const [draft, setDraft] = useState(""); + const [loading, setLoading] = useState(true); + const [creatingSession, setCreatingSession] = useState(false); + const [sendingMessage, setSendingMessage] = useState(false); + const [rewindingTurnId, setRewindingTurnId] = useState(null); + + const storageKey = useMemo(() => getTextSessionStorageKey(workflowId), [workflowId]); + const turns = session?.session_data.turns ?? []; + const hasTurns = turns.length > 0; + + const request = useCallback(async ( + path: string, + init?: RequestInit, + ): Promise => { + if (!accessToken) { + throw new Error("Authentication is still loading"); + } + + const baseUrl = client.getConfig().baseUrl || window.location.origin; + const response = await fetch(`${baseUrl}${path}`, { + ...init, + headers: { + "Authorization": `Bearer ${accessToken}`, + "Content-Type": "application/json", + ...(init?.headers ?? {}), + }, + }); + + if (!response.ok) { + throw new Error(await readErrorMessage(response)); + } + + return response.json() as Promise; + }, [accessToken]); + + useEffect(() => { + let ignore = false; + + const restoreSession = async () => { + if (!accessToken) { + setLoading(false); + return; + } + + const storedRunId = window.localStorage.getItem(storageKey); + if (!storedRunId) { + setLoading(false); + return; + } + + setLoading(true); + try { + const restored = await request(`/api/v1/workflow/${workflowId}/text-chat/sessions/${storedRunId}`); + if (ignore) return; + setSession(restored); + } catch { + window.localStorage.removeItem(storageKey); + } finally { + if (!ignore) { + setLoading(false); + } + } + }; + + restoreSession(); + + return () => { + ignore = true; + }; + }, [accessToken, request, storageKey, workflowId]); + + const createSession = useCallback(async () => { + if (disabled) return; + setCreatingSession(true); + try { + const created = await request(`/api/v1/workflow/${workflowId}/text-chat/sessions`, { + method: "POST", + body: JSON.stringify({ + initial_context: initialContextVariables ?? {}, + annotations: { + tester: { + source: "workflow_editor", + modality: "text", + ui_mode: "manual_text", + }, + }, + }), + }); + setSession(created); + setDraft(""); + window.localStorage.setItem(storageKey, String(created.workflow_run_id)); + } catch (error) { + toast.error(getErrorMessage(error)); + } finally { + setCreatingSession(false); + } + }, [disabled, initialContextVariables, request, storageKey, workflowId]); + + useEffect(() => { + if (loading || creatingSession || session || !accessToken || disabled) { + return; + } + void createSession(); + }, [accessToken, createSession, creatingSession, disabled, loading, session]); + + const sendMessage = useCallback(async () => { + if (!session || !draft.trim() || disabled) return; + setSendingMessage(true); + try { + const updated = await request( + `/api/v1/workflow/${workflowId}/text-chat/sessions/${session.workflow_run_id}/messages`, + { + method: "POST", + body: JSON.stringify({ + text: draft.trim(), + expected_revision: session.revision, + }), + }, + ); + setSession(updated); + setDraft(""); + } catch (error) { + toast.error(getErrorMessage(error)); + } finally { + setSendingMessage(false); + } + }, [disabled, draft, request, session, workflowId]); + + const rewindToTurn = useCallback(async (turnId: string) => { + if (!session || disabled) return; + setRewindingTurnId(turnId); + try { + const updated = await request( + `/api/v1/workflow/${workflowId}/text-chat/sessions/${session.workflow_run_id}/rewind`, + { + method: "POST", + body: JSON.stringify({ + cursor_turn_id: turnId, + expected_revision: session.revision, + }), + }, + ); + setSession(updated); + } catch (error) { + toast.error(getErrorMessage(error)); + } finally { + setRewindingTurnId(null); + } + }, [disabled, request, session, workflowId]); + + if (loading || (creatingSession && !session)) { + return ( +
+ + + +
+ ); + } + + return ( +
+
+
+
Manual Chat
+

Manually chat with the agent.

+
+
+ + {session ? ( + + ) : null} +
+
+ + {disabledReason ? : null} + + {!session ? ( + } + title={disabled ? "Manual chat is paused" : "Preparing chat"} + description={disabled + ? (disabledReason ?? "Save the draft before starting a manual chat.") + : "Creating a durable text session for this workflow."} + /> + ) : ( +
+
+
+ {turns.length === 0 + ? "Send the first message to begin testing." + : "Continue the conversation or rewind from any previous turn."} +
+ {session.session_data.discarded_future.length > 0 ? ( + + {session.session_data.discarded_future.length} archived branch{session.session_data.discarded_future.length === 1 ? "" : "es"} + + ) : null} +
+ +
+
+ {hasTurns ? turns.map((turn) => { + const isResumePoint = session.session_data.cursor_turn_id === turn.id; + return ( +
+
+
+

+ {turn.id.replace("turn_", "Turn ")} +

+ {isResumePoint ? ( + Resume point + ) : null} +
+
+ {formatTimestamp(turn.created_at)} + +
+
+ +
+
+

{turn.user_message.text}

+
+
+ +
+
+ {turn.assistant_message ? ( +

{turn.assistant_message.text}

+ ) : ( +
+

Assistant turn pending

+

+ The durable session was saved. The executor that fills assistant replies lands in the next pass. +

+
+ )} +
+
+
+ ); + }) : ( + } + title="Session created" + description="Send the first user message to populate this test thread." + /> + )} +
+
+ +
+
+