feat: enable runtime transition of nodes

This commit is contained in:
Abhishek Kumar 2026-05-21 14:48:02 +05:30
parent f1fdc41949
commit dfee942f9a
22 changed files with 369 additions and 200 deletions

122
ui/package-lock.json generated
View file

@ -26,7 +26,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@sentry/nextjs": "^9.28.1",
"@stackframe/stack": "^2.8.80",
"@xyflow/react": "^12.9.2",
"@xyflow/react": "^12.10.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
@ -11227,12 +11227,12 @@
"peer": true
},
"node_modules/@xyflow/react": {
"version": "12.9.2",
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.9.2.tgz",
"integrity": "sha512-Xr+LFcysHCCoc5KRHaw+FwbqbWYxp9tWtk1mshNcqy25OAPuaKzXSdqIMNOA82TIXF/gFKo0Wgpa6PU7wUUVqw==",
"version": "12.10.2",
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.2.tgz",
"integrity": "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==",
"license": "MIT",
"dependencies": {
"@xyflow/system": "0.0.72",
"@xyflow/system": "0.0.76",
"classcat": "^5.0.3",
"zustand": "^4.4.0"
},
@ -11270,9 +11270,9 @@
}
},
"node_modules/@xyflow/system": {
"version": "0.0.72",
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.72.tgz",
"integrity": "sha512-WBI5Aau0fXTXwxHPzceLNS6QdXggSWnGjDtj/gG669crApN8+SCmEtkBth1m7r6pStNo/5fI9McEi7Dk0ymCLA==",
"version": "0.0.76",
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.76.tgz",
"integrity": "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==",
"license": "MIT",
"dependencies": {
"@types/d3-drag": "^3.0.7",
@ -11661,12 +11661,6 @@
"tslib": "^2.4.0"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
@ -11702,17 +11696,6 @@
"node": ">=4"
}
},
"node_modules/axios": {
"version": "1.13.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@ -11957,6 +11940,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@ -12243,18 +12227,6 @@
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
"license": "MIT"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/commander": {
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
@ -12797,15 +12769,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/destr": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
@ -12875,6 +12838,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
@ -13022,6 +12986,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -13031,6 +12996,7 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -13075,6 +13041,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@ -13087,6 +13054,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@ -13908,26 +13876,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@ -13944,22 +13892,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/forwarded-parse": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/forwarded-parse/-/forwarded-parse-2.1.2.tgz",
@ -14048,6 +13980,7 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@ -14081,6 +14014,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
@ -14192,6 +14126,7 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -14268,6 +14203,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -14280,6 +14216,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
@ -15084,12 +15021,6 @@
"license": "MIT",
"peer": true
},
"node_modules/json-schema": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
"license": "(AFL-2.1 OR BSD-3-Clause)"
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@ -15499,6 +15430,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -15547,6 +15479,7 @@
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@ -15556,6 +15489,7 @@
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"peer": true,
"dependencies": {
"mime-db": "1.52.0"
},
@ -18131,18 +18065,6 @@
"node": ">= 0.8.0"
}
},
"node_modules/type-fest": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typed-array-buffer": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",

View file

@ -30,7 +30,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@sentry/nextjs": "^9.28.1",
"@stackframe/stack": "^2.8.80",
"@xyflow/react": "^12.9.2",
"@xyflow/react": "^12.10.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",

View file

