feat: Update Dograh's UI Design (#67)

* feat: create app sidebar and update layout

* fix: fix loading errors

* fix: fix stack auth hydration issue

* fix: fix design for create-workflow

* fix: fix service configuration page design

* Add header for workflow detail

* feat: fix workflow editor design

* Fix css classes

* Fix callback status parsing for Vobiz

* Fix filter and remove gender service
This commit is contained in:
Abhishek 2025-11-29 15:39:57 +05:30 committed by GitHub
parent 8342cd1dda
commit a7f2238044
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
90 changed files with 4398 additions and 2312 deletions

View file

@ -1,4 +1,5 @@
import { Globe, Headset, OctagonX, Play, X } from 'lucide-react';
import { useEffect } from 'react';
import { Button } from '@/components/ui/button';
@ -41,9 +42,20 @@ const GLOBAL_NODE_TYPES = [
]
export default function AddNodePanel({ isOpen, onNodeSelect, onClose }: AddNodePanelProps) {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isOpen) {
onClose();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
return (
<div
className={`fixed z-51 right-0 top-0 h-full w-80 bg-white shadow-lg transform transition-transform duration-300 ease-in-out ${isOpen ? 'translate-x-0' : 'translate-x-full'
className={`fixed z-51 right-0 top-0 h-full w-80 bg-background shadow-lg transform transition-transform duration-300 ease-in-out ${isOpen ? 'translate-x-0' : 'translate-x-full'
}`}
>
<div className="p-4">
@ -54,7 +66,7 @@ export default function AddNodePanel({ isOpen, onNodeSelect, onClose }: AddNodeP
</Button>
</div>
<h1 className="text-sm text-gray-500 mb-2">Agent Nodes</h1>
<h1 className="text-sm text-muted-foreground mb-2">Agent Nodes</h1>
<div className="space-y-2">
{NODE_TYPES.map((node) => (
@ -65,19 +77,19 @@ export default function AddNodePanel({ isOpen, onNodeSelect, onClose }: AddNodeP
onClick={() => onNodeSelect(node.type)}
>
<div className="flex items-center">
<div className="bg-gray-100 p-2 rounded-lg mr-3 border border-gray-200">
<div className="bg-muted p-2 rounded-lg mr-3 border border-border">
<node.icon className="h-6 w-6" />
</div>
<div className="flex flex-col items-start">
<span className="font-medium">{node.label}</span>
<span className="text-sm text-gray-500">{node.description}</span>
<span className="text-sm text-muted-foreground">{node.description}</span>
</div>
</div>
</Button>
))}
</div>
<h1 className="text-sm text-gray-500 mb-2">Global Nodes</h1>
<h1 className="text-sm text-muted-foreground mb-2">Global Nodes</h1>
<div className="space-y-2">
{GLOBAL_NODE_TYPES.map((node) => (
@ -88,12 +100,12 @@ export default function AddNodePanel({ isOpen, onNodeSelect, onClose }: AddNodeP
onClick={() => onNodeSelect(node.type)}
>
<div className="flex items-center">
<div className="bg-gray-100 p-2 rounded-lg mr-3 border border-gray-200">
<div className="bg-muted p-2 rounded-lg mr-3 border border-border">
<node.icon className="h-6 w-6" />
</div>
<div className="flex flex-col items-start">
<span className="font-medium">{node.label}</span>
<span className="text-sm text-gray-500">{node.description}</span>
<span className="text-sm text-muted-foreground">{node.description}</span>
</div>
</div>
</Button>

View file

@ -1,4 +1,4 @@
import { BaseEdge, type Edge, EdgeLabelRenderer, type EdgeProps, getBezierPath, useReactFlow } from '@xyflow/react';
import { BaseEdge, type Edge, EdgeLabelRenderer, type EdgeProps, getSmoothStepPath, useReactFlow } from '@xyflow/react';
import { AlertCircle, Pencil } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
@ -54,7 +54,7 @@ const EdgeDetailsDialog = ({ open, onOpenChange, data, onSave }: EdgeDetailsDial
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>Condition Label</Label>
<Label className="text-xs text-gray-500">
<Label className="text-xs text-muted-foreground">
Enter a short label which helps identify this pathway in logs
</Label>
<Input
@ -63,13 +63,13 @@ const EdgeDetailsDialog = ({ open, onOpenChange, data, onSave }: EdgeDetailsDial
maxLength={64}
onChange={(e) => setLabel(e.target.value)}
/>
<div className="text-xs text-gray-500">
<div className="text-xs text-muted-foreground">
{label.length}/64 characters
</div>
</div>
<div className="grid gap-2">
<Label>Condition</Label>
<Label className="text-xs text-gray-500">
<Label className="text-xs text-muted-foreground">
Describe a condition that will be evaluated to determine if this pathway should be taken
</Label>
<Textarea
@ -126,15 +126,44 @@ export default function CustomEdge(props: CustomEdgeProps) {
}
}
// 3) draw the bezier path + get label coords
const [edgePath, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
// Check if this is a self-loop (source and target are the same node)
const isSelfLoop = source === target;
// 3) draw the edge path + get label coords
// Use custom arc path for self-loops, smoothstep for regular edges
let edgePath: string;
let labelX: number;
let labelY: number;
if (isSelfLoop) {
// Create a loop arc that goes out and around the node
const loopRadius = 50;
const loopOffsetX = 80;
// Arc path: start from source, curve out and back to target
edgePath = `M ${sourceX} ${sourceY}
C ${sourceX + loopOffsetX} ${sourceY - loopRadius},
${targetX + loopOffsetX} ${targetY + loopRadius},
${targetX} ${targetY}`;
labelX = sourceX + loopOffsetX;
labelY = sourceY;
} else {
// Use smoothstep path for orthogonal/elbow edges
// borderRadius: 8 gives slightly rounded corners for a clean look
// offset: 20 provides spacing before the first bend
const [path, lx, ly] = getSmoothStepPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
borderRadius: 8,
offset: 20,
});
edgePath = path;
labelX = lx;
labelY = ly;
}
// Update connected nodes when edge is selected or hovered
useEffect(() => {
@ -221,22 +250,22 @@ export default function CustomEdge(props: CustomEdgeProps) {
{/* Show full EdgeLabel when selected or hovered, otherwise show simple label */}
{(selected || isHovered) ? (
<div className={cn(
"flex flex-col gap-2 bg-white rounded-lg border-2 shadow-xl min-w-[200px]",
"flex flex-col gap-2 bg-card rounded-lg border shadow-xl min-w-[220px]",
"animate-in fade-in zoom-in duration-200",
data?.invalid ? "border-red-500 shadow-[0_0_15px_rgba(239,68,68,0.5)]" : "border-gray-300"
data?.invalid ? "border-destructive/50 shadow-[0_0_15px_rgba(239,68,68,0.3)]" : "border-border"
)}>
{/* Header with label */}
<div className={cn(
"flex items-center justify-between px-3 py-2 border-b",
data?.invalid ? "bg-red-50 border-red-200" : "bg-gray-50 border-gray-200"
data?.invalid ? "bg-destructive/10 border-destructive/30" : "bg-muted/50 border-border"
)}>
<span className="text-xs font-semibold text-gray-600 uppercase tracking-wide">
Condition - EdgeID: {id}
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Condition
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 p-0 hover:bg-gray-200"
className="h-6 w-6 p-0 hover:bg-muted text-muted-foreground"
onClick={() => setOpen(true)}
>
<Pencil className="h-3 w-3" />
@ -244,20 +273,21 @@ export default function CustomEdge(props: CustomEdgeProps) {
</div>
{/* Content */}
<div className="px-3 pb-3">
<div className="text-sm font-medium text-gray-900 break-words">
<div className="text-sm font-medium text-card-foreground break-words">
{data?.label || data?.condition || 'Click to set condition'}
</div>
</div>
</div>
) : (
/* Simple label shown by default */
/* Simple label shown by default - amber/orange colored pill style */
<div className={cn(
"px-2 py-1 bg-white rounded border shadow-sm",
data?.invalid ? "border-red-400 text-red-600" : "border-gray-300 text-gray-700"
"px-3 py-1.5 rounded-full text-xs font-medium shadow-md",
"transition-all duration-200",
data?.invalid
? "bg-destructive text-destructive-foreground"
: "bg-amber-500 text-amber-950"
)}>
<div className="text-xs font-medium">
{data?.label || data?.condition || 'No condition'}
</div>
{data?.label || data?.condition || 'No condition'}
</div>
)}
</div>

View file

@ -105,15 +105,15 @@ export const AgentNode = memo(({ data, selected, id }: AgentNodeProps) => {
hovered_through_edge={data.hovered_through_edge}
title={data.name || 'Agent'}
icon={<Headset />}
bgColor="bg-blue-300"
nodeType="agent"
hasSourceHandle={true}
hasTargetHandle={true}
onDoubleClick={() => setOpen(true)}
nodeId={id}
>
<div className="text-sm text-muted-foreground">
{data.prompt?.length > 30 ? `${data.prompt.substring(0, 30)}...` : data.prompt}
</div>
<p className="text-sm text-muted-foreground line-clamp-5 leading-relaxed">
{data.prompt || 'No prompt configured'}
</p>
</NodeContent>
<NodeToolbar isVisible={selected} position={Position.Right}>
@ -204,7 +204,7 @@ const AgentNodeEditForm = ({
return (
<div className="grid gap-2">
<Label>Name</Label>
<Label className="text-xs text-gray-500">
<Label className="text-xs text-muted-foreground">
The name of the agent that will be used to identify the agent in the call logs. It should be short and should identify the step in the call.
</Label>
<Input
@ -215,7 +215,7 @@ const AgentNodeEditForm = ({
<div className="flex items-center space-x-2 p-2 border rounded-md bg-muted/20">
<Switch id="allow-interrupt" checked={allowInterrupt} onCheckedChange={setAllowInterrupt} />
<Label htmlFor="allow-interrupt">Allow Interruption</Label>
<Label className="text-xs text-gray-500 ml-2">
<Label className="text-xs text-muted-foreground ml-2">
Whether you would like user to be able to interrupt the bot.
</Label>
</div>
@ -223,7 +223,7 @@ const AgentNodeEditForm = ({
<div className="flex items-center space-x-2 p-2 border rounded-md bg-muted/20">
<Switch id="add-global-prompt" checked={addGlobalPrompt} onCheckedChange={setAddGlobalPrompt} />
<Label htmlFor="add-global-prompt">Add Global Prompt</Label>
<Label className="text-xs text-gray-500 ml-2">
<Label className="text-xs text-muted-foreground ml-2">
Whether you want to add global prompt with this node&apos;s prompt.
</Label>
</div>
@ -231,7 +231,7 @@ const AgentNodeEditForm = ({
<div className="pt-2 space-y-2">
<Label>Prompt</Label>
<Label className="text-xs text-gray-500">
<Label className="text-xs text-muted-foreground">
Enter the prompt for the agent. This will be used to generate the agent&apos;s response. Prompt engineering&apos;s best practices apply.
</Label>
<Textarea
@ -249,7 +249,7 @@ const AgentNodeEditForm = ({
<div className="flex items-center space-x-2 pt-2">
<Switch id="enable-extraction" checked={extractionEnabled} onCheckedChange={setExtractionEnabled} />
<Label htmlFor="enable-extraction">Enable Variable Extraction</Label>
<Label className="text-xs text-gray-500 ml-2">
<Label className="text-xs text-muted-foreground ml-2">
Are there any variables you would like to extract from the conversation?
</Label>
</div>
@ -257,7 +257,7 @@ const AgentNodeEditForm = ({
{extractionEnabled && (
<div className="border rounded-md p-3 mt-2 space-y-2 bg-muted/20">
<Label>Extraction Prompt</Label>
<Label className="text-xs text-gray-500">
<Label className="text-xs text-muted-foreground">
Provide an overall extraction prompt that guides how variables should be extracted from the conversation.
</Label>
<Textarea
@ -268,7 +268,7 @@ const AgentNodeEditForm = ({
/>
<Label>Variables</Label>
<Label className="text-xs text-gray-500">
<Label className="text-xs text-muted-foreground">
Define each variable you want to extract along with its data type.
</Label>

View file

@ -14,14 +14,19 @@ export const BaseNode = forwardRef<
<div
ref={ref}
className={cn(
"relative rounded-md border bg-card p-5 text-card-foreground min-w-[300px] min-h-[100px]",
// Base styling - larger with max width, uses semantic colors
"relative rounded-lg border bg-card text-card-foreground min-w-[320px] max-w-[400px] min-h-[120px]",
// Border styling
"border-border",
className,
// Selected state
selected ? "border-muted-foreground shadow-lg" : "",
invalid ? "border-red-500 shadow-[0_0_10px_rgba(239,68,68,0.5)]" : "",
// Invalid state
invalid ? "border-destructive shadow-[0_0_10px_rgba(239,68,68,0.3)]" : "",
// Hovered through edge takes precedence over selected through edge
hovered_through_edge ? "ring-2 ring-blue-400 shadow-[0_0_12px_rgba(96,165,250,0.5)]" : "",
!hovered_through_edge && selected_through_edge ? "ring-1 ring-blue-500 shadow-[0_0_8px_rgba(59,130,246,0.4)]" : "",
!selected_through_edge && !hovered_through_edge && "hover:ring-1 hover:ring-gray-300",
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)]" : "",
!selected_through_edge && !hovered_through_edge && "hover:border-muted-foreground/50",
)}
tabIndex={0}
{...props}

View file

@ -103,14 +103,14 @@ export const EndCall = memo(({ data, selected, id }: EndCallNodeProps) => {
hovered_through_edge={data.hovered_through_edge}
title="End Call"
icon={<OctagonX />}
bgColor="bg-red-300"
nodeType="end"
hasTargetHandle={true}
onDoubleClick={() => setOpen(true)}
nodeId={id}
>
<div className="text-sm text-muted-foreground">
{data.prompt?.length > 30 ? `${data.prompt.substring(0, 30)}...` : data.prompt}
</div>
<p className="text-sm text-muted-foreground line-clamp-5 leading-relaxed">
{data.prompt || 'No prompt configured'}
</p>
</NodeContent>
<NodeToolbar isVisible={selected} position={Position.Right}>
@ -191,13 +191,13 @@ const EndCallEditForm = ({
return (
<div className="grid gap-2">
<Label>Name</Label>
<Label className="text-xs text-gray-500">
<Label className="text-xs text-muted-foreground">
The name of the agent that will be used to identify the agent in the call logs. It should be short and should identify the step in the call.
</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
<Label>Prompt</Label>
<Label className="text-xs text-gray-500">
<Label className="text-xs text-muted-foreground">
Enter the prompt for the agent. This will be used to generate the agent&apos;s response. Prompt engineering&apos;s best practices apply.
</Label>
<Textarea
@ -212,7 +212,7 @@ const EndCallEditForm = ({
<div className="flex items-center space-x-2">
<Switch id="add-global-prompt" checked={addGlobalPrompt} onCheckedChange={setAddGlobalPrompt} />
<Label htmlFor="add-global-prompt">Add Global Prompt</Label>
<Label className="text-xs text-gray-500">
<Label className="text-xs text-muted-foreground">
Whether you want to add global prompt with this node&apos;s prompt.
</Label>
</div>
@ -221,7 +221,7 @@ const EndCallEditForm = ({
<div className="flex items-center space-x-2 pt-2">
<Switch id="enable-extraction" checked={extractionEnabled} onCheckedChange={setExtractionEnabled} />
<Label htmlFor="enable-extraction">Enable Variable Extraction</Label>
<Label className="text-xs text-gray-500 ml-2">
<Label className="text-xs text-muted-foreground ml-2">
Are there any variables you would like to extract from the conversation?
</Label>
</div>
@ -229,7 +229,7 @@ const EndCallEditForm = ({
{extractionEnabled && (
<div className="border rounded-md p-3 mt-2 space-y-2 bg-muted/20">
<Label>Extraction Prompt</Label>
<Label className="text-xs text-gray-500">
<Label className="text-xs text-muted-foreground">
Provide an overall extraction prompt that guides how variables should be extracted from the conversation.
</Label>
<Textarea
@ -240,7 +240,7 @@ const EndCallEditForm = ({
/>
<Label>Variables</Label>
<Label className="text-xs text-gray-500">
<Label className="text-xs text-muted-foreground">
Define each variable you want to extract along with its data type.
</Label>

View file

@ -73,13 +73,13 @@ export const GlobalNode = memo(({ data, selected, id }: GlobalNodeProps) => {
hovered_through_edge={data.hovered_through_edge}
title={data.name || 'Global'}
icon={<Headset />}
bgColor="bg-orange-300"
nodeType="global"
onDoubleClick={() => setOpen(true)}
nodeId={id}
>
<div className="text-sm text-muted-foreground">
{data.prompt?.length > 30 ? `${data.prompt.substring(0, 30)}...` : data.prompt}
</div>
<p className="text-sm text-muted-foreground line-clamp-5 leading-relaxed">
{data.prompt || 'No prompt configured'}
</p>
</NodeContent>
<NodeToolbar isVisible={selected} position={Position.Right}>
@ -123,7 +123,7 @@ const GlobalNodeEditForm = ({
return (
<div className="grid gap-2">
<Label>Name</Label>
<Label className="text-xs text-gray-500">
<Label className="text-xs text-muted-foreground">
The name of the global node.
</Label>
<Input
@ -132,7 +132,7 @@ const GlobalNodeEditForm = ({
/>
<Label>Prompt</Label>
<Label className="text-xs text-gray-500">
<Label className="text-xs text-muted-foreground">
This is the global prompt. This will be added to the system prompt of all the agents.
</Label>
<Textarea

View file

@ -107,14 +107,14 @@ export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => {
hovered_through_edge={data.hovered_through_edge}
title="Start Call"
icon={<Play />}
bgColor="bg-green-300"
nodeType="start"
hasSourceHandle={true}
onDoubleClick={() => setOpen(true)}
nodeId={id}
>
<div className="text-sm text-muted-foreground">
{data.prompt?.length > 30 ? `${data.prompt.substring(0, 30)}...` : data.prompt}
</div>
<p className="text-sm text-muted-foreground line-clamp-5 leading-relaxed">
{data.prompt || 'No prompt configured'}
</p>
</NodeContent>
<NodeToolbar isVisible={selected} position={Position.Right}>
@ -173,7 +173,7 @@ const StartCallEditForm = ({
return (
<div className="grid gap-2">
<Label>Name</Label>
<Label className="text-xs text-gray-500">
<Label className="text-xs text-muted-foreground">
The name of the agent that will be used to identify the agent in the call logs. It should be short and should identify the step in the call.
</Label>
<Input
@ -182,7 +182,7 @@ const StartCallEditForm = ({
/>
<Label>Prompt</Label>
<Label className="text-xs text-gray-500">
<Label className="text-xs text-muted-foreground">
Enter the prompt for the agent. This will be used to generate the agent&apos;s response. Prompt engineering&apos;s best practices apply.
</Label>
<Textarea
@ -197,7 +197,7 @@ const StartCallEditForm = ({
<div className="flex items-center space-x-2">
<Switch id="allow-interrupt" checked={allowInterrupt} onCheckedChange={setAllowInterrupt} />
<Label htmlFor="allow-interrupt">Allow Interruption</Label>
<Label className="text-xs text-gray-500">
<Label className="text-xs text-muted-foreground">
Whether you would like user to be able to interrupt the bot.
</Label>
</div>
@ -221,7 +221,7 @@ const StartCallEditForm = ({
<Label htmlFor="detect-voicemail">
Detect Voicemail
</Label>
<Label className="text-xs text-gray-500">
<Label className="text-xs text-muted-foreground">
Automatically detect and end call if voicemail is reached.
</Label>
</div>
@ -236,7 +236,7 @@ const StartCallEditForm = ({
<Label htmlFor="delayed-start">
Delayed Start
</Label>
<Label className="text-xs text-gray-500">
<Label className="text-xs text-muted-foreground">
Introduce a delay before the agent starts speaking.
</Label>
</div>

View file

@ -3,7 +3,7 @@ import { ReactNode } from "react";
import { BaseHandle } from "@/components/flow/nodes/BaseHandle";
import { BaseNode } from "@/components/flow/nodes/BaseNode";
import { NodeHeader, NodeHeaderIcon, NodeHeaderTitle } from "@/components/flow/nodes/NodeHeader";
import { cn } from "@/lib/utils";
interface NodeContentProps {
selected: boolean;
@ -12,7 +12,7 @@ interface NodeContentProps {
hovered_through_edge?: boolean;
title: string;
icon: ReactNode;
bgColor: string;
nodeType?: 'start' | 'agent' | 'end' | 'global';
hasSourceHandle?: boolean;
hasTargetHandle?: boolean;
children?: ReactNode;
@ -21,6 +21,22 @@ interface NodeContentProps {
nodeId?: string;
}
// Get badge styling based on node type
const getNodeTypeBadge = (nodeType?: string) => {
switch (nodeType) {
case 'start':
return { label: 'Start Node', className: 'bg-emerald-500 text-white' };
case 'agent':
return { label: 'Agent Node', className: 'bg-blue-500 text-white' };
case 'end':
return { label: 'End Node', className: 'bg-rose-500 text-white' };
case 'global':
return { label: 'Global Node', className: 'bg-amber-500 text-white' };
default:
return { label: 'Node', className: 'bg-zinc-500 text-white' };
}
};
export const NodeContent = ({
selected,
invalid,
@ -28,31 +44,52 @@ export const NodeContent = ({
hovered_through_edge,
title,
icon,
bgColor,
nodeType,
hasSourceHandle = false,
hasTargetHandle = false,
children,
className = "",
onDoubleClick,
nodeId,
}: NodeContentProps) => {
const badge = getNodeTypeBadge(nodeType);
return (
<BaseNode
selected={selected}
invalid={invalid}
selected_through_edge={selected_through_edge}
hovered_through_edge={hovered_through_edge}
className={`p-0 overflow-hidden ${className}`}
className={`p-0 ${className}`}
onDoubleClick={onDoubleClick}
>
{hasTargetHandle && <BaseHandle type="target" position={Position.Top} />}
<NodeHeader className={`px-3 py-2 border-b ${bgColor}`}>
<NodeHeaderIcon>{icon}</NodeHeaderIcon>
<NodeHeaderTitle>{title} - NodeID: {nodeId}</NodeHeaderTitle>
</NodeHeader>
<div className="p-3">
{/* Node type badge - positioned at top */}
<div className="absolute -top-3 left-4">
<span className={cn(
"inline-flex items-center gap-1.5 px-2 py-0.5 rounded text-xs font-medium",
badge.className
)}>
<span className="[&>*]:w-3 [&>*]:h-3">{icon}</span>
{badge.label}
</span>
</div>
{/* Header with title */}
<div className="px-4 pt-5 pb-2 border-b border-border">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground truncate">
{title}
</h3>
</div>
</div>
{/* Content area with prompt label */}
<div className="p-4">
<div className="text-xs text-muted-foreground mb-1.5 font-medium">Prompt:</div>
{children}
</div>
{hasSourceHandle && <BaseHandle type="source" position={Position.Bottom} />}
</BaseNode>
);