@ -17,6 +17,7 @@ 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";
@ -24,6 +25,7 @@ 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";
@ -40,6 +42,8 @@ interface RenderWorkflowProps {
initialWorkflowName: string;
workflowId: number;
workflowUuid?: string;
initialTotalRuns?: number | null;
openTesterOnLoad?: boolean;
initialFlow?: {
nodes: FlowNode[];
edges: FlowEdge[];
@ -56,18 +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);
@ -75,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,
@ -208,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");
@ -283,6 +312,29 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
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 () => {
@ -326,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.
@ -418,7 +512,7 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
<div className="relative min-w-0 flex-1">
<ReactFlow
key={activeVersionId ?? 'current'}
nodes={nodes}
nodes={displayNodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
@ -572,7 +666,12 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
initialContextVariables={templateContextVariables}
disabled={testerDisabledReason !== null}
disabledReason={testerDisabledReason}
showWebCallOnboarding={shouldShowWebCallOnboarding}
isVisible={isDesktopViewport}
onClose={() => setIsTesterRailOpen(false)}
runtimeFocusMode={runtimeFocusMode}
onRuntimeFocusModeChange={setRuntimeFocusMode}
onRuntimeNodeTransition={handleRuntimeNodeTransition}
/>
</aside>
)}
@ -585,6 +684,11 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
initialContextVariables={templateContextVariables}
disabled={testerDisabledReason !== null}
disabledReason={testerDisabledReason}
showWebCallOnboarding={shouldShowWebCallOnboarding}
isVisible={isTesterSheetOpen}
runtimeFocusMode={runtimeFocusMode}
onRuntimeFocusModeChange={setRuntimeFocusMode}
onRuntimeNodeTransition={handleRuntimeNodeTransition}
/>
</SheetContent>
</Sheet>

View file

@ -1,21 +1,26 @@
"use client";
import { Loader2, MessageSquareText, Mic, Phone, RefreshCw, X } from "lucide-react";
import { useCallback, useEffect, useState } from "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 } from "./workflow-tester/shared";
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 {
@ -23,8 +28,13 @@ interface WorkflowTesterPanelProps {
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({
@ -32,10 +42,16 @@ export function WorkflowTesterPanel({
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");
@ -45,6 +61,7 @@ export function WorkflowTesterPanel({
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;
@ -99,6 +116,13 @@ export function WorkflowTesterPanel({
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) {
@ -106,13 +130,22 @@ export function WorkflowTesterPanel({
} finally {
setCreatingVoiceRun(false);
}
}, [accessToken, disabled, workflowId]);
}, [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)}>
@ -145,6 +178,13 @@ export function WorkflowTesterPanel({
</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">
@ -165,6 +205,7 @@ export function WorkflowTesterPanel({
initialContextVariables={initialContextVariables}
accessToken={accessToken}
onReset={() => setVoiceRunId(null)}
onNodeTransition={onRuntimeNodeTransition}
/>
) : (
<>
@ -174,7 +215,11 @@ export function WorkflowTesterPanel({
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 onClick={createVoiceRun} disabled={creatingVoiceRun || testerBlocked}>
<Button
ref={runTestButtonRef}
onClick={createVoiceRun}
disabled={creatingVoiceRun || testerBlocked}
>
{creatingVoiceRun ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
@ -221,6 +266,7 @@ export function WorkflowTesterPanel({
disabled={testerBlocked}
disabledReason={effectiveDisabledReason}
onActiveChange={setChatActive}
onNodeTransition={onRuntimeNodeTransition}
/>
) : (
<AiSimulatorPlaceholder disabledReason={effectiveDisabledReason} />
@ -228,6 +274,15 @@ export function WorkflowTesterPanel({
</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

@ -9,6 +9,7 @@ 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;
@ -16,6 +17,7 @@ interface EmbeddedVoiceTesterProps {
initialContextVariables?: Record<string, string>;
accessToken: string;
onReset: () => void;
onNodeTransition?: (transition: WorkflowRuntimeNodeTransition) => void;
}
export function EmbeddedVoiceTester({
@ -24,6 +26,7 @@ export function EmbeddedVoiceTester({
initialContextVariables,
accessToken,
onReset,
onNodeTransition,
}: EmbeddedVoiceTesterProps) {
const router = useRouter();
const {
@ -48,6 +51,7 @@ export function EmbeddedVoiceTester({
workflowRunId,
accessToken,
initialContextVariables,
onNodeTransition,
});
const autoStartedRef = useRef(false);

View file

@ -7,6 +7,7 @@ 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 {
@ -16,6 +17,7 @@ interface ManualTextChatPanelProps {
disabled: boolean;
disabledReason: string | null;
onActiveChange?: (active: boolean) => void;
onNodeTransition?: (transition: WorkflowRuntimeNodeTransition) => void;
}
export function ManualTextChatPanel({
@ -25,6 +27,7 @@ export function ManualTextChatPanel({
disabled,
disabledReason,
onActiveChange,
onNodeTransition,
}: ManualTextChatPanelProps) {
const {
session,
@ -51,6 +54,7 @@ export function ManualTextChatPanel({
initialContextVariables,
disabled,
onActiveChange,
onNodeTransition,
});
if (!started && !session) {
@ -70,7 +74,7 @@ export function ManualTextChatPanel({
</div>
) : null}
<div className="min-h-0 flex-1">
<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" />

View file

@ -81,6 +81,42 @@ export function ChatModeToggle({
);
}
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">

View file

@ -1,4 +1,5 @@
import type { WorkflowRunTextSessionResponse } from "@/client/types.gen";
import type { ConversationNodeTransitionItem } from "@/components/workflow/conversation";
export interface TextChatMessage {
text: string;
@ -46,6 +47,10 @@ export interface TurnActionState {
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 {

View file

@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import {
@ -16,6 +16,7 @@ import {
type TextChatTurn,
toTextChatSession,
type TurnActionState,
type WorkflowRuntimeNodeTransition,
} from "./types";
import { extractSdkErrorMessage, getErrorMessage, getReplayCursorTurnId } from "./utils";
@ -25,6 +26,7 @@ interface UseTextChatSessionProps {
initialContextVariables?: Record<string, string>;
disabled: boolean;
onActiveChange?: (active: boolean) => void;
onNodeTransition?: (transition: WorkflowRuntimeNodeTransition) => void;
}
export function useTextChatSession({
@ -33,6 +35,7 @@ export function useTextChatSession({
initialContextVariables,
disabled,
onActiveChange,
onNodeTransition,
}: UseTextChatSessionProps) {
const [session, setSession] = useState<TextChatSession | null>(null);
const [started, setStarted] = useState(false);
@ -41,6 +44,7 @@ export function useTextChatSession({
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
@ -91,6 +95,26 @@ export function useTextChatSession({
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;

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

@ -4,7 +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 { RealtimeFeedbackMessage as FeedbackMessage } from "@/components/workflow/conversation";
import type { ConversationNodeTransitionItem, RealtimeFeedbackMessage as FeedbackMessage } from "@/components/workflow/conversation";
import { useAppConfig } from "@/context/AppConfigContext";
import logger from '@/lib/logger';
@ -16,9 +16,10 @@ interface UseWebSocketRTCProps {
workflowRunId: number;
accessToken: string | null;
initialContextVariables?: Record<string, string> | null;
onNodeTransition?: (transition: ConversationNodeTransitionItem) => void;
}
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);
@ -53,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 = () => {
@ -394,17 +400,37 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
}
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,17 +1,14 @@
'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 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';
@ -23,7 +20,6 @@ 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;
@ -151,9 +147,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();
@ -205,24 +199,6 @@ 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 showHistoricalRunView = Boolean(workflowRun?.is_completed || isTextChatRun);
@ -271,21 +247,6 @@ export default function WorkflowRunPage() {
</div>
</div>
<div className="flex items-center gap-2">
{!isTextChatRun && (
<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}

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>

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

@ -62,7 +62,9 @@ function liveFeedbackItem(message: RealtimeFeedbackMessage, reasoningDurationMs?
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,
};
@ -241,7 +243,9 @@ export function conversationItemsFromRealtimeFeedbackEvents(events: RealtimeFeed
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,
});

View file

@ -53,7 +53,9 @@ function conversationItemsFromTextChatEvents(
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,
});

View file

@ -20,7 +20,9 @@ export interface RealtimeFeedbackMessage {
arguments?: unknown;
result?: unknown;
status?: "running" | "completed";
nodeId?: string;
nodeName?: string;
previousNodeId?: string;
previousNode?: string;
allowInterrupt?: boolean;
ttfbSeconds?: number;
@ -40,7 +42,9 @@ export interface RealtimeFeedbackEvent {
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;
@ -84,7 +88,9 @@ export interface ConversationToolCallItem extends ConversationItemBase {
export interface ConversationNodeTransitionItem extends ConversationItemBase {
kind: "node-transition";
nodeId?: string;
nodeName: string;
previousNodeId?: string;
previousNodeName?: string;
allowInterrupt?: boolean;
}

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

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