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

@ -5,15 +5,15 @@ Revises: e02f387b7538
Create Date: 2025-11-27 21:24:34.072030
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from alembic_postgresql_enum import TableReference
# revision identifiers, used by Alembic.
revision: str = 'a188ff90e76f'
down_revision: Union[str, None] = 'e02f387b7538'
revision: str = "a188ff90e76f"
down_revision: Union[str, None] = "e02f387b7538"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
@ -21,10 +21,23 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.sync_enum_values(
enum_schema='public',
enum_name='workflow_run_mode',
new_values=['twilio', 'vonage', 'vobiz', 'stasis', 'webrtc', 'smallwebrtc', 'VOICE', 'CHAT'],
affected_columns=[TableReference(table_schema='public', table_name='workflow_runs', column_name='mode')],
enum_schema="public",
enum_name="workflow_run_mode",
new_values=[
"twilio",
"vonage",
"vobiz",
"stasis",
"webrtc",
"smallwebrtc",
"VOICE",
"CHAT",
],
affected_columns=[
TableReference(
table_schema="public", table_name="workflow_runs", column_name="mode"
)
],
enum_values_to_rename=[],
)
# ### end Alembic commands ###
@ -33,10 +46,22 @@ def upgrade() -> None:
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.sync_enum_values(
enum_schema='public',
enum_name='workflow_run_mode',
new_values=['twilio', 'vonage', 'stasis', 'webrtc', 'smallwebrtc', 'VOICE', 'CHAT'],
affected_columns=[TableReference(table_schema='public', table_name='workflow_runs', column_name='mode')],
enum_schema="public",
enum_name="workflow_run_mode",
new_values=[
"twilio",
"vonage",
"stasis",
"webrtc",
"smallwebrtc",
"VOICE",
"CHAT",
],
affected_columns=[
TableReference(
table_schema="public", table_name="workflow_runs", column_name="mode"
)
],
enum_values_to_rename=[],
)
# ### end Alembic commands ###

View file

@ -3,7 +3,7 @@
from datetime import datetime
from typing import Any, Dict, List, Optional
from sqlalchemy import Integer, and_, cast
from sqlalchemy import Integer, and_, cast, func
from sqlalchemy.dialects.postgresql import JSONB
from api.db.models import WorkflowRunModel
@ -128,10 +128,16 @@ def apply_workflow_run_filters(
):
tags = value.get("codes", [])
if tags:
# The gathered_context column is JSON type (not JSONB)
# JSON type doesn't support subscripting, so we must cast to JSONB first
# Then extract call_tags and check containment with @>
gathered_context_jsonb = cast(
WorkflowRunModel.gathered_context, JSONB
)
# Use -> operator with literal text key to get call_tags as JSONB
call_tags = gathered_context_jsonb.op("->")("call_tags")
filter_conditions.append(
cast(WorkflowRunModel.gathered_context, JSONB)[
"call_tags"
].contains(tags)
call_tags.op("@>")(func.cast(tags, JSONB))
)
elif filter_type == "text" and field == "initial_context.phone":

View file

@ -277,7 +277,6 @@ async def handle_twilio_status_callback(
# Parse form data
form_data = await request.form()
callback_data = dict(form_data)
logger.info(
f"[run {workflow_run_id}] Received status callback: {json.dumps(callback_data)}"
)
@ -519,20 +518,11 @@ async def handle_vobiz_hangup_callback(
This includes call duration, status, and billing information.
"""
# Parse the callback data (Vobiz sends form data or JSON)
try:
callback_data = await request.json()
logger.info(
f"[run {workflow_run_id}] Received Vobiz hangup callback (JSON): "
f"{json.dumps(callback_data)}"
)
except Exception:
# Fallback to form data if JSON fails
form_data = await request.form()
callback_data = dict(form_data)
logger.info(
f"[run {workflow_run_id}] Received Vobiz hangup callback (form): "
f"{json.dumps(callback_data)}"
)
form_data = await request.form()
callback_data = dict(form_data)
logger.info(
f"[run {workflow_run_id}] Received Vobiz hangup callback {json.dumps(callback_data)}"
)
# Get workflow run for processing
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
@ -591,19 +581,11 @@ async def handle_vobiz_ring_callback(
This is optional and used for tracking ringing status.
"""
# Parse the callback data
try:
callback_data = await request.json()
logger.info(
f"[run {workflow_run_id}] Received Vobiz ring callback (JSON): "
f"{json.dumps(callback_data)}"
)
except Exception:
form_data = await request.form()
callback_data = dict(form_data)
logger.info(
f"[run {workflow_run_id}] Received Vobiz ring callback (form): "
f"{json.dumps(callback_data)}"
)
form_data = await request.form()
callback_data = dict(form_data)
logger.info(
f"[run {workflow_run_id}] Received Vobiz ring callback {json.dumps(callback_data)}"
)
# Get workflow run for processing
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)

View file

@ -275,13 +275,13 @@ class VobizProvider(TelephonyProvider):
- status, from, to, duration, etc.
"""
return {
"call_id": data.get("call_uuid", data.get("CallUUID", "")),
"status": data.get("status", data.get("Status", "")),
"from_number": data.get("from", data.get("From")),
"to_number": data.get("to", data.get("To")),
"direction": data.get("direction", data.get("Direction")),
"duration": data.get("duration", data.get("Duration")),
"extra": data, # Include all original data
"call_id": data.get("CallUUID", ""),
"status": data.get("CallStatus", ""),
"from_number": data.get("From"),
"to_number": data.get("To"),
"direction": data.get("Direction"),
"duration": data.get("Duration"),
"extra": data,
}
async def handle_websocket(

View file

@ -1,7 +1,6 @@
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Optional, Union
from api.constants import DEPLOYMENT_MODE, ENABLE_TRACING, VOICEMAIL_RECORDING_DURATION
from api.services.gender.gender_service import GenderService
from api.services.workflow.disposition_mapper import (
apply_disposition_mapping,
get_organization_id_from_workflow_run,
@ -94,8 +93,6 @@ class PipecatEngine:
# access to _context
self._variable_extraction_manager = None
self._gender_service = GenderService(confidence_threshold=0.5)
# Voicemail detection state
self._detect_voicemail = False
self._voicemail_detector = None
@ -160,13 +157,6 @@ class PipecatEngine:
# Register built-in functions with the LLM
await self._register_builtin_functions()
# Set gender in initial context predicted from first name
if "first_name" in self._call_context_vars:
salutation = await self._gender_service.get_salutation(
self._call_context_vars["first_name"]
)
self._call_context_vars["salutation"] = salutation
await self.set_node(self.workflow.start_node_id)
logger.debug(f"{self.__class__.__name__} initialized")
except Exception as e:

2114
ui/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -16,20 +16,21 @@
"@livekit/components-react": "^2.9.0",
"@nangohq/frontend": "^0.60.3",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.13",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.7",
"@radix-ui/react-label": "^2.1.3",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-select": "^2.2.2",
"@radix-ui/react-separator": "^1.1.3",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.1.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.0",
"@radix-ui/react-tooltip": "^1.2.8",
"@sentry/nextjs": "^9.28.1",
"@stackframe/stack": "^2.8.28",
"@stackframe/stack": "^2.8.52",
"@xyflow/react": "^12.9.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

View file

@ -52,8 +52,8 @@ export default async function AfterSignInPage() {
logger.debug('[AfterSignInPage] Redirecting to /workflow - user has workflows');
redirect('/workflow');
} else {
logger.debug('[AfterSignInPage] Redirecting to /create-workflow - no workflows found');
redirect('/create-workflow');
logger.debug('[AfterSignInPage] Redirecting to /workflow/create - no workflows found');
redirect('/workflow/create');
}
}
} catch (error) {
@ -61,6 +61,6 @@ export default async function AfterSignInPage() {
}
// Default fallback
logger.debug('[AfterSignInPage] Final fallback redirect to /create-workflow');
redirect('/create-workflow');
logger.debug('[AfterSignInPage] Final fallback redirect to /workflow/create');
redirect('/workflow/create');
}

View file

@ -1,14 +0,0 @@
import BaseHeader from "@/components/header/BaseHeader";
export default function APIKeysLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
<BaseHeader/>
{children}
</>
);
}

View file

@ -302,7 +302,7 @@ export default function APIKeysPage() {
// Don't render content until auth is loaded
if (loading || !user) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="space-y-4">
<Skeleton className="h-12 w-64" />
<Skeleton className="h-64 w-96" />
@ -317,16 +317,16 @@ export default function APIKeysPage() {
const showServiceKeyArchiveControls = !isOSSMode();
return (
<div className="min-h-screen bg-gray-50">
<div className="min-h-screen bg-background">
<div className="container mx-auto px-4 py-8">
<div className="max-w-6xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">Developer Portal</h1>
<p className="text-gray-600">Manage your API keys to access Dograh services programmatically</p>
<h1 className="text-3xl font-bold mb-2">Developer Portal</h1>
<p className="text-muted-foreground">Manage your API keys to access Dograh services programmatically</p>
</div>
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
<div className="mb-4 p-4 bg-destructive/10 border border-destructive/20 rounded-lg text-destructive">
{error}
</div>
)}
@ -374,8 +374,8 @@ export default function APIKeysPage() {
</div>
) : apiKeys.length === 0 ? (
<div className="text-center py-12">
<Key className="w-12 h-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-600 mb-4">No API keys found</p>
<Key className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground mb-4">No API keys found</p>
<Button onClick={() => setIsCreateDialogOpen(true)}>
Create Your First API Key
</Button>
@ -386,12 +386,12 @@ export default function APIKeysPage() {
<div
key={key.id}
className={`flex items-center justify-between p-4 border rounded-lg ${
key.archived_at ? 'bg-gray-50 opacity-60' : 'bg-white'
key.archived_at ? 'bg-muted opacity-60' : 'bg-card'
}`}
>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-gray-900">{key.name}</span>
<span className="font-medium">{key.name}</span>
{key.archived_at ? (
<Badge variant="secondary">Archived</Badge>
) : key.is_active ? (
@ -400,13 +400,13 @@ export default function APIKeysPage() {
<Badge variant="destructive">Inactive</Badge>
)}
</div>
<div className="flex items-center gap-2 text-sm text-gray-600">
<span className="font-mono bg-gray-100 px-2 py-1 rounded">{key.key_prefix}...</span>
<span className="text-xs text-gray-400">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="font-mono bg-muted px-2 py-1 rounded">{key.key_prefix}...</span>
<span className="text-xs text-muted-foreground/70">
(Full key hidden for security)
</span>
</div>
<div className="mt-2 text-xs text-gray-500">
<div className="mt-2 text-xs text-muted-foreground">
Created: {formatDate(key.created_at)}
Last used: {formatDate(key.last_used_at ?? null)}
</div>
@ -426,7 +426,7 @@ export default function APIKeysPage() {
variant="ghost"
size="sm"
onClick={() => handleArchiveKey(key.id)}
className="text-red-600 hover:text-red-700"
className="text-destructive hover:text-destructive/90"
>
<Trash2 className="w-4 h-4" />
</Button>
@ -487,8 +487,8 @@ export default function APIKeysPage() {
</div>
) : serviceKeys.length === 0 ? (
<div className="text-center py-12">
<Key className="w-12 h-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-600 mb-4">No service keys found</p>
<Key className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground mb-4">No service keys found</p>
{canCreateServiceKey && (
<Button onClick={() => setIsCreateServiceDialogOpen(true)}>
Create Your First Service Key
@ -501,12 +501,12 @@ export default function APIKeysPage() {
<div
key={key.id}
className={`flex items-center justify-between p-4 border rounded-lg ${
key.archived_at ? 'bg-gray-50 opacity-60' : 'bg-white'
key.archived_at ? 'bg-muted opacity-60' : 'bg-card'
}`}
>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-gray-900">{key.name}</span>
<span className="font-medium">{key.name}</span>
{key.archived_at ? (
<Badge variant="secondary">Archived</Badge>
) : key.is_active ? (
@ -520,13 +520,13 @@ export default function APIKeysPage() {
</Badge>
)}
</div>
<div className="flex items-center gap-2 text-sm text-gray-600">
<span className="font-mono bg-gray-100 px-2 py-1 rounded">{key.key_prefix}...</span>
<span className="text-xs text-gray-400">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="font-mono bg-muted px-2 py-1 rounded">{key.key_prefix}...</span>
<span className="text-xs text-muted-foreground/70">
(Full key hidden for security)
</span>
</div>
<div className="mt-2 text-xs text-gray-500">
<div className="mt-2 text-xs text-muted-foreground">
Created: {formatDate(key.created_at)}
Last used: {formatDate(key.last_used_at ?? null)}
</div>
@ -537,7 +537,7 @@ export default function APIKeysPage() {
variant="ghost"
size="sm"
onClick={() => handleArchiveServiceKey(String(key.id))}
className="text-red-600 hover:text-red-700"
className="text-destructive hover:text-destructive/90"
>
<Trash2 className="w-4 h-4" />
</Button>
@ -550,8 +550,8 @@ export default function APIKeysPage() {
</CardContent>
</Card>
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm text-yellow-800">
<div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
<p className="text-sm text-yellow-600 dark:text-yellow-500">
<strong>Important:</strong> Keep your API keys secure. Never share them publicly or commit them to version control.
API keys provide full access to your organization&apos;s resources.
</p>
@ -601,10 +601,10 @@ export default function APIKeysPage() {
</DialogHeader>
{createdKey && (
<div className="space-y-4">
<div className="p-4 bg-gray-100 rounded-lg">
<p className="text-sm text-gray-600 mb-2">Your API Key:</p>
<div className="p-4 bg-muted rounded-lg">
<p className="text-sm text-muted-foreground mb-2">Your API Key:</p>
<div className="flex items-center gap-2">
<code className="flex-1 p-2 bg-white rounded text-sm font-mono break-all">
<code className="flex-1 p-2 bg-background rounded text-sm font-mono break-all">
{createdKey.api_key}
</code>
<Button
@ -616,8 +616,8 @@ export default function APIKeysPage() {
</Button>
</div>
</div>
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm text-yellow-800">
<div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
<p className="text-sm text-yellow-600 dark:text-yellow-500">
Store this key securely. It will only be shown once and cannot be retrieved later.
</p>
</div>
@ -676,10 +676,10 @@ export default function APIKeysPage() {
</DialogHeader>
{createdServiceKey && (
<div className="space-y-4">
<div className="p-4 bg-gray-100 rounded-lg">
<p className="text-sm text-gray-600 mb-2">Your Service Key:</p>
<div className="p-4 bg-muted rounded-lg">
<p className="text-sm text-muted-foreground mb-2">Your Service Key:</p>
<div className="flex items-center gap-2">
<code className="flex-1 p-2 bg-white rounded text-sm font-mono break-all">
<code className="flex-1 p-2 bg-background rounded text-sm font-mono break-all">
{createdServiceKey.service_key}
</code>
<Button
@ -691,8 +691,8 @@ export default function APIKeysPage() {
</Button>
</div>
</div>
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-800">
<div className="p-4 bg-blue-500/10 border border-blue-500/20 rounded-lg">
<p className="text-sm text-blue-600 dark:text-blue-500">
This key provides access to Dograh AI services including LLM, Text-to-Speech, and Speech-to-Text.
{createdServiceKey.expires_at && (
<span className="block mt-1">
@ -701,8 +701,8 @@ export default function APIKeysPage() {
)}
</p>
</div>
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm text-yellow-800">
<div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
<p className="text-sm text-yellow-600 dark:text-yellow-500">
Store this key securely. It will only be shown once and cannot be retrieved later.
</p>
</div>

View file

@ -1,14 +0,0 @@
import BaseHeader from "@/components/header/BaseHeader"
export default function CampaignsLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<>
<BaseHeader />
{children}
</>
)
}

View file

@ -1,33 +1,39 @@
"use client";
import { Zap } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
export default function AutomationPage() {
return (
<div className="container mx-auto p-6 space-y-6">
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">Automation</h1>
<p className="text-gray-600">Automate your workflows and processes</p>
<h1 className="text-3xl font-bold mb-2">Automation</h1>
<p>Automate your workflows and processes</p>
</div>
<Card>
<CardHeader>
<CardTitle>Coming Soon</CardTitle>
<CardDescription>
Automation features are currently under development
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-center py-12">
<p className="text-gray-500 text-lg mb-4">
We&apos;re working on powerful automation features to help you streamline your workflows.
</p>
<p className="text-gray-500">
Check back soon for updates!
</p>
</div>
</CardContent>
</Card>
<CardHeader>
<CardTitle>Coming Soon</CardTitle>
<CardDescription>
Automation features are currently under development
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-center py-12">
<Zap className="w-16 h-16 mx-auto mb-6" />
<p className="text-lg mb-4">
We&apos;re working on powerful automation features to help you streamline your workflows.
</p>
<p>
Automate repetitive tasks, trigger actions based on events, and create intelligent workflow pipelines.
</p>
<p className="mt-4">
Check back soon for updates!
</p>
</div>
</CardContent>
</Card>
</div>
);
}

View file

@ -126,12 +126,12 @@ export default function CsvUploadSelector({ accessToken, onFileUploaded, selecte
</Button>
{selectedFileName && !uploading && (
<div className="flex-1 text-sm">
<span className="text-gray-600">Selected: </span>
<span className="text-blue-600">{selectedFileName}</span>
<span className="text-muted-foreground">Selected: </span>
<span className="text-primary">{selectedFileName}</span>
</div>
)}
</div>
<p className="text-sm text-gray-500">
<p className="text-sm text-muted-foreground">
Upload a CSV file with contact data. Must include phone_number, first_name, and last_name columns. Max 10MB.
</p>
</div>

View file

@ -178,7 +178,7 @@ export default function GoogleSheetSelector({ accessToken, onSheetSelected, sele
return (
<div className="space-y-2">
<Label>Google Sheet</Label>
<div className="text-sm text-gray-500">Checking Google integration...</div>
<div className="text-sm text-muted-foreground">Checking Google integration...</div>
</div>
);
}
@ -217,7 +217,7 @@ export default function GoogleSheetSelector({ accessToken, onSheetSelected, sele
</Button>
{selectedSheetUrl && (
<div className="flex-1 text-sm">
<span className="text-gray-600">Selected: </span>
<span className="text-muted-foreground">Selected: </span>
<a
href={selectedSheetUrl}
target="_blank"
@ -229,7 +229,7 @@ export default function GoogleSheetSelector({ accessToken, onSheetSelected, sele
</div>
)}
</div>
<p className="text-sm text-gray-500">
<p className="text-sm text-muted-foreground">
Select a Google Sheet from your connected Google account
</p>
</div>

View file

@ -314,8 +314,8 @@ export default function CampaignDetailPage() {
return (
<div className="container mx-auto p-6 space-y-6">
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-1/4 mb-4"></div>
<div className="h-64 bg-gray-200 rounded"></div>
<div className="h-8 bg-muted rounded w-1/4 mb-4"></div>
<div className="h-64 bg-muted rounded"></div>
</div>
</div>
);
@ -324,7 +324,7 @@ export default function CampaignDetailPage() {
if (!campaign) {
return (
<div className="container mx-auto p-6 space-y-6">
<p className="text-center text-gray-500">Campaign not found</p>
<p className="text-center text-muted-foreground">Campaign not found</p>
</div>
);
}
@ -342,12 +342,12 @@ export default function CampaignDetailPage() {
</Button>
<div className="flex justify-between items-start">
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">{campaign.name}</h1>
<h1 className="text-3xl font-bold mb-2">{campaign.name}</h1>
<div className="flex items-center gap-4">
<Badge variant={getStateBadgeVariant(campaign.state)}>
{campaign.state}
</Badge>
<span className="text-gray-600">
<span className="text-muted-foreground">
Created {formatDate(campaign.created_at)}
</span>
</div>
@ -367,7 +367,7 @@ export default function CampaignDetailPage() {
<CardContent>
<dl className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<dt className="text-sm font-medium text-gray-500">Workflow</dt>
<dt className="text-sm font-medium">Workflow</dt>
<dd className="mt-1">
<button
onClick={handleWorkflowClick}
@ -378,11 +378,11 @@ export default function CampaignDetailPage() {
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Source Type</dt>
<dt className="text-sm font-medium">Source Type</dt>
<dd className="mt-1 capitalize">{campaign.source_type.replace('-', ' ')}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">
<dt className="text-sm font-medium">
{campaign.source_type === 'csv' ? 'Source File' : 'Source Sheet'}
</dt>
<dd className="mt-1">
@ -406,18 +406,18 @@ export default function CampaignDetailPage() {
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">State</dt>
<dt className="text-sm font-medium">State</dt>
<dd className="mt-1 capitalize">{campaign.state}</dd>
</div>
{campaign.started_at && (
<div>
<dt className="text-sm font-medium text-gray-500">Started At</dt>
<dt className="text-sm font-medium">Started At</dt>
<dd className="mt-1">{formatDateTime(campaign.started_at)}</dd>
</div>
)}
{campaign.completed_at && (
<div>
<dt className="text-sm font-medium text-gray-500">Completed At</dt>
<dt className="text-sm font-medium">Completed At</dt>
<dd className="mt-1">{formatDateTime(campaign.completed_at)}</dd>
</div>
)}
@ -437,7 +437,7 @@ export default function CampaignDetailPage() {
{isLoadingRuns ? (
<div className="animate-pulse space-y-3">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-12 bg-gray-200 rounded"></div>
<div key={i} className="h-12 bg-muted rounded"></div>
))}
</div>
) : runs.length > 0 ? (
@ -455,7 +455,7 @@ export default function CampaignDetailPage() {
{runs.map((run) => (
<TableRow
key={run.id}
className="cursor-pointer hover:bg-gray-50"
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleRunClick(run.id)}
>
<TableCell className="font-mono text-sm">#{run.id}</TableCell>
@ -483,7 +483,7 @@ export default function CampaignDetailPage() {
</Table>
</div>
) : (
<p className="text-center py-8 text-gray-500">
<p className="text-center py-8 text-muted-foreground">
{campaign.state === 'created'
? 'No runs yet. Start the campaign to begin execution.'
: 'No workflow runs found for this campaign.'}

View file

@ -1,14 +0,0 @@
import BaseHeader from "@/components/header/BaseHeader"
export default function CampaignsLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<>
<BaseHeader />
{children}
</>
)
}

View file

@ -141,8 +141,8 @@ export default function NewCampaignPage() {
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Campaigns
</Button>
<h1 className="text-3xl font-bold text-gray-900 mb-2">Create New Campaign</h1>
<p className="text-gray-600">Set up a new campaign to execute workflows at scale</p>
<h1 className="text-3xl font-bold mb-2">Create New Campaign</h1>
<p className="text-muted-foreground">Set up a new campaign to execute workflows at scale</p>
</div>
<Card>
@ -164,7 +164,7 @@ export default function NewCampaignPage() {
maxLength={255}
required
/>
<p className="text-sm text-gray-500">
<p className="text-sm text-muted-foreground">
Choose a descriptive name for your campaign
</p>
</div>
@ -200,7 +200,7 @@ export default function NewCampaignPage() {
)}
</SelectContent>
</Select>
<p className="text-sm text-gray-500">
<p className="text-sm text-muted-foreground">
Select the workflow to execute for each row in the data source
</p>
</div>
@ -220,11 +220,11 @@ export default function NewCampaignPage() {
<SelectValue placeholder="Select source type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="google-sheet">Google Sheet</SelectItem>
{/* <SelectItem value="google-sheet">Google Sheet</SelectItem> */}
<SelectItem value="csv">CSV File</SelectItem>
</SelectContent>
</Select>
<p className="text-sm text-gray-500">
<p className="text-sm text-muted-foreground">
Choose where your contact data is stored
</p>
</div>

View file

@ -2,7 +2,7 @@
import { Plus } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { getCampaignsApiV1CampaignGet } from '@/client/sdk.gen';
import type { CampaignsResponse } from '@/client/types.gen';
@ -23,9 +23,9 @@ export default function CampaignsPage() {
const { user, getAccessToken, redirectToLogin, loading } = useAuth();
const router = useRouter();
// Campaigns state
const [campaignsData, setCampaignsData] = useState<CampaignsResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);
const hasFetched = useRef(false);
// Redirect if not authenticated
useEffect(() => {
@ -34,51 +34,48 @@ export default function CampaignsPage() {
}
}, [loading, user, redirectToLogin]);
// Fetch campaigns
const fetchCampaigns = useCallback(async () => {
if (!user) return;
setIsLoading(true);
try {
const accessToken = await getAccessToken();
const response = await getCampaignsApiV1CampaignGet({
headers: {
'Authorization': `Bearer ${accessToken}`,
}
});
if (response.data) {
setCampaignsData(response.data);
}
} catch (error) {
console.error('Failed to fetch campaigns:', error);
} finally {
setIsLoading(false);
}
}, [user, getAccessToken]);
// Initial load
// Fetch campaigns once when user is ready
useEffect(() => {
if (user) {
fetchCampaigns();
if (loading || !user || hasFetched.current) {
return;
}
}, [fetchCampaigns, user]);
hasFetched.current = true;
const fetchCampaigns = async () => {
setIsLoading(true);
try {
const accessToken = await getAccessToken();
const response = await getCampaignsApiV1CampaignGet({
headers: {
'Authorization': `Bearer ${accessToken}`,
}
});
if (response.data) {
setCampaignsData(response.data);
}
} catch (error) {
console.error('Failed to fetch campaigns:', error);
} finally {
setIsLoading(false);
}
};
fetchCampaigns();
}, [loading, user, getAccessToken]);
// Handle row click to navigate to campaign detail
const handleRowClick = (campaignId: number) => {
router.push(`/campaigns/${campaignId}`);
};
// Handle create campaign button
const handleCreateCampaign = () => {
router.push('/campaigns/new');
};
// Format date for display
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString();
};
// Get badge variant for state
const getStateBadgeVariant = (state: string) => {
switch (state) {
case 'created':
@ -100,8 +97,8 @@ export default function CampaignsPage() {
<div className="container mx-auto p-6 space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">Campaigns</h1>
<p className="text-gray-600">Manage your bulk workflow execution campaigns</p>
<h1 className="text-3xl font-bold mb-2">Campaigns</h1>
<p>Manage your bulk workflow execution campaigns</p>
</div>
<Button onClick={handleCreateCampaign}>
<Plus className="h-4 w-4 mr-2" />
@ -109,7 +106,6 @@ export default function CampaignsPage() {
</Button>
</div>
{/* Campaigns Table */}
<Card>
<CardHeader>
<CardTitle>All Campaigns</CardTitle>
@ -121,7 +117,7 @@ export default function CampaignsPage() {
{isLoading ? (
<div className="animate-pulse space-y-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-12 bg-gray-200 rounded"></div>
<div key={i} className="h-12 bg-muted rounded"></div>
))}
</div>
) : campaignsData && campaignsData.campaigns.length > 0 ? (
@ -140,7 +136,7 @@ export default function CampaignsPage() {
{campaignsData.campaigns.map((campaign) => (
<TableRow
key={campaign.id}
className="cursor-pointer hover:bg-gray-50"
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleRowClick(campaign.id)}
>
<TableCell className="font-medium">{campaign.name}</TableCell>
@ -170,7 +166,7 @@ export default function CampaignsPage() {
</div>
) : (
<div className="text-center py-8">
<p className="text-gray-500 mb-4">No campaigns found</p>
<p className="mb-4">No campaigns found</p>
<Button onClick={handleCreateCampaign} variant="outline">
<Plus className="h-4 w-4 mr-2" />
Create your first campaign

View file

@ -1,14 +0,0 @@
import BaseHeader from "@/components/header/BaseHeader"
export default function ConfigureTelephonyLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<>
<BaseHeader />
{children}
</>
)
}

View file

@ -1,279 +0,0 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { createWorkflowFromTemplateApiV1WorkflowCreateTemplatePost, createWorkflowRunApiV1WorkflowWorkflowIdRunsPost } from '@/client/sdk.gen';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
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();
const { user, getAccessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showSuccessModal, setShowSuccessModal] = useState(false);
const [workflowId, setWorkflowId] = useState<string | null>(null);
const [callType, setCallType] = useState<'INBOUND' | 'OUTBOUND'>('INBOUND');
const [useCase, setUseCase] = useState('');
const [activityDescription, setActivityDescription] = useState('');
const handleCreateWorkflow = async () => {
if (!useCase || !activityDescription) {
setError('Please fill in all fields');
return;
}
if (!user) {
setError('You must be logged in to create a workflow');
return;
}
setIsLoading(true);
setError(null);
try {
const accessToken = await getAccessToken();
// Call the API to create workflow from template
const response = await createWorkflowFromTemplateApiV1WorkflowCreateTemplatePost({
body: {
call_type: callType,
use_case: useCase,
activity_description: activityDescription,
},
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
if (response.data?.id) {
setWorkflowId(String(response.data.id));
setShowSuccessModal(true);
}
} catch (err) {
setError('Failed to create workflow. Please try again.');
logger.error(`Error creating workflow: ${err}`);
} finally {
setIsLoading(false);
}
};
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}`);
}
};
return (
<div className="min-h-[100vh] flex items-center justify-center p-4 bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800">
<Card className="w-full max-w-4xl shadow-xl border-0 bg-white/95 dark:bg-gray-900/95 backdrop-blur">
<CardHeader className="text-center pb-4 pt-6">
<h1 className="text-2xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
Create Your Voice Agent Workflow
</h1>
<CardDescription className="text-base mt-2 text-gray-600 dark:text-gray-400">
Tell us about your use case and we&apos;ll create a customized workflow for you
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 px-6 pb-6">
<div className="space-y-4">
<div className="flex flex-col space-y-4">
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<div className="flex items-center flex-wrap gap-2">
<span className="text-base font-medium text-gray-700 dark:text-gray-300">I want to create an</span>
<Select value={callType} onValueChange={(value) => setCallType(value as 'INBOUND' | 'OUTBOUND')}>
<SelectTrigger className="w-[180px] h-10 text-sm font-semibold border-2 focus:ring-2 focus:ring-blue-500">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="INBOUND" className="text-sm">
<span className="font-medium">📞 INBOUND</span>
<span className="text-xs text-gray-500 ml-1">(Users call AI)</span>
</SelectItem>
<SelectItem value="OUTBOUND" className="text-sm">
<span className="font-medium"> OUTBOUND</span>
<span className="text-xs text-gray-500 ml-1">(AI calls users)</span>
</SelectItem>
</SelectContent>
</Select>
<span className="text-base font-medium text-gray-700 dark:text-gray-300">voice agent</span>
</div>
</div>
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<div className="flex items-start flex-col gap-2">
<span className="text-base font-medium text-gray-700 dark:text-gray-300">For the use case of</span>
<Input
className="w-full h-10 text-sm px-3 border-2 focus:ring-2 focus:ring-blue-500 transition-all"
placeholder="e.g., Lead Qualification, HR Screening, Customer Support"
value={useCase}
onChange={(e) => setUseCase(e.target.value)}
/>
</div>
</div>
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<div className="flex items-start flex-col gap-2">
<span className="text-base font-medium text-gray-700 dark:text-gray-300">Which can</span>
<textarea
className="w-full min-h-[80px] text-sm px-3 py-2 border-2 rounded-md focus:ring-2 focus:ring-blue-500 transition-all resize-none"
placeholder="Describe briefly what your voice agent will do (e.g., Qualify leads for real estate, Screen candidates for roles, Handle customer support). This will be a prompt to an LLM."
value={activityDescription}
onChange={(e) => setActivityDescription(e.target.value)}
/>
</div>
</div>
</div>
</div>
{error && (
<div className="text-sm text-red-600 bg-red-50 dark:bg-red-900/20 p-3 rounded-lg border border-red-200 dark:border-red-800 flex items-center gap-2">
<svg className="w-5 h-5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
{error}
</div>
)}
<div className="pt-2">
<Button
onClick={handleCreateWorkflow}
disabled={isLoading || !useCase || !activityDescription}
className="w-full h-12 text-base font-semibold bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 transition-all transform hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none"
size="lg"
>
{isLoading ? (
<span className="flex items-center gap-3">
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Creating Your Workflow...
</span>
) : (
<span className="flex items-center gap-2">
Create Workflow
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</span>
)}
</Button>
</div>
</CardContent>
</Card>
{/* Loading Overlay */}
{isLoading && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<Card className="w-full max-w-md p-8 bg-white dark:bg-gray-900 border-0 shadow-2xl">
<div className="flex flex-col items-center space-y-6">
{/* Animated spinner */}
<div className="relative">
<div className="w-20 h-20 border-4 border-gray-200 dark:border-gray-700 rounded-full"></div>
<div className="absolute top-0 left-0 w-20 h-20 border-4 border-transparent border-t-blue-600 rounded-full animate-spin"></div>
<div className="absolute top-2 left-2 w-16 h-16 border-4 border-transparent border-t-purple-600 rounded-full animate-spin-slow"></div>
</div>
<div className="text-center space-y-2">
<h3 className="text-xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
Creating Your Workflow
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 max-w-xs">
We&apos;re setting up your voice agent with your specifications. This will just take a moment...
</p>
</div>
{/* Animated dots */}
<div className="flex space-x-2">
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce" style={{ animationDelay: '0ms' }}></div>
<div className="w-2 h-2 bg-purple-600 rounded-full animate-bounce" style={{ animationDelay: '150ms' }}></div>
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce" style={{ animationDelay: '300ms' }}></div>
</div>
</div>
</Card>
</div>
)}
{/* Success Modal */}
<Dialog open={showSuccessModal} onOpenChange={setShowSuccessModal}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-xl font-bold flex items-center gap-2">
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Workflow Created Successfully!
</DialogTitle>
<DialogDescription asChild>
<div className="text-base mt-4 space-y-3 text-gray-700 dark:text-gray-300">
<p>
A voice agent workflow has been generated for your use case, with some artificial data and sample actions.
</p>
<p>
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.
</p>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-6">
<Button
onClick={handleModalContinue}
className="w-full bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 font-semibold"
>
Start Web Call
<svg className="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -1,14 +0,0 @@
import BaseHeader from "@/components/header/BaseHeader"
export default function StackLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<>
<BaseHeader />
{children}
</>
)
}

View file

@ -12,7 +12,7 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const refreshToken = searchParams.get("refresh_token");
const redirectPath = searchParams.get("redirect_path") ?? "/create-workflow";
const redirectPath = searchParams.get("redirect_path") ?? "/workflow/create";
if (!refreshToken) {
return new Response("Missing refresh_token", { status: 400 });

View file

@ -291,7 +291,7 @@ export default function GmailSearchPage() {
<button
onClick={searchEmails}
disabled={!accessToken || loading || !searchQuery.trim()}
className="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
className="px-6 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground disabled:cursor-not-allowed"
>
{loading ? 'Searching...' : 'Search'}
</button>
@ -306,13 +306,13 @@ export default function GmailSearchPage() {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Email List */}
<div className="bg-white border border-gray-200 rounded-lg">
<div className="p-4 border-b border-gray-200">
<div className="bg-card border border-border rounded-lg">
<div className="p-4 border-b border-border">
<h2 className="text-lg font-semibold">Search Results</h2>
</div>
<div className="divide-y divide-gray-200 max-h-[600px] overflow-y-auto">
<div className="divide-y divide-border max-h-[600px] overflow-y-auto">
{emails.length === 0 && !loading && (
<div className="p-8 text-center text-gray-500">
<div className="p-8 text-center text-muted-foreground">
{searchQuery ? 'No emails found' : 'Enter a search query to find emails'}
</div>
)}
@ -320,27 +320,27 @@ export default function GmailSearchPage() {
<div
key={email.id}
onClick={() => loadEmailDetail(email.id)}
className={`p-4 cursor-pointer hover:bg-gray-50 ${
selectedEmail?.id === email.id ? 'bg-blue-50' : ''
className={`p-4 cursor-pointer hover:bg-muted/50 ${
selectedEmail?.id === email.id ? 'bg-accent' : ''
}`}
>
<div className="font-medium text-sm mb-1">{email.subject}</div>
<div className="text-xs text-gray-600 mb-1">{email.from}</div>
<div className="text-xs text-gray-500">{email.snippet}</div>
<div className="text-xs text-gray-400 mt-1">{email.date}</div>
<div className="text-xs text-muted-foreground mb-1">{email.from}</div>
<div className="text-xs text-muted-foreground">{email.snippet}</div>
<div className="text-xs text-muted-foreground/70 mt-1">{email.date}</div>
</div>
))}
</div>
</div>
{/* Email Detail and Reply */}
<div className="bg-white border border-gray-200 rounded-lg">
<div className="p-4 border-b border-gray-200">
<div className="bg-card border border-border rounded-lg">
<div className="p-4 border-b border-border">
<h2 className="text-lg font-semibold">Email Details</h2>
</div>
{selectedEmail ? (
<div className="p-4">
<div className="mb-4 pb-4 border-b border-gray-200">
<div className="mb-4 pb-4 border-b border-border">
<div className="mb-2">
<span className="font-medium">Subject:</span> {selectedEmail.subject}
</div>
@ -350,36 +350,36 @@ export default function GmailSearchPage() {
<div className="mb-2 text-sm">
<span className="font-medium">To:</span> {selectedEmail.to}
</div>
<div className="text-sm text-gray-500">
<div className="text-sm text-muted-foreground">
<span className="font-medium">Date:</span> {selectedEmail.date}
</div>
</div>
<div className="mb-4 p-4 bg-gray-50 rounded max-h-[200px] overflow-y-auto">
<div className="mb-4 p-4 bg-muted rounded max-h-[200px] overflow-y-auto">
<pre className="whitespace-pre-wrap text-sm">{selectedEmail.body}</pre>
</div>
<div className="border-t border-gray-200 pt-4">
<div className="border-t border-border pt-4">
<h3 className="font-medium mb-2">Reply</h3>
<textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder="Type your reply here..."
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 mb-2"
className="w-full px-3 py-2 border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-ring mb-2 bg-background"
rows={6}
disabled={sendingReply}
/>
<button
onClick={sendReply}
disabled={sendingReply || !replyText.trim()}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground disabled:cursor-not-allowed"
>
{sendingReply ? 'Sending...' : 'Send Reply'}
</button>
</div>
</div>
) : (
<div className="p-8 text-center text-gray-500">
<div className="p-8 text-center text-muted-foreground">
Select an email to view details and reply
</div>
)}

View file

@ -1,14 +0,0 @@
import BaseHeader from "@/components/header/BaseHeader"
export default function IntegrationsLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<>
<BaseHeader />
{children}
</>
)
}

View file

@ -42,7 +42,7 @@ async function IntegrationList() {
if (integrations.length === 0) {
return (
<div className="text-center py-8 text-gray-500">
<div className="text-center py-8 text-muted-foreground">
No integrations found. Create your first integration to get started.
</div>
);
@ -51,39 +51,39 @@ async function IntegrationList() {
return (
<div className="space-y-6">
<div className="overflow-x-auto">
<table className="min-w-full bg-white border border-gray-200">
<thead className="bg-gray-50">
<table className="min-w-full bg-card border border-border">
<thead className="bg-muted">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Provider
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Channel
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Action
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Created At
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
<tbody className="bg-card divide-y divide-border">
{integrations.map((integration) => (
<tr key={integration.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
<tr key={integration.id} className="hover:bg-muted/50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
{integration.provider}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
{integration.provider === 'slack' && integration.provider_data ? (integration.provider_data.channel as string) || '-' : '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
{integration.action}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
{new Date(integration.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
@ -92,7 +92,7 @@ async function IntegrationList() {
minute: '2-digit'
})}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
{integration.provider === 'google-mail' && (
<Link
href={`/integrations/${integration.id}/gmail`}
@ -140,41 +140,41 @@ function IntegrationsLoading() {
<div className="container mx-auto px-4 py-8">
<div className="mb-6">
<div className="flex justify-between items-center mb-6">
<div className="h-8 w-48 bg-gray-200 rounded"></div>
<div className="h-10 w-32 bg-gray-200 rounded"></div>
<div className="h-8 w-48 bg-muted rounded"></div>
<div className="h-10 w-32 bg-muted rounded"></div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full bg-white border border-gray-200">
<thead className="bg-gray-50">
<table className="min-w-full bg-card border border-border">
<thead className="bg-muted">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Integration ID
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Channel
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Action
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Created At
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
<tbody className="bg-card divide-y divide-border">
{Array.from({ length: 5 }, (_, i) => (
<tr key={i}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="h-4 w-32 bg-gray-200 rounded"></div>
<div className="h-4 w-32 bg-muted rounded"></div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="h-4 w-24 bg-gray-200 rounded"></div>
<div className="h-4 w-24 bg-muted rounded"></div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="h-4 w-24 bg-gray-200 rounded"></div>
<div className="h-4 w-24 bg-muted rounded"></div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="h-4 w-24 bg-gray-200 rounded"></div>
<div className="h-4 w-24 bg-muted rounded"></div>
</td>
</tr>
))}

View file

@ -5,6 +5,7 @@ import { Geist, Geist_Mono } from "next/font/google";
import { Suspense } from "react";
import ChatwootWidget from "@/components/ChatwootWidget";
import AppLayout from "@/components/layout/AppLayout";
import PostHogIdentify from "@/components/PostHogIdentify";
import SpinLoader from "@/components/SpinLoader";
import { Toaster } from "@/components/ui/sonner";
@ -35,7 +36,26 @@ export default function RootLayout({
}) {
return (
<html lang="en">
<html lang="en" suppressHydrationWarning>
<head>
{/* Inline script to prevent flash of light theme - runs before React hydrates */}
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
try {
var theme = localStorage.getItem('theme');
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
} catch (e) {}
})();
`,
}}
/>
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
<AuthProvider>
@ -43,7 +63,9 @@ export default function RootLayout({
<UserConfigProvider>
<OnboardingProvider>
<PostHogIdentify />
{children}
<AppLayout>
{children}
</AppLayout>
<Toaster />
<ChatwootWidget />
</OnboardingProvider>

View file

@ -1,6 +1,6 @@
import React, { ReactNode } from 'react'
import BaseHeader from '@/components/header/BaseHeader'
import AppLayout from '@/components/layout/AppLayout'
interface LoopTalkLayoutProps {
children: ReactNode,
@ -8,12 +8,13 @@ interface LoopTalkLayoutProps {
backButton?: ReactNode,
}
const LoopTalkLayout: React.FC<LoopTalkLayoutProps> = ({ children, headerActions, backButton }) => {
const LoopTalkLayout: React.FC<LoopTalkLayoutProps> = ({ children, headerActions }) => {
// backButton is kept in interface for backward compatibility
// but not used with the new sidebar layout
return (
<>
<BaseHeader headerActions={headerActions} backButton={backButton} />
<AppLayout headerActions={headerActions}>
{children}
</>
</AppLayout>
)
}

View file

@ -98,9 +98,9 @@ function TestSessionLoading() {
return (
<div className="container mx-auto px-4 py-8">
<div className="space-y-4">
<div className="h-32 bg-gray-200 rounded-lg animate-pulse"></div>
<div className="h-20 bg-gray-200 rounded-lg animate-pulse"></div>
<div className="h-64 bg-gray-200 rounded-lg animate-pulse"></div>
<div className="h-32 bg-muted rounded-lg animate-pulse"></div>
<div className="h-20 bg-muted rounded-lg animate-pulse"></div>
<div className="h-64 bg-muted rounded-lg animate-pulse"></div>
</div>
</div>
);

View file

@ -1,79 +1,40 @@
import { Suspense } from 'react';
"use client";
import { CreateTestSessionButton } from '@/components/looptalk/CreateTestSessionButton';
import { LoopTalkTestSessionsList } from '@/components/looptalk/LoopTalkTestSessionsList';
import { getServerAuthProvider, isServerAuthenticated } from '@/lib/auth/server';
import { MessageSquare } from 'lucide-react';
import LoopTalkLayout from "./LoopTalkLayout";
export const dynamic = 'force-dynamic';
async function PageContent() {
const authProvider = getServerAuthProvider();
const isAuthenticated = await isServerAuthenticated();
if (authProvider === 'stack' && !isAuthenticated) {
const { redirect } = await import('next/navigation');
redirect('/');
}
return (
<div className="container mx-auto px-4 py-8">
{/* Active Tests Section */}
<div className="mb-12">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold">Active Tests</h2>
</div>
<LoopTalkTestSessionsList status="active" />
</div>
{/* Test Sessions Section */}
<div className="mb-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Test Sessions</h1>
<CreateTestSessionButton />
</div>
<LoopTalkTestSessionsList />
</div>
</div>
);
}
function LoopTalkLoading() {
return (
<div className="container mx-auto px-4 py-8">
{/* Active Tests Section Loading */}
<div className="mb-12">
<div className="h-8 w-48 bg-gray-200 rounded mb-6"></div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 3 }, (_, i) => (
<div key={i} className="bg-gray-200 rounded-lg h-40"></div>
))}
</div>
</div>
{/* Test Sessions Section Loading */}
<div className="mb-6">
<div className="flex justify-between items-center mb-6">
<div className="h-8 w-48 bg-gray-200 rounded"></div>
<div className="h-10 w-32 bg-gray-200 rounded"></div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 6 }, (_, i) => (
<div key={i} className="bg-gray-200 rounded-lg h-32"></div>
))}
</div>
</div>
</div>
);
}
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
export default function LoopTalkPage() {
return (
<LoopTalkLayout>
<Suspense fallback={<LoopTalkLoading />}>
<PageContent />
</Suspense>
</LoopTalkLayout>
<div className="container mx-auto p-6 space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">LoopTalk</h1>
<p>Enable voice agents to talk to each other and create artificial datasets</p>
</div>
<Card>
<CardHeader>
<CardTitle>Coming Soon</CardTitle>
<CardDescription>
LoopTalk features are currently under development
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-center py-12">
<MessageSquare className="w-16 h-16 mx-auto mb-6" />
<p className="text-lg mb-4">
We&apos;re building LoopTalk to enable voice agents to communicate with each other,
allowing you to generate artificial datasets for training and testing.
</p>
<p>
This powerful feature will help you create comprehensive test scenarios and improve your voice AI workflows.
</p>
<p className="mt-4">
Check back soon for updates!
</p>
</div>
</CardContent>
</Card>
</div>
);
}

View file

@ -2,9 +2,9 @@ import ServiceConfiguration from "@/components/ServiceConfiguration";
export default function ServiceConfigurationPage() {
return (
<div className="min-h-screen bg-gray-50">
<div className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto">
<div className="min-h-screen bg-background">
<div className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto">
<ServiceConfiguration />
</div>
</div>

View file

@ -0,0 +1,123 @@
"use client";
import { Star } from 'lucide-react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { useAuth } from '@/lib/auth';
export default function OverviewPage() {
const { user, provider } = useAuth();
const isOSSMode = provider !== 'stack';
return (
<div className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto">
{/* Welcome Card */}
<Card className="mb-8">
<CardHeader>
<CardTitle className="text-3xl">
{isOSSMode ? (
"Welcome to Dograh"
) : (
`Welcome${user?.displayName ? `, ${user.displayName.split(' ')[0]}` : ''}!`
)}
</CardTitle>
<CardDescription className="text-lg mt-2">
{isOSSMode ? (
<>
Open source alternative to Vapi. Help us support the project by giving us a star on GitHub.
</>
) : (
"Get started with building voice AI workflows"
)}
</CardDescription>
</CardHeader>
<CardContent>
{isOSSMode && (
<Button asChild className="mb-6">
<a
href="https://github.com/dograh-hq/dograh"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center"
>
<Star className="h-4 w-4 fill-yellow-400 text-yellow-400" />
Star us on GitHub
</a>
</Button>
)}
</CardContent>
</Card>
{/* Quick Actions */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle>Create and Manage your Voice Agents</CardTitle>
<CardDescription>
Build powerful AI Voice Agents with our visual editor
</CardDescription>
</CardHeader>
<CardContent>
<Button asChild>
<Link href="/workflow">
Go to Agents
</Link>
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Configure Services</CardTitle>
<CardDescription>
Set up your AI services like LLM, TTS, and STT providers
</CardDescription>
</CardHeader>
<CardContent>
<Button asChild variant="outline">
<Link href="/model-configurations">
Configure Models
</Link>
</Button>
</CardContent>
</Card>
</div>
{/* Resources Section */}
<Card className="mt-8">
<CardHeader>
<CardTitle>Resources</CardTitle>
<CardDescription>
Get help and learn more about Dograh
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-4">
<Button asChild variant="outline">
<a
href="https://docs.dograh.com"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</Button>
<Button asChild variant="outline">
<a
href="https://github.com/dograh-hq/dograh/issues"
target="_blank"
rel="noopener noreferrer"
>
Report an Issue
</a>
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View file

@ -39,8 +39,8 @@ export default async function Home() {
logger.debug('[HomePage] Redirecting to /workflow - user has workflows');
redirect('/workflow');
} else {
logger.debug('[HomePage] Redirecting to /create-workflow - no workflows found');
redirect('/create-workflow');
logger.debug('[HomePage] Redirecting to /workflow/create - no workflows found');
redirect('/workflow/create');
}
}
} catch (error) {
@ -50,9 +50,9 @@ export default async function Home() {
}
logger.error('[HomePage] Error checking workflows for local provider:', error);
// Default to create-workflow on actual errors
logger.debug('[HomePage] Defaulting to /create-workflow due to error');
redirect('/create-workflow');
// Default to /workflow/create on actual errors
logger.debug('[HomePage] Defaulting to /workflow/create due to error');
redirect('/workflow/create');
}
}

View file

@ -1,14 +0,0 @@
import BaseHeader from "@/components/header/BaseHeader"
export default function ReportsLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<>
<BaseHeader />
{children}
</>
)
}

View file

@ -1,14 +0,0 @@
import BaseHeader from "@/components/header/BaseHeader"
export default function ServiceConfigurationLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<>
<BaseHeader />
{children}
</>
)
}

View file

@ -1,14 +0,0 @@
import BaseHeader from "@/components/header/BaseHeader"
export default function SuperAdminLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<>
<BaseHeader />
{children}
</>
)
}

View file

@ -51,8 +51,8 @@ export default function SuperadminPage() {
<>
<main className="container mx-auto p-6 space-y-6 max-w-4xl">
<div className="text-center">
<h1 className="text-3xl font-bold text-gray-900 mb-2">Superadmin Dashboard</h1>
<p className="text-sm text-gray-600">Manage users and view system-wide data</p>
<h1 className="text-3xl font-bold mb-2">Superadmin Dashboard</h1>
<p className="text-sm text-muted-foreground">Manage users and view system-wide data</p>
</div>
<div className="grid gap-6 md:grid-cols-2">
@ -111,7 +111,7 @@ export default function SuperadminPage() {
</CardHeader>
<CardContent>
<div className="space-y-4">
<p className="text-sm text-gray-600">
<p className="text-sm text-muted-foreground">
Access detailed information about all workflow runs, including status,
recordings, transcripts, and usage data.
</p>

View file

@ -328,8 +328,8 @@ export default function RunsPage() {
return (
<div className="container mx-auto p-6 space-y-6 max-w-full">
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">Workflow Runs</h1>
<p className="text-gray-600">View and manage all workflow runs across organizations</p>
<h1 className="text-3xl font-bold mb-2">Workflow Runs</h1>
<p className="text-muted-foreground">View and manage all workflow runs across organizations</p>
</div>
{error && (
@ -368,15 +368,15 @@ export default function RunsPage() {
</CardHeader>
<CardContent>
{runs.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<div className="text-center py-8 text-muted-foreground">
No workflow runs found.
</div>
) : (
<>
<div className="bg-white border rounded-lg overflow-hidden shadow-sm">
<div className="bg-card border rounded-lg overflow-hidden shadow-sm">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableRow className="bg-muted">
<TableHead className="font-semibold">ID</TableHead>
<TableHead className="font-semibold">Workflow</TableHead>
<TableHead className="font-semibold">Status</TableHead>
@ -406,7 +406,7 @@ export default function RunsPage() {
: run.workflow_name
) : 'Unknown Workflow'}
</span>
<span className="text-xs text-gray-500 font-mono">
<span className="text-xs text-muted-foreground font-mono">
ID: {String(run.workflow_id).length > 12
? `${String(run.workflow_id).substring(0, 12)}...`
: run.workflow_id}
@ -446,7 +446,7 @@ export default function RunsPage() {
{run.admin_comment ? (
<span>{run.admin_comment}</span>
) : (
<span className="text-gray-400 italic">No comment</span>
<span className="text-muted-foreground/70 italic">No comment</span>
)}
</TableCell>
<TableCell className="text-sm whitespace-pre-wrap break-words">
@ -464,7 +464,7 @@ export default function RunsPage() {
{(run.usage_info || run.cost_info) && (
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-4 w-4 text-gray-500 cursor-pointer" />
<Info className="h-4 w-4 text-muted-foreground cursor-pointer" />
</TooltipTrigger>
<TooltipContent sideOffset={4} className="max-w-xs whitespace-pre-wrap break-words">
<pre className="max-w-xs whitespace-pre-wrap break-words">
@ -584,7 +584,7 @@ export default function RunsPage() {
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-6">
<div className="text-sm text-gray-500">
<div className="text-sm text-muted-foreground">
Page {currentPage} of {totalPages} ({totalCount} total runs)
</div>
<div className="flex space-x-2">

View file

@ -1,6 +1,5 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@ -45,14 +44,14 @@ interface TelephonyConfigForm {
}
export default function ConfigureTelephonyPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { user, getAccessToken, loading: authLoading } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [hasExistingConfig, setHasExistingConfig] = useState(false);
// Get returnTo parameter from URL
const returnTo = searchParams.get("returnTo") || "/workflow";
// Clean up any stale pointer-events from dialogs that weren't properly closed before navigation
useEffect(() => {
document.body.style.pointerEvents = '';
}, []);
const {
register,
@ -167,9 +166,6 @@ export default function ConfigureTelephonyPage() {
}
toast.success("Telephony configuration saved successfully");
// Redirect back to the page that sent us here
router.push(returnTo);
} catch (error) {
toast.error(
error instanceof Error
@ -182,14 +178,15 @@ export default function ConfigureTelephonyPage() {
};
return (
<div className="min-h-screen bg-gray-50">
<div className="container mx-auto px-4 py-8">
<div className="container mx-auto p-6 space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">Configure Telephony</h1>
<p className="text-gray-600 mb-6">
<p className="text-muted-foreground">
Set up your telephony provider to make phone calls
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div>
<Card className="h-full">
<CardHeader>
@ -245,7 +242,7 @@ export default function ConfigureTelephonyPage() {
</SelectContent>
</Select>
{hasExistingConfig && (
<p className="text-sm text-amber-600">
<p className="text-sm text-yellow-600 dark:text-yellow-500">
Switching providers will require entering new credentials
</p>
)}
@ -481,7 +478,6 @@ export default function ConfigureTelephonyPage() {
</form>
</div>
</div>
</div>
</div>
);

View file

@ -1,14 +0,0 @@
import BaseHeader from "@/components/header/BaseHeader"
export default function UsageLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<>
<BaseHeader />
{children}
</>
)
}

View file

@ -313,11 +313,11 @@ export default function UsagePage() {
<div>
<div className="flex justify-between items-start">
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">Usage Dashboard</h1>
<p className="text-gray-600">Monitor your Dograh Token usage and quota</p>
<h1 className="text-3xl font-bold mb-2">Usage Dashboard</h1>
<p className="text-muted-foreground">Monitor your Dograh Token usage and quota</p>
</div>
<div className="flex items-center gap-2">
<Globe className="h-4 w-4 text-gray-500" />
<Globe className="h-4 w-4 text-muted-foreground" />
<div className="w-[300px]">
<TimezoneSelect
instanceId={timezoneSelectId}
@ -353,9 +353,9 @@ export default function UsagePage() {
<CardContent>
{isLoadingCurrent ? (
<div className="animate-pulse space-y-4">
<div className="h-4 bg-gray-200 rounded w-1/4"></div>
<div className="h-8 bg-gray-200 rounded"></div>
<div className="h-4 bg-gray-200 rounded w-1/3"></div>
<div className="h-4 bg-muted rounded w-1/4"></div>
<div className="h-8 bg-muted rounded"></div>
<div className="h-4 bg-muted rounded w-1/3"></div>
</div>
) : currentUsage ? (
<div className="space-y-4">
@ -366,8 +366,8 @@ export default function UsagePage() {
<p className="text-2xl font-bold">
${(currentUsage.used_amount_usd || 0).toFixed(2)}
</p>
<p className="text-sm text-gray-600">Total Cost (USD)</p>
<p className="text-xs text-gray-500 mt-1">
<p className="text-sm text-muted-foreground">Total Cost (USD)</p>
<p className="text-xs text-muted-foreground mt-1">
Rate: ${(organizationPricing.price_per_second_usd * 60).toFixed(4)}/minute
</p>
</>
@ -376,14 +376,14 @@ export default function UsagePage() {
<p className="text-2xl font-bold">
{currentUsage.used_dograh_tokens.toLocaleString()} / {currentUsage.quota_dograh_tokens.toLocaleString()}
</p>
<p className="text-sm text-gray-600">Dograh Tokens</p>
<p className="text-sm text-muted-foreground">Dograh Tokens</p>
</>
)}
</div>
{!organizationPricing?.price_per_second_usd && (
<div className="text-right">
<p className="text-lg font-semibold">{currentUsage.percentage_used}%</p>
<p className="text-sm text-gray-600">Used</p>
<p className="text-sm text-muted-foreground">Used</p>
</div>
)}
</div>
@ -392,18 +392,18 @@ export default function UsagePage() {
<Progress value={currentUsage.percentage_used} className="h-3" />
)}
<div className="flex justify-between items-center text-sm text-gray-600">
<div className="flex justify-between items-center text-sm text-muted-foreground">
<div className="flex items-center">
<Calendar className="h-4 w-4 mr-1" />
Next refresh: {formatDate(currentUsage.next_refresh_date)}
</div>
<div>
Total Duration: <span className="font-medium text-gray-900">{formatDuration(currentUsage.total_duration_seconds)}</span>
Total Duration: <span className="font-medium text-foreground">{formatDuration(currentUsage.total_duration_seconds)}</span>
</div>
</div>
</div>
) : (
<p className="text-gray-500">Unable to load usage data</p>
<p className="text-muted-foreground">Unable to load usage data</p>
)}
</CardContent>
</Card>
@ -446,17 +446,17 @@ export default function UsagePage() {
{isLoadingHistory ? (
<div className="animate-pulse space-y-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-12 bg-gray-200 rounded"></div>
<div key={i} className="h-12 bg-muted rounded"></div>
))}
</div>
) : usageHistory && usageHistory.runs.length > 0 ? (
<>
<div className="bg-white border rounded-lg overflow-hidden shadow-sm">
<div className="bg-card border rounded-lg overflow-hidden shadow-sm">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableRow className="bg-muted/50">
<TableHead className="font-semibold">Run ID</TableHead>
<TableHead className="font-semibold">Workflow Name</TableHead>
<TableHead className="font-semibold">Agent Name</TableHead>
<TableHead className="font-semibold">Phone Number</TableHead>
<TableHead className="font-semibold">Disposition</TableHead>
<TableHead className="font-semibold">Date</TableHead>
@ -518,13 +518,13 @@ export default function UsagePage() {
{/* Summary */}
{activeFilters.length > 0 && (
<div className="mt-4 p-3 bg-gray-50 rounded-md">
<p className="text-sm text-gray-600">
Total for filtered period: <span className="font-semibold text-gray-900">
<div className="mt-4 p-3 bg-muted rounded-md">
<p className="text-sm text-muted-foreground">
Total for filtered period: <span className="font-semibold text-foreground">
{usageHistory.total_dograh_tokens.toLocaleString()} Dograh Tokens
</span>
{' • '}
<span className="font-semibold text-gray-900">
<span className="font-semibold text-foreground">
{formatDuration(usageHistory.total_duration_seconds)}
</span>
</p>
@ -534,7 +534,7 @@ export default function UsagePage() {
{/* Pagination */}
{usageHistory.total_pages > 1 && (
<div className="flex items-center justify-between mt-6">
<p className="text-sm text-gray-600">
<p className="text-sm text-muted-foreground">
Page {usageHistory.page} of {usageHistory.total_pages} ({usageHistory.total_count} total runs)
</p>
<div className="flex gap-2">
@ -561,7 +561,7 @@ export default function UsagePage() {
)}
</>
) : (
<p className="text-center py-8 text-gray-500">No usage history found</p>
<p className="text-center py-8 text-muted-foreground">No usage history found</p>
)}
</CardContent>
</Card>

View file

@ -1,28 +1,17 @@
import React, { ReactNode } from 'react'
import BaseHeader from '@/components/header/BaseHeader'
interface WorkflowLayoutProps {
children: ReactNode,
headerActions?: ReactNode,
backButton?: ReactNode,
showFeaturesNav?: boolean,
stickyTabs?: ReactNode
}
const WorkflowLayout: React.FC<WorkflowLayoutProps> = ({ children, headerActions, backButton, showFeaturesNav = true, stickyTabs }) => {
const WorkflowLayout: React.FC<WorkflowLayoutProps> = ({ children }) => {
// This component is kept for backward compatibility
// AppLayout is now applied globally in the root layout
return (
<>
<BaseHeader headerActions={headerActions} backButton={backButton} showFeaturesNav={showFeaturesNav} />
{stickyTabs && (
<div className="sticky top-[73px] z-40 bg-[#2a2e39] border-b border-gray-700">
<div className="container mx-auto px-4">
<div className="flex items-center justify-center py-2">
{stickyTabs}
</div>
</div>
</div>
)}
{children}
</>
)

View file

@ -3,14 +3,12 @@ import '@xyflow/react/dist/style.css';
import {
Background,
BackgroundVariant,
MiniMap,
Panel,
ReactFlow,
} from "@xyflow/react";
import { BrushCleaning, Maximize2, Minus, Plus, Rocket, Settings, Variable } from 'lucide-react';
import React, { useMemo, useState } from 'react';
import WorkflowLayout from '@/app/workflow/WorkflowLayout';
import { FlowEdge, FlowNode, NodeType } from "@/components/flow/types";
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
@ -21,9 +19,9 @@ import CustomEdge from "../../../components/flow/edges/CustomEdge";
import { AgentNode, EndCall, GlobalNode, StartCall } from "../../../components/flow/nodes";
import { ConfigurationsDialog } from './components/ConfigurationsDialog';
import { EmbedDialog } from './components/EmbedDialog';
import { PhoneCallDialog } from './components/PhoneCallDialog';
import { TemplateContextVariablesDialog } from './components/TemplateContextVariablesDialog';
import WorkflowHeader from "./components/WorkflowHeader";
import { WorkflowTabs } from './components/WorkflowTabs';
import { WorkflowEditorHeader } from "./components/WorkflowEditorHeader";
import { WorkflowProvider } from "./contexts/WorkflowContext";
import { useWorkflowState } from "./hooks/useWorkflowState";
import { layoutNodes } from './utils/layoutNodes';
@ -40,22 +38,6 @@ const edgeTypes = {
custom: CustomEdge,
};
// Helper function for MiniMap node colors
const getNodeColor = (node: FlowNode) => {
switch (node.type) {
case NodeType.START_CALL:
return '#10B981'; // green-500
case NodeType.AGENT_NODE:
return '#3B82F6'; // blue-500
case NodeType.END_CALL:
return '#EF4444'; // red-500
case NodeType.GLOBAL_NODE:
return '#F59E0B'; // orange-500
default:
return '#6B7280'; // gray-500
}
};
interface RenderWorkflowProps {
initialWorkflowName: string;
workflowId: number;
@ -78,6 +60,7 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
const [isContextVarsDialogOpen, setIsContextVarsDialogOpen] = useState(false);
const [isConfigurationsDialogOpen, setIsConfigurationsDialogOpen] = useState(false);
const [isEmbedDialogOpen, setIsEmbedDialogOpen] = useState(false);
const [isPhoneCallDialogOpen, setIsPhoneCallDialogOpen] = useState(false);
const {
rfInstance,
@ -90,6 +73,7 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
templateContextVariables,
workflowConfigurations,
setNodes,
setIsDirty,
setIsAddNodePanelOpen,
handleNodeSelect,
saveWorkflow,
@ -115,29 +99,28 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
type: "custom"
}), []);
const headerActions = (
<WorkflowHeader
workflowValidationErrors={workflowValidationErrors}
isDirty={isDirty}
workflowName={workflowName}
rfInstance={rfInstance}
onRun={onRun}
workflowId={workflowId}
saveWorkflow={saveWorkflow}
user={user}
getAccessToken={getAccessToken}
/>
);
const stickyTabs = <WorkflowTabs workflowId={workflowId} currentTab="editor" />;
// Memoize the context value to prevent unnecessary re-renders
const workflowContextValue = useMemo(() => ({ saveWorkflow }), [saveWorkflow]);
return (
<WorkflowProvider value={workflowContextValue}>
<WorkflowLayout headerActions={headerActions} showFeaturesNav={false} stickyTabs={stickyTabs}>
<div className="h-[calc(100vh-128px)] relative">
<div className="flex flex-col h-screen">
{/* New Workflow Editor Header */}
<WorkflowEditorHeader
workflowName={workflowName}
isDirty={isDirty}
workflowValidationErrors={workflowValidationErrors}
rfInstance={rfInstance}
onRun={onRun}
workflowId={workflowId}
saveWorkflow={saveWorkflow}
user={user}
getAccessToken={getAccessToken}
onPhoneCallClick={() => setIsPhoneCallDialogOpen(true)}
/>
{/* Workflow Canvas */}
<div className="flex-1 relative">
<ReactFlow
nodes={nodes}
edges={edges}
@ -162,12 +145,6 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
size={1}
color="#94a3b8"
/>
<MiniMap
nodeColor={getNodeColor}
position="bottom-right"
className="bg-white/90 border rounded shadow-lg"
maskColor="rgb(0, 0, 0, 0.1)"
/>
{/* Top-right controls - vertical layout */}
<Panel position="top-right">
@ -301,7 +278,10 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
<Button
variant="outline"
size="icon"
onClick={() => setNodes(layoutNodes(nodes, edges, 'LR', rfInstance))}
onClick={() => {
setNodes(layoutNodes(nodes, edges, 'TB', rfInstance));
setIsDirty(true);
}}
className="bg-white shadow-sm hover:shadow-md h-8 w-8"
>
<BrushCleaning className="h-4 w-4" />
@ -343,7 +323,15 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
workflowName={workflowName}
getAccessToken={getAccessToken}
/>
</WorkflowLayout>
<PhoneCallDialog
open={isPhoneCallDialogOpen}
onOpenChange={setIsPhoneCallDialogOpen}
workflowId={workflowId}
getAccessToken={getAccessToken}
user={user}
/>
</div>
</WorkflowProvider>
);
}

View file

@ -98,9 +98,9 @@ export const ConfigurationsDialog = ({
{/* Workflow Name Section */}
<div className="space-y-4">
<div>
<h3 className="text-sm font-semibold mb-1">Workflow Name</h3>
<p className="text-xs text-gray-500">
The name of your workflow
<h3 className="text-sm font-semibold mb-1">Agent Name</h3>
<p className="text-xs text-muted-foreground">
The name of your agent
</p>
</div>
<div className="space-y-2">
@ -112,7 +112,7 @@ export const ConfigurationsDialog = ({
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter workflow name"
placeholder="Enter Agent name"
/>
</div>
</div>
@ -121,7 +121,7 @@ export const ConfigurationsDialog = ({
<div className="space-y-4">
<div>
<h3 className="text-sm font-semibold mb-1">Voice Activity Detection</h3>
<p className="text-xs text-gray-500">
<p className="text-xs text-muted-foreground">
Hyperparameters to set for voice activity detection. Already configured with defaults.
</p>
</div>
@ -191,7 +191,7 @@ export const ConfigurationsDialog = ({
<div className="space-y-4">
<div>
<h3 className="text-sm font-semibold mb-1">Ambient Noise</h3>
<p className="text-xs text-gray-500">
<p className="text-xs text-muted-foreground">
Add background office ambient noise to make the conversation sound more natural.
</p>
</div>
@ -238,7 +238,7 @@ export const ConfigurationsDialog = ({
<div className="space-y-4">
<div>
<h3 className="text-sm font-semibold mb-1">Call Management</h3>
<p className="text-xs text-gray-500">
<p className="text-xs text-muted-foreground">
Configure call duration limits and idle timeout settings.
</p>
</div>
@ -261,7 +261,7 @@ export const ConfigurationsDialog = ({
}
}}
/>
<p className="text-xs text-gray-500">Default: 600 (10 minutes)</p>
<p className="text-xs text-muted-foreground">Default: 600 (10 minutes)</p>
</div>
<div className="space-y-2">
@ -281,7 +281,7 @@ export const ConfigurationsDialog = ({
}
}}
/>
<p className="text-xs text-gray-500">Default: 10 seconds</p>
<p className="text-xs text-muted-foreground">Default: 10 seconds</p>
</div>
</div>
</div>

View file

@ -200,7 +200,7 @@ export function EmbedDialog({
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-gray-500" />
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<div className="space-y-6">

View file

@ -0,0 +1,220 @@
"use client";
import 'react-international-phone/style.css';
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { PhoneInput } from 'react-international-phone';
import {
getTelephonyConfigurationApiV1OrganizationsTelephonyConfigGet,
initiateCallApiV1TelephonyInitiateCallPost
} from '@/client/sdk.gen';
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useUserConfig } from "@/context/UserConfigContext";
interface PhoneCallDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
workflowId: number;
getAccessToken: () => Promise<string>;
user: { id: string; email?: string };
}
export const PhoneCallDialog = ({
open,
onOpenChange,
workflowId,
getAccessToken,
user,
}: PhoneCallDialogProps) => {
const router = useRouter();
const { userConfig, saveUserConfig } = useUserConfig();
const [phoneNumber, setPhoneNumber] = useState(userConfig?.test_phone_number || "");
const [callLoading, setCallLoading] = useState(false);
const [callError, setCallError] = useState<string | null>(null);
const [callSuccessMsg, setCallSuccessMsg] = useState<string | null>(null);
const [phoneChanged, setPhoneChanged] = useState(false);
const [configureDialogOpen, setConfigureDialogOpen] = useState(false);
const [needsConfiguration, setNeedsConfiguration] = useState(false);
// Check telephony configuration when dialog opens
useEffect(() => {
const checkConfig = async () => {
if (!open) return;
try {
const accessToken = await getAccessToken();
const configResponse = await getTelephonyConfigurationApiV1OrganizationsTelephonyConfigGet({
headers: { 'Authorization': `Bearer ${accessToken}` },
});
if (configResponse.error || (!configResponse.data?.twilio && !configResponse.data?.vonage && !configResponse.data?.vobiz)) {
setNeedsConfiguration(true);
setConfigureDialogOpen(true);
onOpenChange(false);
} else {
setNeedsConfiguration(false);
}
} catch (err) {
console.error("Failed to check telephony config:", err);
}
};
checkConfig();
}, [open, getAccessToken, onOpenChange]);
// Reset state when dialog closes
useEffect(() => {
if (!open) {
setCallError(null);
setCallSuccessMsg(null);
setCallLoading(false);
}
}, [open]);
// Keep phoneNumber in sync with userConfig when dialog opens
useEffect(() => {
if (open) {
setPhoneNumber(userConfig?.test_phone_number || "");
setPhoneChanged(false);
setCallError(null);
setCallSuccessMsg(null);
setCallLoading(false);
}
}, [open, userConfig?.test_phone_number]);
const handlePhoneInputChange = (formattedValue: string) => {
setPhoneNumber(formattedValue);
setPhoneChanged(formattedValue !== userConfig?.test_phone_number);
setCallError(null);
setCallSuccessMsg(null);
};
const handleConfigureContinue = () => {
setConfigureDialogOpen(false);
router.push('/telephony-configurations');
};
const handleStartCall = async () => {
setCallLoading(true);
setCallError(null);
setCallSuccessMsg(null);
try {
if (!user || !userConfig) return;
const accessToken = await getAccessToken();
// Save phone number if it has changed
if (phoneChanged) {
await saveUserConfig({ ...userConfig, test_phone_number: phoneNumber });
setPhoneChanged(false);
}
const response = await initiateCallApiV1TelephonyInitiateCallPost({
body: {
workflow_id: workflowId,
phone_number: phoneNumber
},
headers: { 'Authorization': `Bearer ${accessToken}` },
});
if (response.error) {
let errMsg = "Failed to initiate call";
if (typeof response.error === "string") {
errMsg = response.error;
} else if (response.error && typeof response.error === "object") {
errMsg = (response.error as unknown as { detail: string }).detail || JSON.stringify(response.error);
}
setCallError(errMsg);
} else {
const msg = response.data && (response.data as unknown as { message: string }).message || "Call initiated successfully!";
setCallSuccessMsg(typeof msg === "string" ? msg : JSON.stringify(msg));
}
} catch (err: unknown) {
setCallError(err instanceof Error ? err.message : "Failed to initiate call");
} finally {
setCallLoading(false);
}
};
return (
<>
{/* Phone Call Dialog */}
<Dialog open={open && !needsConfiguration} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Phone Call</DialogTitle>
<DialogDescription>
Enter the phone number to call. The number will be saved automatically.
</DialogDescription>
</DialogHeader>
<PhoneInput
defaultCountry="in"
value={phoneNumber}
onChange={handlePhoneInputChange}
/>
<DialogFooter className="flex-col sm:flex-row gap-2">
<Button
variant="outline"
onClick={() => {
onOpenChange(false);
router.push('/telephony-configurations');
}}
>
Configure Telephony
</Button>
<div className="flex gap-2 flex-1 justify-end">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
{!callSuccessMsg ? (
<Button
onClick={handleStartCall}
disabled={callLoading || !phoneNumber}
>
{callLoading ? "Calling..." : "Start Call"}
</Button>
) : (
<Button onClick={() => onOpenChange(false)}>
Close
</Button>
)}
</div>
</DialogFooter>
{callError && <div className="text-red-500 text-sm mt-2">{callError}</div>}
{callSuccessMsg && <div className="text-green-600 text-sm mt-2">{callSuccessMsg}</div>}
</DialogContent>
</Dialog>
{/* Configure Telephony Dialog */}
<Dialog open={configureDialogOpen} onOpenChange={setConfigureDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Configure Telephony</DialogTitle>
<DialogDescription>
You need to configure your telephony settings before making phone calls.
You will be redirected to the telephony configuration page.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setConfigureDialogOpen(false)}>
Do it Later
</Button>
<Button onClick={handleConfigureContinue}>
Continue
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};

View file

@ -77,7 +77,7 @@ export const TemplateContextVariablesDialog = ({
<div key={key} className="flex items-center gap-2 p-2 border rounded-md">
<div className="flex-1">
<div className="text-sm font-medium">{key}</div>
<div className="text-xs text-gray-500 truncate">{value}</div>
<div className="text-xs text-muted-foreground truncate">{value}</div>
</div>
<Button
size="sm"

View file

@ -0,0 +1,184 @@
"use client";
import { ReactFlowInstance } from "@xyflow/react";
import { ArrowLeft, ChevronDown, Download, History, LoaderCircle, MoreVertical, Phone } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { WorkflowError } from "@/client/types.gen";
import { FlowEdge, FlowNode } from "@/components/flow/types";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { WORKFLOW_RUN_MODES } from "@/constants/workflowRunModes";
interface WorkflowEditorHeaderProps {
workflowName: string;
isDirty: boolean;
workflowValidationErrors: WorkflowError[];
rfInstance: React.RefObject<ReactFlowInstance<FlowNode, FlowEdge> | null>;
onRun: (mode: string) => Promise<void>;
workflowId: number;
saveWorkflow: (updateWorkflowDefinition?: boolean) => Promise<void>;
user: { id: string; email?: string };
getAccessToken: () => Promise<string>;
onPhoneCallClick: () => void;
}
export const WorkflowEditorHeader = ({
workflowName,
isDirty,
workflowValidationErrors,
rfInstance,
saveWorkflow,
onRun,
onPhoneCallClick,
workflowId,
}: WorkflowEditorHeaderProps) => {
const router = useRouter();
const [savingWorkflow, setSavingWorkflow] = useState(false);
const hasValidationErrors = workflowValidationErrors.length > 0;
const isCallDisabled = isDirty || hasValidationErrors;
const handleSave = async () => {
setSavingWorkflow(true);
await saveWorkflow();
setSavingWorkflow(false);
};
const handleBack = () => {
router.push("/workflow");
};
const handleDownloadWorkflow = () => {
if (!rfInstance.current) return;
const workflowDefinition = rfInstance.current.toObject();
const exportData = {
name: workflowName,
workflow_definition: workflowDefinition,
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${workflowName}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
return (
<div className="flex items-center justify-between w-full h-14 px-4 bg-[#1a1a1a] border-b border-[#2a2a2a]">
{/* Left section: Back button + Workflow name */}
<div className="flex items-center gap-3">
<button
onClick={handleBack}
className="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-[#2a2a2a] transition-colors"
>
<ArrowLeft className="w-5 h-5 text-gray-400" />
</button>
<div className="flex items-center gap-2">
<h1 className="text-base font-medium text-white">
{workflowName}
</h1>
</div>
</div>
{/* Right section: Unsaved indicator + Call button + Save button */}
<div className="flex items-center gap-3">
{/* Unsaved changes indicator */}
{isDirty && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-md border border-yellow-500/30 bg-yellow-500/10">
<div className="w-2 h-2 rounded-full bg-yellow-500" />
<span className="text-sm text-yellow-500">Unsaved changes</span>
</div>
)}
{/* Call button with dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="flex items-center gap-2 bg-transparent border-[#3a3a3a] hover:bg-[#2a2a2a] text-white"
disabled={isCallDisabled}
>
<Phone className="w-4 h-4" />
Call
<ChevronDown className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-[#1a1a1a] border-[#3a3a3a]">
<DropdownMenuItem
onClick={() => onRun(WORKFLOW_RUN_MODES.SMALL_WEBRTC)}
className="text-white hover:bg-[#2a2a2a] cursor-pointer"
>
<Phone className="w-4 h-4 mr-2" />
Web Call
</DropdownMenuItem>
<DropdownMenuItem
onClick={onPhoneCallClick}
className="text-white hover:bg-[#2a2a2a] cursor-pointer"
>
<Phone className="w-4 h-4 mr-2" />
Phone Call
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Save button */}
<Button
onClick={handleSave}
disabled={!isDirty || savingWorkflow}
className="bg-teal-600 hover:bg-teal-700 text-white px-4"
>
{savingWorkflow ? (
<>
<LoaderCircle className="w-4 h-4 mr-2 animate-spin" />
Saving...
</>
) : (
"Save"
)}
</Button>
{/* More options dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-gray-400 hover:text-white hover:bg-[#2a2a2a]"
>
<MoreVertical className="w-5 h-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-[#1a1a1a] border-[#3a3a3a]">
<DropdownMenuItem
onClick={() => router.push(`/workflow/${workflowId}/runs`)}
className="text-white hover:bg-[#2a2a2a] cursor-pointer"
>
<History className="w-4 h-4 mr-2" />
View Runs
</DropdownMenuItem>
<DropdownMenuItem
onClick={handleDownloadWorkflow}
className="text-white hover:bg-[#2a2a2a] cursor-pointer"
>
<Download className="w-4 h-4 mr-2" />
Download Workflow
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
};

View file

@ -137,7 +137,6 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti
const updatePageInUrl = useCallback((page: number, filters?: ActiveFilter[]) => {
const params = new URLSearchParams();
params.set('tab', 'executions');
params.set('page', page.toString());
// Add filters to URL if present
@ -149,7 +148,7 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti
}
}
router.push(`/workflow/${workflowId}?${params.toString()}`, { scroll: false });
router.push(`/workflow/${workflowId}/runs?${params.toString()}`, { scroll: false });
}, [router, workflowId]);
useEffect(() => {
@ -194,12 +193,12 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti
<div className="animate-pulse">Loading workflow runs...</div>
</div>
) : error ? (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
<div className="bg-destructive/10 border border-destructive/30 text-destructive px-4 py-3 rounded">
{error}
</div>
) : workflowRuns.length === 0 ? (
<div className="text-center py-8">
<p className="text-gray-500">No workflow runs found</p>
<p className="text-muted-foreground">No workflow runs found</p>
</div>
) : (
<Card>
@ -210,10 +209,10 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti
</CardDescription>
</CardHeader>
<CardContent>
<div className="bg-white border rounded-lg overflow-hidden shadow-sm">
<div className="bg-card border border-border rounded-lg overflow-hidden shadow-sm">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableRow className="bg-muted/50">
<TableHead className="font-semibold">ID</TableHead>
<TableHead className="font-semibold">Status</TableHead>
<TableHead className="font-semibold">Created At</TableHead>
@ -306,7 +305,7 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-6">
<p className="text-sm text-gray-600">
<p className="text-sm text-muted-foreground">
Page {currentPage} of {totalPages}
</p>
<div className="flex gap-2">

View file

@ -1,427 +0,0 @@
import 'react-international-phone/style.css';
import { ReactFlowInstance, ReactFlowJsonObject } from "@xyflow/react";
import { AlertTriangle, CheckCheck, Download, LoaderCircle, Phone, ShieldCheck } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { PhoneInput } from 'react-international-phone';
import { getTelephonyConfigurationApiV1OrganizationsTelephonyConfigGet, initiateCallApiV1TelephonyInitiateCallPost } from '@/client/sdk.gen';
import { WorkflowError } from '@/client/types.gen';
import { FlowEdge, FlowNode } from "@/components/flow/types";
import { OnboardingTooltip } from '@/components/onboarding/OnboardingTooltip';
import { Button } from "@/components/ui/button";
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { WORKFLOW_RUN_MODES } from '@/constants/workflowRunModes';
import { useOnboarding } from '@/context/OnboardingContext';
import { useUserConfig } from "@/context/UserConfigContext";
interface WorkflowHeaderProps {
isDirty: boolean;
workflowName: string;
rfInstance: React.RefObject<ReactFlowInstance<FlowNode, FlowEdge> | null>;
onRun: (mode: string) => Promise<void>;
workflowId: number;
workflowValidationErrors: WorkflowError[];
saveWorkflow: (updateWorkflowDefinition?: boolean) => Promise<void>;
user: { id: string; email?: string };
getAccessToken: () => Promise<string>;
}
const handleExport = (workflow_name: string, workflow_definition: ReactFlowJsonObject<FlowNode, FlowEdge> | undefined) => {
if (!workflow_definition) return { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } };
const exportData = {
name: workflow_name,
workflow_definition: workflow_definition
};
// Convert to JSON string with proper formatting
const jsonString = JSON.stringify(exportData, null, 2);
// Create a blob with the JSON data
const blob = new Blob([jsonString], { type: 'application/json' });
// Create a download link
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${workflow_name.replace(/\s+/g, '_')}.json`;
// Trigger download
document.body.appendChild(link);
link.click();
// Cleanup
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
const WorkflowHeader = ({ isDirty, workflowName, rfInstance, onRun, workflowId, workflowValidationErrors, saveWorkflow, user, getAccessToken }: WorkflowHeaderProps) => {
const router = useRouter();
const { userConfig, saveUserConfig } = useUserConfig();
const { hasSeenTooltip, markTooltipSeen } = useOnboarding();
const [dialogOpen, setDialogOpen] = useState(false);
const [phoneNumber, setPhoneNumber] = useState(userConfig?.test_phone_number || "");
const [savingWorkflow, setSavingWorkflow] = useState(false);
const [callLoading, setCallLoading] = useState(false);
const [callError, setCallError] = useState<string | null>(null);
const [callSuccessMsg, setCallSuccessMsg] = useState<string | null>(null);
const [phoneChanged, setPhoneChanged] = useState(false);
const [validationDialogOpen, setValidationDialogOpen] = useState(false);
const [configureDialogOpen, setConfigureDialogOpen] = useState(false);
const webCallButtonRef = useRef<HTMLButtonElement>(null);
const hasValidationErrors = workflowValidationErrors.length > 0;
// Reset call-related state whenever the dialog is closed so that a new call can be placed
useEffect(() => {
if (!dialogOpen) {
setCallError(null);
setCallSuccessMsg(null);
setCallLoading(false);
}
}, [dialogOpen]);
// Keep phoneNumber in sync with userConfig when dialog opens
const handleDialogOpenChange = (open: boolean) => {
setDialogOpen(open);
if (open) {
setPhoneNumber(userConfig?.test_phone_number || "");
setPhoneChanged(false);
setCallError(null);
setCallSuccessMsg(null);
setCallLoading(false);
}
};
const handlePhoneInputChange = (
formattedValue: string
) => {
// `value` is the raw E.164 value, e.g. "+14155552671"
setPhoneNumber(formattedValue);
setPhoneChanged(formattedValue !== userConfig?.test_phone_number);
// clear any prior errors, etc.
setCallError(null);
setCallSuccessMsg(null);
};
const handlePhoneCallClick = async () => {
// Check telephony configuration before opening dialog
try {
const accessToken = await getAccessToken();
const configResponse = await getTelephonyConfigurationApiV1OrganizationsTelephonyConfigGet({
headers: { 'Authorization': `Bearer ${accessToken}` },
});
// If no configuration exists, show configure dialog
// Check if any telephony provider is configured (Twilio, Vonage, or Vobiz)
if (configResponse.error || (!configResponse.data?.twilio && !configResponse.data?.vonage && !configResponse.data?.vobiz)) {
setConfigureDialogOpen(true);
return;
}
// Configuration exists, open the phone call dialog
setDialogOpen(true);
} catch (err: unknown) {
console.error("Failed to check telephony config:", err);
// Still open dialog to show the error
setDialogOpen(true);
}
};
const handleConfigureContinue = () => {
setConfigureDialogOpen(false);
router.push(`/configure-telephony?returnTo=/workflow/${workflowId}`);
};
const handleStartCall = async () => {
setCallLoading(true);
setCallError(null);
setCallSuccessMsg(null);
try {
if (!user || !userConfig) return;
const accessToken = await getAccessToken();
// Save phone number if it has changed
if (phoneChanged) {
await saveUserConfig({ ...userConfig, test_phone_number: phoneNumber });
setPhoneChanged(false);
}
// Configuration exists, proceed with call initiation
const response = await initiateCallApiV1TelephonyInitiateCallPost({
body: {
workflow_id: workflowId,
phone_number: phoneNumber
},
headers: { 'Authorization': `Bearer ${accessToken}` },
});
if (response.error) {
let errMsg = "Failed to initiate call";
if (typeof response.error === "string") {
errMsg = response.error;
} else if (response.error && typeof response.error === "object") {
errMsg = (response.error as unknown as { detail: string }).detail || JSON.stringify(response.error);
}
setCallError(errMsg);
} else {
// Try to show a message from the response, fallback to generic
const msg = response.data && (response.data as unknown as { message: string }).message || "Call initiated successfully!";
setCallSuccessMsg(typeof msg === "string" ? msg : JSON.stringify(msg));
}
} catch (err: unknown) {
setCallError(err instanceof Error ? err.message : "Failed to initiate call");
} finally {
setCallLoading(false);
}
};
return (
<div className="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1 text-sm text-gray-500 mr-2">
{hasValidationErrors ? (
<AlertTriangle className="h-4 w-4 text-red-500" />
) : (
<ShieldCheck className="h-4 w-4 text-green-500" />
)}
<span>{hasValidationErrors ? 'Invalid' : 'Valid'}</span>
{hasValidationErrors && (
<Button
size="sm"
className="ml-1 h-6 px-2 text-xs"
onClick={() => setValidationDialogOpen(true)}
>
View Issues
</Button>
)}
</div>
</TooltipTrigger>
<TooltipContent>
{hasValidationErrors
? `Workflow has ${workflowValidationErrors.length} validation ${workflowValidationErrors.length === 1 ? 'issue' : 'issues'}`
: 'Workflow is valid'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="outline"
size="sm"
onClick={() => handleExport(workflowName, rfInstance.current?.toObject())}
disabled={isDirty || hasValidationErrors}
>
<Download className="mr-2 h-4 w-4" />
Export Pathway
</Button>
</span>
</TooltipTrigger>
{(isDirty || hasValidationErrors) && (
<TooltipContent>
{isDirty ? 'Save the workflow before exporting' : 'Fix validation errors before exporting'}
</TooltipContent>
)}
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
ref={webCallButtonRef}
variant="outline"
size="sm"
onClick={() => {
// Mark the tooltip as seen when the button is clicked
if (!hasSeenTooltip('web_call')) {
markTooltipSeen('web_call');
}
onRun(WORKFLOW_RUN_MODES.SMALL_WEBRTC);
}}
disabled={isDirty || hasValidationErrors}
>
<Phone className="mr-2 h-4 w-4" />
Web Call
</Button>
</span>
</TooltipTrigger>
{(isDirty || hasValidationErrors) && (
<TooltipContent>
{isDirty ? 'Save the workflow before testing' : 'Fix validation errors before testing'}
</TooltipContent>
)}
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="outline"
size="sm"
onClick={handlePhoneCallClick}
disabled={isDirty || hasValidationErrors}
>
<Phone className="mr-2 h-4 w-4" />
Phone Call
</Button>
</span>
</TooltipTrigger>
{(isDirty || hasValidationErrors) && (
<TooltipContent>
{isDirty ? 'Save the workflow before making a call' : 'Fix validation errors before making a call'}
</TooltipContent>
)}
</Tooltip>
{isDirty ? (
<Button
variant="default"
size="sm"
onClick={async () => {
setSavingWorkflow(true);
await saveWorkflow();
setSavingWorkflow(false);
}}
disabled={savingWorkflow}
className="animate-pulse"
>
{savingWorkflow ? (
<>
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
'Save Changes'
)}
</Button>
) : (
<div className="flex items-center gap-1 text-sm text-gray-500">
<CheckCheck className="h-4 w-4 text-green-500" />
<span className='mr-2'>Saved</span>
</div>
)}
{/* Validation Errors Dialog */}
<Dialog open={validationDialogOpen} onOpenChange={setValidationDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Workflow Validation Issues</DialogTitle>
<DialogDescription>
Please fix the following issues before running the workflow.
</DialogDescription>
</DialogHeader>
<div className="max-h-[60vh] overflow-y-auto">
<ul className="space-y-2">
{workflowValidationErrors.map((error, index) => (
<li key={index} className="border-l-2 border-red-500 pl-3 py-2">
<div className="font-medium">{error.message}</div>
{error.id && (
<div className="text-sm text-gray-500">
{error.kind === 'node' ? 'Node' : error.kind === 'edge' ? 'Edge' : 'Workflow'} ID: {error.id}
</div>
)}
{error.field && (
<div className="text-sm mt-1">
Field: {error.field}
</div>
)}
</li>
))}
</ul>
</div>
<DialogFooter>
<Button onClick={() => setValidationDialogOpen(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Phone Call Dialog */}
<Dialog open={dialogOpen} onOpenChange={handleDialogOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Phone Call</DialogTitle>
<DialogDescription>
Enter the phone number to call. The number will be saved automatically.
</DialogDescription>
</DialogHeader>
<PhoneInput
defaultCountry="in"
value={phoneNumber}
onChange={handlePhoneInputChange}
/>
<DialogFooter className="flex-col sm:flex-row gap-2">
<Button
variant="outline"
onClick={() => {
setDialogOpen(false);
router.push(`/configure-telephony?returnTo=/workflow/${workflowId}`);
}}
>
Configure Telephony
</Button>
<div className="flex gap-2 flex-1 justify-end">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
{!callSuccessMsg ? (
<Button
onClick={handleStartCall}
disabled={callLoading || !phoneNumber}
>
{callLoading ? "Calling..." : "Start Call"}
</Button>
) : (
<Button onClick={() => setDialogOpen(false)}>
Close
</Button>
)}
</div>
</DialogFooter>
{callError && <div className="text-red-500 text-sm mt-2">{callError}</div>}
{callSuccessMsg && <div className="text-green-600 text-sm mt-2">{callSuccessMsg}</div>}
</DialogContent>
</Dialog>
{/* Configure Telephony Dialog */}
<Dialog open={configureDialogOpen} onOpenChange={setConfigureDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Configure Telephony</DialogTitle>
<DialogDescription>
You need to configure your telephony settings before making phone calls.
You will be redirected to the telephony configuration page.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setConfigureDialogOpen(false)}>
Do it Later
</Button>
<Button onClick={handleConfigureContinue}>
Continue
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Onboarding Tooltip */}
<OnboardingTooltip
title='Test your Voice Agent'
targetRef={webCallButtonRef}
message="Test this workflow now in your browser using Web Call"
onDismiss={() => markTooltipSeen('web_call')}
showNext={false}
isVisible={!hasSeenTooltip('web_call') && !hasValidationErrors}
/>
</div>
);
};
export default WorkflowHeader;

View file

@ -1,45 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { cn } from "@/lib/utils";
interface WorkflowTabsProps {
workflowId: number;
currentTab: 'editor' | 'executions';
}
export const WorkflowTabs = ({ workflowId, currentTab }: WorkflowTabsProps) => {
const router = useRouter();
const handleTabChange = (tab: 'editor' | 'executions') => {
router.push(`/workflow/${workflowId}?tab=${tab}`, { scroll: false });
};
return (
<div className="flex gap-2">
<button
onClick={() => handleTabChange('editor')}
className={cn(
"px-6 py-2.5 text-sm font-medium transition-all relative cursor-pointer rounded-md",
currentTab === 'editor'
? "text-white bg-[#3d4451]"
: "text-gray-300 hover:text-white hover:bg-[#343842]"
)}
>
Editor
</button>
<button
onClick={() => handleTabChange('executions')}
className={cn(
"px-6 py-2.5 text-sm font-medium transition-all relative cursor-pointer rounded-md",
currentTab === 'executions'
? "text-white bg-[#3d4451]"
: "text-gray-300 hover:text-white hover:bg-[#343842]"
)}
>
Executions
</button>
</div>
);
};

View file

@ -1 +0,0 @@
export * from './WorkflowHeader';

View file

@ -431,6 +431,7 @@ export const useWorkflowState = ({
templateContextVariables,
workflowConfigurations,
setNodes,
setIsDirty,
setIsAddNodePanelOpen,
handleNodeSelect,
handleNameChange,

View file

@ -1,6 +1,6 @@
'use client';
import { useParams, useSearchParams } from 'next/navigation';
import { useParams } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
import RenderWorkflow from '@/app/workflow/[workflowId]/RenderWorkflow';
@ -10,23 +10,17 @@ import { FlowEdge, FlowNode } from '@/components/flow/types';
import SpinLoader from '@/components/SpinLoader';
import { useAuth } from '@/lib/auth';
import logger from '@/lib/logger';
import { DEFAULT_WORKFLOW_CONFIGURATIONS,WorkflowConfigurations } from '@/types/workflow-configurations';
import { DEFAULT_WORKFLOW_CONFIGURATIONS, WorkflowConfigurations } from '@/types/workflow-configurations';
import WorkflowLayout from '../WorkflowLayout';
import { WorkflowExecutions } from './components/WorkflowExecutions';
import { WorkflowTabs } from './components/WorkflowTabs';
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);
const { user, getAccessToken, redirectToLogin, loading: authLoading } = useAuth();
// Get current tab from URL, default to 'editor'
const currentTab = (searchParams.get('tab') as 'editor' | 'executions') || 'editor';
// Redirect if not authenticated
useEffect(() => {
if (!authLoading && !user) {
@ -62,59 +56,41 @@ export default function WorkflowDetailPage() {
}
}, [params.workflowId, user, getAccessToken]);
const stickyTabs = workflow ? <WorkflowTabs workflowId={workflow.id} currentTab={currentTab} /> : null;
// Memoize user and getAccessToken to prevent unnecessary re-renders
const stableUser = useMemo(() => user, [user]);
const stableGetAccessToken = useMemo(() => getAccessToken, [getAccessToken]);
if (loading) {
return (
<WorkflowLayout stickyTabs={stickyTabs}>
<WorkflowLayout>
<SpinLoader />
</WorkflowLayout>
);
}
else if (error || !workflow) {
return (
<WorkflowLayout showFeaturesNav={false} stickyTabs={stickyTabs}>
<WorkflowLayout showFeaturesNav={false}>
<div className="flex items-center justify-center min-h-screen">
<div className="text-lg text-red-500">{error || 'Workflow not found'}</div>
<div className="text-lg text-destructive">{error || 'Workflow not found'}</div>
</div>
</WorkflowLayout>
);
}
else {
// Render both views but hide the inactive one using absolute positioning
// This preserves state when switching tabs
return (
<>
{/* Editor view */}
<div className={currentTab === 'editor' ? 'block' : 'hidden'} aria-hidden={currentTab !== 'editor'}>
{stableUser && (
<RenderWorkflow
initialWorkflowName={workflow.name}
workflowId={workflow.id}
initialFlow={{
nodes: workflow.workflow_definition.nodes as FlowNode[],
edges: workflow.workflow_definition.edges as FlowEdge[],
viewport: { x: 0, y: 0, zoom: 0 }
}}
initialTemplateContextVariables={workflow.template_context_variables as Record<string, string> || {}}
initialWorkflowConfigurations={(workflow.workflow_configurations as WorkflowConfigurations) || DEFAULT_WORKFLOW_CONFIGURATIONS}
user={stableUser}
getAccessToken={stableGetAccessToken}
/>
)}
</div>
{/* Executions view */}
<div className={currentTab === 'executions' ? 'block' : 'hidden'} aria-hidden={currentTab !== 'executions'}>
<WorkflowLayout stickyTabs={stickyTabs} showFeaturesNav={false}>
<WorkflowExecutions workflowId={workflow.id} searchParams={searchParams} />
</WorkflowLayout>
</div>
</>
);
return stableUser ? (
<RenderWorkflow
initialWorkflowName={workflow.name}
workflowId={workflow.id}
initialFlow={{
nodes: workflow.workflow_definition.nodes as FlowNode[],
edges: workflow.workflow_definition.edges as FlowEdge[],
viewport: { x: 0, y: 0, zoom: 0 }
}}
initialTemplateContextVariables={workflow.template_context_variables as Record<string, string> || {}}
initialWorkflowConfigurations={(workflow.workflow_configurations as WorkflowConfigurations) || DEFAULT_WORKFLOW_CONFIGURATIONS}
user={stableUser}
getAccessToken={stableGetAccessToken}
/>
) : null;
}
}

View file

@ -101,10 +101,10 @@ const BrowserCall = ({ workflowId, workflowRunId, accessToken, initialContextVar
<CardContent>
{isCompleted && checkingForRecording ? (
<div className="flex flex-col items-center justify-center space-y-4 p-8">
<Loader2 className="h-8 w-8 animate-spin text-blue-600" />
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<div className="text-center space-y-2">
<p className="text-gray-700 font-medium">Processing your call</p>
<p className="text-sm text-gray-500">Fetching transcript and recording...</p>
<p className="text-foreground font-medium">Processing your call</p>
<p className="text-sm text-muted-foreground">Fetching transcript and recording...</p>
</div>
</div>
) : (

View file

@ -63,13 +63,12 @@ export const AudioControls = ({
return (
<div className="flex flex-col items-center justify-center space-y-4 p-8">
<div className="text-center space-y-2">
<p className="text-gray-700 font-medium">Audio permissions required</p>
<p className="text-sm text-gray-500">Click below to grant microphone access</p>
<p className="text-foreground font-medium">Audio permissions required</p>
<p className="text-sm text-muted-foreground">Click below to grant microphone access</p>
</div>
<Button
onClick={requestAudioPermissions}
size="lg"
className="bg-blue-600 hover:bg-blue-700 text-white"
>
<Mic className="h-5 w-5 mr-2" />
Grant Audio Permissions
@ -85,33 +84,33 @@ export const AudioControls = ({
<button
onClick={start}
disabled={isStarting}
className="group relative h-20 w-20 rounded-full bg-green-600 hover:bg-green-700 transition-all duration-200 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
className="group relative h-20 w-20 rounded-full bg-emerald-600 hover:bg-emerald-700 transition-all duration-200 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
aria-label="Start Call"
>
<div className="absolute inset-0 rounded-full bg-green-600 animate-ping opacity-25"></div>
<div className="absolute inset-0 rounded-full bg-emerald-600 animate-ping opacity-25"></div>
<div className="relative flex items-center justify-center h-full">
<Phone className="h-8 w-8 text-white" />
</div>
</button>
<p className="text-sm font-medium text-gray-700">Start Call</p>
<p className="text-sm font-medium text-foreground">Start Call</p>
</>
) : (
<>
<p className="text-sm text-gray-600">Call in progress</p>
<p className="text-sm text-muted-foreground">Call in progress</p>
<button
onClick={stop}
className="group relative h-20 w-20 rounded-full bg-red-600 hover:bg-red-700 transition-all duration-200 shadow-lg hover:shadow-xl"
className="group relative h-20 w-20 rounded-full bg-destructive hover:bg-destructive/90 transition-all duration-200 shadow-lg hover:shadow-xl"
aria-label="End Call"
>
<div className="relative flex items-center justify-center h-full">
<PhoneOff className="h-8 w-8 text-white" />
<PhoneOff className="h-8 w-8 text-destructive-foreground" />
</div>
</button>
<p className="text-sm font-medium text-gray-700">End Call</p>
<p className="text-sm font-medium text-foreground">End Call</p>
</>
)}
{permissionError && (
<p className="text-sm text-red-500 text-center">{permissionError}</p>
<p className="text-sm text-destructive text-center">{permissionError}</p>
)}
</div>
);

View file

@ -27,11 +27,11 @@ export const ContextDisplay = ({ title, context }: ContextDisplayProps) => {
<CardContent className="space-y-3">
{Object.entries(context).map(([key, value]) => (
<div key={key} className="space-y-1">
<label className="text-sm font-medium text-gray-700">
<label className="text-sm font-medium text-muted-foreground">
{key}
</label>
<div className="p-3 bg-gray-50 border rounded-md">
<p className="text-sm text-gray-900 whitespace-pre-wrap">
<div className="p-3 bg-muted border rounded-md">
<p className="text-sm whitespace-pre-wrap">
{typeof value === 'object' && value !== null ? JSON.stringify(value, null, 2) : (value || 'No value')}
</p>
</div>

View file

@ -49,9 +49,9 @@ export const ContextVariablesSection = ({
<div className="space-y-2">
<Label className="text-sm font-medium">Current Variables</Label>
{Object.entries(initialContext).map(([key, value]) => (
<div key={key} className="flex items-center gap-2 p-3 border rounded-md bg-gray-50">
<div key={key} className="flex items-center gap-2 p-3 border rounded-md bg-muted">
<div className="flex-1">
<Label className="text-xs text-gray-600">{key}</Label>
<Label className="text-xs text-muted-foreground">{key}</Label>
<Input
value={value}
onChange={(e) => handleUpdateContextVar(key, e.target.value)}

View file

@ -84,7 +84,7 @@ export default function WorkflowRunPage() {
if (isLoading) {
returnValue = (
<div className="min-h-screen flex mt-40 justify-center">
<div className="h-full flex items-center justify-center">
<div className="w-full max-w-4xl p-6">
<Card>
<CardHeader>
@ -106,14 +106,14 @@ export default function WorkflowRunPage() {
}
else if (workflowRun?.is_completed) {
returnValue = (
<div className="min-h-screen flex mt-40 justify-center p-6">
<div className="h-full flex items-center justify-center p-6">
<div className="w-full max-w-4xl space-y-6">
<Card className="border-gray-100">
<Card className="border-border">
<CardHeader className="flex flex-row items-center justify-between">
<div className="flex items-center gap-4">
<CardTitle className="text-2xl">Agent Run Completed</CardTitle>
<div className="h-8 w-8 bg-green-100 rounded-full flex items-center justify-center">
<svg className="h-5 w-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div className="h-8 w-8 bg-emerald-500/20 rounded-full flex items-center justify-center">
<svg className="h-5 w-5 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
</svg>
</div>
@ -137,11 +137,11 @@ export default function WorkflowRunPage() {
</Link>
</CardHeader>
<CardContent>
<p className="text-gray-600 mb-8">Your voice agent run has been completed successfully. You can preview or download the transcript and recording.</p>
<p className="text-muted-foreground mb-8">Your voice agent run has been completed successfully. You can preview or download the transcript and recording.</p>
<div className="flex flex-wrap gap-4">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">Preview:</span>
<span className="text-sm text-muted-foreground">Preview:</span>
<MediaPreviewButtons
recordingUrl={workflowRun?.recording_url}
transcriptUrl={workflowRun?.transcript_url}
@ -150,8 +150,8 @@ export default function WorkflowRunPage() {
onOpenTranscript={openTranscriptModal}
/>
</div>
<div className="flex items-center gap-2 border-l pl-4">
<span className="text-sm text-gray-600">Download:</span>
<div className="flex items-center gap-2 border-l border-border pl-4">
<span className="text-sm text-muted-foreground">Download:</span>
<Button
onClick={() => downloadFile(workflowRun?.transcript_url, accessToken!)}
disabled={!workflowRun?.transcript_url || !accessToken}
@ -191,7 +191,7 @@ export default function WorkflowRunPage() {
}
else {
returnValue =
<div className="min-h-screen mt-40">
<div className="h-full flex items-center justify-center">
<BrowserCall
workflowId={Number(params.workflowId)}
workflowRunId={Number(params.runId)}

View file

@ -1,19 +1,20 @@
"use client";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { useParams, useSearchParams } from "next/navigation";
import WorkflowLayout from "../../WorkflowLayout";
import { WorkflowExecutions } from "../components/WorkflowExecutions";
export default function WorkflowRunsPage() {
const { workflowId } = useParams();
const router = useRouter();
const searchParams = useSearchParams();
// Redirect to main workflow page with executions tab
useEffect(() => {
const params = new URLSearchParams(searchParams.toString());
params.set('tab', 'executions');
router.replace(`/workflow/${workflowId}?${params.toString()}`);
}, [workflowId, router, searchParams]);
return null;
return (
<WorkflowLayout showFeaturesNav={false}>
<WorkflowExecutions
workflowId={Number(workflowId)}
searchParams={searchParams}
/>
</WorkflowLayout>
);
}

View file

@ -10,7 +10,10 @@ export const layoutNodes = (
rfInstance: React.RefObject<ReactFlowInstance<FlowNode, FlowEdge> | null>
) => {
const g = new dagre.graphlib.Graph();
g.setGraph({ rankdir, nodesep: 250, ranksep: 250 });
// For TB (top-to-bottom) layout:
// - nodesep: horizontal spacing between nodes at the same depth level
// - ranksep: vertical spacing between depth levels
g.setGraph({ rankdir, nodesep: 400, ranksep: 300 });
g.setDefaultEdgeLabel(() => ({}));
// Sort nodes so startCall nodes come first and endCall nodes come last
@ -22,8 +25,10 @@ export const layoutNodes = (
return 0;
});
// Use larger node dimensions to account for actual rendered size
// This prevents overlapping when dagre calculates positions
sortedNodes.forEach((node) => {
g.setNode(node.id, { width: 180, height: 60 });
g.setNode(node.id, { width: 350, height: 120 });
});
edges.forEach((edge) => {
@ -32,11 +37,45 @@ export const layoutNodes = (
dagre.layout(g);
// Group nodes by their Y position (rank/depth level)
const nodesByRank = new Map<number, { node: FlowNode; dagreNode: dagre.Node }[]>();
sortedNodes.forEach((node) => {
const dagreNode = g.node(node.id);
const rankY = Math.round(dagreNode.y / 50) * 50; // Round to group nearby Y values
if (!nodesByRank.has(rankY)) {
nodesByRank.set(rankY, []);
}
nodesByRank.get(rankY)!.push({ node, dagreNode });
});
// Calculate horizontal offset for zigzag pattern
// Nodes at each rank level get staggered left/right
const horizontalStagger = 600; // How much to offset alternating ranks
const ranks = Array.from(nodesByRank.keys()).sort((a, b) => a - b);
const newNodes = sortedNodes.map((node) => {
const nodeWithPosition = g.node(node.id);
const dagreNode = g.node(node.id);
const rankY = Math.round(dagreNode.y / 50) * 50;
const rankIndex = ranks.indexOf(rankY);
const nodesAtRank = nodesByRank.get(rankY)!;
let xOffset = 0;
// Apply zigzag pattern: alternate ranks offset left/right
// But only if there's a single node at this rank (linear chain)
if (nodesAtRank.length === 1) {
// Skip startCall (keep centered) and endCall (keep centered)
if (node.type !== 'startCall' && node.type !== 'endCall' && node.type !== 'global') {
xOffset = (rankIndex % 2 === 0) ? -horizontalStagger : horizontalStagger;
}
}
return {
...node,
position: { x: nodeWithPosition.x, y: nodeWithPosition.y }
position: {
x: dagreNode.x + xOffset,
y: dagreNode.y
}
};
});

View file

@ -0,0 +1,253 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { createWorkflowFromTemplateApiV1WorkflowCreateTemplatePost, createWorkflowRunApiV1WorkflowWorkflowIdRunsPost } from '@/client/sdk.gen';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
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();
const { user, getAccessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showSuccessModal, setShowSuccessModal] = useState(false);
const [workflowId, setWorkflowId] = useState<string | null>(null);
const [callType, setCallType] = useState<'INBOUND' | 'OUTBOUND'>('INBOUND');
const [useCase, setUseCase] = useState('');
const [activityDescription, setActivityDescription] = useState('');
const handleCreateWorkflow = async () => {
if (!useCase || !activityDescription) {
setError('Please fill in all fields');
return;
}
if (!user) {
setError('You must be logged in to create a workflow');
return;
}
setIsLoading(true);
setError(null);
try {
const accessToken = await getAccessToken();
// Call the API to create workflow from template
const response = await createWorkflowFromTemplateApiV1WorkflowCreateTemplatePost({
body: {
call_type: callType,
use_case: useCase,
activity_description: activityDescription,
},
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
if (response.data?.id) {
setWorkflowId(String(response.data.id));
setShowSuccessModal(true);
}
} catch (err) {
setError('Failed to create workflow. Please try again.');
logger.error(`Error creating workflow: ${err}`);
} finally {
setIsLoading(false);
}
};
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}`);
}
};
return (
<div className="min-h-screen bg-background">
<div className="container mx-auto px-4 py-8 max-w-2xl">
<div className="mb-6">
<h1 className="text-3xl font-bold mb-2">Create Voice Agent</h1>
<p className="text-muted-foreground">
Tell us about your use case and we&apos;ll create a customized voice agent for you
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Agent Details</CardTitle>
<CardDescription>
Configure your voice agent settings
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor="call-type">Call Type</Label>
<Select value={callType} onValueChange={(value) => setCallType(value as 'INBOUND' | 'OUTBOUND')}>
<SelectTrigger id="call-type">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="INBOUND">
Inbound (Users call AI)
</SelectItem>
<SelectItem value="OUTBOUND">
Outbound (AI calls users)
</SelectItem>
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
Choose whether users will call your AI or your AI will call users
</p>
</div>
<div className="space-y-2">
<Label htmlFor="use-case">Use Case</Label>
<Input
id="use-case"
placeholder="e.g., Lead Qualification, HR Screening, Customer Support"
value={useCase}
onChange={(e) => setUseCase(e.target.value)}
/>
<p className="text-sm text-muted-foreground">
Describe the primary purpose of your voice agent
</p>
</div>
<div className="space-y-2">
<Label htmlFor="activity-description">Activity Description</Label>
<Textarea
id="activity-description"
placeholder="Describe briefly what your voice agent will do (e.g., Qualify leads for real estate, Screen candidates for roles, Handle customer support). This will be a prompt to an LLM."
value={activityDescription}
onChange={(e) => setActivityDescription(e.target.value)}
className="min-h-[100px]"
/>
<p className="text-sm text-muted-foreground">
This description will be used to generate the AI prompt for your voice agent
</p>
</div>
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
<div className="pt-4">
<Button
onClick={handleCreateWorkflow}
disabled={isLoading || !useCase || !activityDescription}
className="w-full"
>
{isLoading ? 'Creating...' : 'Create Agent'}
</Button>
</div>
</CardContent>
</Card>
</div>
{/* Loading Overlay */}
{isLoading && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<Card className="w-full max-w-md p-8">
<div className="flex flex-col items-center space-y-6">
{/* Animated spinner */}
<div className="relative">
<div className="w-16 h-16 border-4 border-muted rounded-full"></div>
<div className="absolute top-0 left-0 w-16 h-16 border-4 border-transparent border-t-primary rounded-full animate-spin"></div>
</div>
<div className="text-center space-y-2">
<h3 className="text-lg font-semibold">
Creating Your Workflow
</h3>
<p className="text-sm text-muted-foreground max-w-xs">
We&apos;re setting up your voice agent with your specifications. This will just take a moment...
</p>
</div>
</div>
</Card>
</div>
)}
{/* Success Modal */}
<Dialog open={showSuccessModal} onOpenChange={setShowSuccessModal}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Workflow Created Successfully!
</DialogTitle>
<DialogDescription asChild>
<div className="mt-4 space-y-3">
<p>
A voice agent workflow has been generated for your use case, with some artificial data and sample actions.
</p>
<p>
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.
</p>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-6">
<Button
onClick={handleModalContinue}
className="w-full"
>
Start Web Call
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -1,11 +1,7 @@
import { Settings } from 'lucide-react';
import Link from 'next/link';
import { Suspense } from 'react';
import { getWorkflowsApiV1WorkflowFetchGet, getWorkflowTemplatesApiV1WorkflowTemplatesGet } from '@/client/sdk.gen';
import { Button } from '@/components/ui/button';
import { getWorkflowsApiV1WorkflowFetchGet } from '@/client/sdk.gen';
import { CreateWorkflowButton } from "@/components/workflow/CreateWorkflowButton";
import { DuplicateWorkflowTemplate } from "@/components/workflow/TemplateCard";
import { UploadWorkflowButton } from '@/components/workflow/UploadWorkflowButton';
import { WorkflowTable } from "@/components/workflow/WorkflowTable";
import { getServerAccessToken, getServerAuthProvider } from '@/lib/auth/server';
@ -15,42 +11,6 @@ import WorkflowLayout from "./WorkflowLayout";
export const dynamic = 'force-dynamic';
// Server component for workflow templates
async function WorkflowTemplatesList() {
try {
const response = await getWorkflowTemplatesApiV1WorkflowTemplatesGet();
// Log request URL if available
if (response.request?.url) {
logger.info(`Template Request URL: ${response.request.url}`);
}
const templates = response.data || [];
// Get access token on server side to pass to client component
const accessToken = await getServerAccessToken();
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{templates.map((template) => (
<DuplicateWorkflowTemplate
key={template.id}
id={template.id}
title={template.template_name}
description={template.template_description}
serverAccessToken={accessToken}
/>
))}
</div>
);
} catch (err) {
logger.error(`Error fetching workflow templates: ${err}`);
return (
<div className="text-red-500">
Failed to load Workflow Templates. Please Try Again Later.
</div>
);
}
}
// Server component for workflow list
async function WorkflowList() {
const authProvider = getServerAuthProvider();
@ -99,11 +59,11 @@ async function WorkflowList() {
<>
{/* Active Workflows Section */}
<div className="mb-8">
<h2 className="text-xl font-semibold mb-4">Active Workflows</h2>
<h2 className="text-xl font-semibold mb-4">Active Agents</h2>
{activeWorkflows.length > 0 ? (
<WorkflowTable workflows={activeWorkflows} showArchived={false} />
) : (
<div className="text-gray-500 bg-gray-50 rounded-lg p-8 text-center">
<div className="text-muted-foreground bg-muted rounded-lg p-8 text-center">
No active workflows found. Create your first workflow to get started.
</div>
)}
@ -112,7 +72,7 @@ async function WorkflowList() {
{/* Archived Workflows Section */}
{archivedWorkflows.length > 0 && (
<div className="mb-8">
<h2 className="text-xl font-semibold mb-4 text-gray-600">Archived Workflows</h2>
<h2 className="text-xl font-semibold mb-4 text-muted-foreground">Archived Workflows</h2>
<WorkflowTable workflows={archivedWorkflows} showArchived={true} />
</div>
)}
@ -134,41 +94,10 @@ async function PageContent() {
return (
<div className="container mx-auto px-4 py-8">
{/* Get Started Section */}
<div className="mb-12">
<div className="flex justify-between items-center px-4">
<h2 className="text-2xl font-bold mb-6">Get Started</h2>
<div className="flex gap-2">
<Link href="/service-configurations">
<Button className="flex items-center gap-2 mb-6">
<Settings size={16} />
Configure Services
</Button>
</Link>
<Link href="/integrations">
<Button className="flex items-center gap-2 mb-6">
<Settings size={16} />
Integrations
</Button>
</Link>
</div>
</div>
<Suspense fallback={
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 3 }, (_, i) => (
<div key={i} className="bg-gray-200 rounded-lg h-40"></div>
))}
</div>
}>
<WorkflowTemplatesList />
</Suspense>
</div>
{/* Your Workflows Section */}
<div className="mb-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Your Workflows</h1>
<h1 className="text-2xl font-bold">Your Agents</h1>
<div className="flex gap-2">
<UploadWorkflowButton />
<CreateWorkflowButton />
@ -185,10 +114,10 @@ function WorkflowsLoading() {
<div className="container mx-auto px-4 py-8">
{/* Get Started Section Loading */}
<div className="mb-12">
<div className="h-8 w-48 bg-gray-200 rounded mb-6"></div>
<div className="h-8 w-48 bg-muted rounded mb-6"></div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 3 }, (_, i) => (
<div key={i} className="bg-gray-200 rounded-lg h-40"></div>
<div key={i} className="bg-muted rounded-lg h-40"></div>
))}
</div>
</div>
@ -196,10 +125,10 @@ function WorkflowsLoading() {
{/* Your Workflows Section Loading */}
<div className="mb-6">
<div className="flex justify-between items-center mb-6">
<div className="h-8 w-48 bg-gray-200 rounded"></div>
<div className="h-10 w-32 bg-gray-200 rounded"></div>
<div className="h-8 w-48 bg-muted rounded"></div>
<div className="h-10 w-32 bg-muted rounded"></div>
</div>
<div className="bg-gray-200 rounded-lg h-96"></div>
<div className="bg-muted rounded-lg h-96"></div>
</div>
</div>
);

View file

@ -1,8 +1,9 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { ClientOptions } from './types.gen';
import { type Config, type ClientOptions as DefaultClientOptions, createClient, createConfig } from '@hey-api/client-fetch';
import { type ClientOptions as DefaultClientOptions, type Config, createClient, createConfig } from '@hey-api/client-fetch';
import { createClientConfig } from '../lib/apiClient';
import type { ClientOptions } from './types.gen';
/**
* The `createClientConfig()` function will be called on client initialization
@ -16,4 +17,4 @@ export type CreateClientConfig<T extends DefaultClientOptions = ClientOptions> =
export const client = createClient(createClientConfig(createConfig<ClientOptions>({
baseUrl: 'http://127.0.0.1:8000'
})));
})));

View file

@ -1,3 +1,3 @@
// This file is auto-generated by @hey-api/openapi-ts
export * from './sdk.gen';
export * from './types.gen';
export * from './sdk.gen';

File diff suppressed because one or more lines are too long

View file

@ -3274,4 +3274,4 @@ export type HealthApiV1HealthGetResponses = {
export type ClientOptions = {
baseUrl: 'http://127.0.0.1:8000' | (string & {});
};
};

View file

@ -5,10 +5,11 @@ import { useForm } from "react-hook-form";
import { getDefaultConfigurationsApiV1UserConfigurationsDefaultsGet } from '@/client/sdk.gen';
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useUserConfig } from "@/context/UserConfigContext";
type ServiceSegment = "llm" | "tts" | "stt";
@ -33,6 +34,12 @@ interface FormValues {
[key: string]: string | number | boolean;
}
const TAB_CONFIG: { key: ServiceSegment; label: string }[] = [
{ key: "llm", label: "LLM" },
{ key: "tts", label: "Voice" },
{ key: "stt", label: "Transcriber" },
];
export default function ServiceConfiguration() {
const [apiError, setApiError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
@ -80,28 +87,6 @@ export default function ServiceConfiguration() {
};
const setServicePropertyValues = (service: ServiceSegment) => {
/*
sets service properties like api_key, model etc. from default configurations
if not present in user configurations
service - llm/ tts/ stt
userConfig['llm'] = {
provider: 'openai',
api_key: 'sk-...'
}
response.data.llm = {
openai: {
properties: {
provider: 'openai'
api_key: 'sk-...'
}
}
}
*/
if (userConfig?.[service]?.provider) {
Object.entries(userConfig?.[service]).forEach(([field, value]) => {
if (field !== "provider") {
@ -110,8 +95,6 @@ export default function ServiceConfiguration() {
});
selectedProviders[service] = userConfig?.[service]?.provider as string;
} else {
// response.data['service'] will all providers for the given service
// selectedProviders[service] will have the provider name
const properties = response.data[service]?.[selectedProviders[service]]?.properties as Record<string, SchemaProperty>;
if (properties) {
Object.entries(properties).forEach(([field, schema]) => {
@ -135,10 +118,6 @@ export default function ServiceConfiguration() {
}, [reset, userConfig]);
const handleProviderChange = (service: ServiceSegment, providerName: string) => {
/*
service can be llm/ tts/ stt
providerName is openAI/ Deepgram etc.
*/
if (!providerName) {
return;
}
@ -170,10 +149,6 @@ export default function ServiceConfiguration() {
const onSubmit = async (data: FormValues) => {
/*
data contains form values like llm_api_key: "sk...", llm_model: "gpt-4o" etc.
extract the values in relevant form
*/
setApiError(null);
setIsSaving(true);
@ -197,7 +172,7 @@ export default function ServiceConfiguration() {
Object.entries(data).forEach(([property, value]) => {
const parts = property.split('_');
const service = parts[0] as ServiceSegment;
const field = parts.slice(1).join('_'); // Join all parts after the service name
const field = parts.slice(1).join('_');
if (userConfig[service] && !(field in userConfig[service])) {
(userConfig[service] as Record<string, string>)[field] = value as string;
@ -222,116 +197,166 @@ export default function ServiceConfiguration() {
}
};
const renderServiceSegmentFields = (service: ServiceSegment) => {
// Segment is segments like llm, tts and stt
const getConfigFields = (service: ServiceSegment): string[] => {
const currentProvider = serviceProviders[service];
const providerSchema = schemas?.[service]?.[currentProvider];
const availableProviders = schemas?.[service] ? Object.keys(schemas[service]) : [];
if (!providerSchema) return [];
return (
<Card className="mb-6">
<CardHeader>
<CardTitle>{service.toUpperCase()} Configuration</CardTitle>
<CardDescription>
Configure your {service.toUpperCase()} service
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="space-y-2">
<Label>Provider</Label>
<Select
value={currentProvider}
onValueChange={(providerName) => {
handleProviderChange(service, providerName);
}}
>
<SelectTrigger>
<SelectValue placeholder={`Select ${service.toUpperCase()} provider`} />
</SelectTrigger>
<SelectContent>
{availableProviders.map((provider) => (
<SelectItem key={provider} value={provider}>
{provider}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{currentProvider && providerSchema && (
<div className="space-y-4">
{Object.entries(providerSchema.properties).map(([field, schema]: [string, SchemaProperty]) => {
// Handle $ref fields by getting the actual schema from $defs
const actualSchema = schema.$ref && providerSchema.$defs
? providerSchema.$defs[schema.$ref.split('/').pop() || '']
: schema;
// Skip provider field as it's handled separately
return field !== "provider" && (
<div key={`${service}_${field}_${currentProvider}`} className="space-y-2">
<Label>{field}</Label>
{actualSchema?.enum ? (
<Select
value={watch(`${service}_${field}`) as string || ""}
onValueChange={(value) => {
setValue(`${service}_${field}`, value, { shouldDirty: true });
}}
>
<SelectTrigger>
<SelectValue placeholder={`Select ${field}`} />
</SelectTrigger>
<SelectContent>
{actualSchema.enum.map((value: string) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
type={actualSchema?.type === "number" ? "number" : "text"}
{...(actualSchema?.type === "number" && { step: "any" })}
placeholder={`Enter ${field}`}
{...register(`${service}_${field}`, {
required: providerSchema.required?.includes(field),
valueAsNumber: actualSchema?.type === "number"
})}
/>
)}
{errors[`${service}_${field}`] && (
<p className="text-sm text-red-500">
{typeof errors[`${service}_${field}`]?.message === 'string'
? String(errors[`${service}_${field}`]?.message)
: "This field is required"}
</p>
)}
</div>
);
})}
</div>
)}
</div>
</CardContent>
</Card>
// Find all config fields (not provider, not api_key)
return Object.keys(providerSchema.properties).filter(
field => field !== "provider" && field !== "api_key"
);
};
const renderServiceFields = (service: ServiceSegment) => {
const currentProvider = serviceProviders[service];
const providerSchema = schemas?.[service]?.[currentProvider];
const availableProviders = schemas?.[service] ? Object.keys(schemas[service]) : [];
const configFields = getConfigFields(service);
return (
<div className="space-y-6">
{/* Provider and first config field in one row */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Provider</Label>
<Select
value={currentProvider}
onValueChange={(providerName) => {
handleProviderChange(service, providerName);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select provider" />
</SelectTrigger>
<SelectContent>
{availableProviders.map((provider) => (
<SelectItem key={provider} value={provider}>
{provider}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{currentProvider && providerSchema && configFields[0] && (
<div className="space-y-2">
<Label className="capitalize">{configFields[0].replace(/_/g, ' ')}</Label>
{renderField(service, configFields[0], providerSchema)}
</div>
)}
</div>
{/* Additional config fields (like voice for TTS) */}
{currentProvider && providerSchema && configFields.length > 1 && (
<div className="grid grid-cols-2 gap-4">
{configFields.slice(1).map((field) => (
<div key={field} className="space-y-2">
<Label className="capitalize">{field.replace(/_/g, ' ')}</Label>
{renderField(service, field, providerSchema)}
</div>
))}
</div>
)}
{/* API Key in bottom row */}
{currentProvider && providerSchema && providerSchema.properties.api_key && (
<div className="space-y-2">
<Label>API Key</Label>
<Input
type="password"
placeholder="Enter API key"
{...register(`${service}_api_key`, {
required: providerSchema.required?.includes("api_key"),
})}
/>
{errors[`${service}_api_key`] && (
<p className="text-sm text-red-500">
{typeof errors[`${service}_api_key`]?.message === 'string'
? String(errors[`${service}_api_key`]?.message)
: "This field is required"}
</p>
)}
</div>
)}
</div>
);
};
const renderField = (service: ServiceSegment, field: string, providerSchema: ProviderSchema) => {
const schema = providerSchema.properties[field];
const actualSchema = schema.$ref && providerSchema.$defs
? providerSchema.$defs[schema.$ref.split('/').pop() || '']
: schema;
if (actualSchema?.enum) {
return (
<Select
value={watch(`${service}_${field}`) as string || ""}
onValueChange={(value) => {
setValue(`${service}_${field}`, value, { shouldDirty: true });
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={`Select ${field}`} />
</SelectTrigger>
<SelectContent>
{actualSchema.enum.map((value: string) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
return (
<Input
type={actualSchema?.type === "number" ? "number" : "text"}
{...(actualSchema?.type === "number" && { step: "any" })}
placeholder={`Enter ${field}`}
{...register(`${service}_${field}`, {
required: providerSchema.required?.includes(field),
valueAsNumber: actualSchema?.type === "number"
})}
/>
);
};
return (
<div className="w-full max-w-4xl mx-auto py-8">
<h1 className="text-2xl font-bold mb-6">Service Configuration</h1>
<div className="w-full max-w-2xl mx-auto">
<div className="mb-6">
<h1 className="text-3xl font-bold mb-2">AI Models Configuration</h1>
<p className="text-muted-foreground">
Configure your AI model, voice, and transcription services.
</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{renderServiceSegmentFields("llm")}
{renderServiceSegmentFields("tts")}
{renderServiceSegmentFields("stt")}
<form onSubmit={handleSubmit(onSubmit)}>
<Card>
<CardContent className="pt-6">
<Tabs defaultValue="llm" className="w-full">
<TabsList className="grid w-full grid-cols-3 mb-6">
{TAB_CONFIG.map(({ key, label }) => (
<TabsTrigger key={key} value={key}>
{label}
</TabsTrigger>
))}
</TabsList>
{apiError && <p className="text-red-500">{apiError}</p>}
{TAB_CONFIG.map(({ key }) => (
<TabsContent key={key} value={key} className="mt-0">
{renderServiceFields(key)}
</TabsContent>
))}
</Tabs>
</CardContent>
</Card>
<Button type="submit" className="w-full" disabled={isSaving}>
{apiError && <p className="text-red-500 mt-4">{apiError}</p>}
<Button type="submit" className="w-full mt-6" disabled={isSaving}>
{isSaving ? "Saving..." : "Save Configuration"}
</Button>
</form>

View file

@ -1,17 +1,31 @@
"use client";
import { Moon,Sun } from "lucide-react";
import { Moon, Sun } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export default function ThemeToggle() {
const [theme, setTheme] = useState("light");
interface ThemeToggleProps {
className?: string;
showLabel?: boolean;
variant?: "ghost" | "outline" | "default";
size?: "default" | "sm" | "lg" | "icon";
}
export default function ThemeToggle({
className,
showLabel = false,
variant = "ghost",
size = "icon"
}: ThemeToggleProps) {
// Start with null to avoid hydration mismatch - theme is set by inline script in layout.tsx
const [theme, setTheme] = useState<"light" | "dark" | null>(null);
useEffect(() => {
const storedTheme = localStorage.getItem("theme") || "light";
setTheme(storedTheme);
document.documentElement.classList.toggle("dark", storedTheme === "dark");
// Read the current theme from the DOM (already set by inline script in layout.tsx)
const isDark = document.documentElement.classList.contains("dark");
setTheme(isDark ? "dark" : "light");
}, []);
const toggleTheme = () => {
@ -22,8 +36,27 @@ export default function ThemeToggle() {
};
return (
<Button variant="outline" className="absolute top-4 right-4" onClick={toggleTheme}>
{theme === "light" ? <Moon className="h-5 w-5" /> : <Sun className="h-5 w-5" />}
<Button
variant={variant}
size={size}
className={cn(
showLabel && "w-full justify-start",
className
)}
onClick={toggleTheme}
>
<Sun className={cn(
"h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0",
showLabel && "absolute"
)} />
<Moon className={cn(
"h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100",
!showLabel && "absolute"
)} />
{showLabel && theme && (
<span className="ml-2">{theme === "light" ? "Light" : "Dark"} Mode</span>
)}
<span className="sr-only">Toggle theme</span>
</Button>
);
}

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

View file

@ -1,162 +0,0 @@
"use client";
import { CircleDollarSign, HelpCircle,Star } from 'lucide-react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import React from 'react';
import { useUserConfig } from '@/context/UserConfigContext';
import { useAuth } from '@/lib/auth';
// Conditionally load Stack components only when using Stack auth
const StackUserButton = React.lazy(() =>
import('@stackframe/stack').then(mod => ({ default: mod.UserButton }))
);
const StackTeamSwitcher = React.lazy(() =>
import('@stackframe/stack').then(mod => ({ default: mod.SelectedTeamSwitcher }))
);
interface BaseHeaderProps {
headerActions?: React.ReactNode,
backButton?: React.ReactNode,
showFeaturesNav?: boolean
}
export default function BaseHeader({ headerActions, backButton, showFeaturesNav = true }: BaseHeaderProps) {
const { permissions } = useUserConfig();
const { provider, user } = useAuth();
const pathname = usePathname();
const router = useRouter();
const isActive = (path: string) => {
return pathname.startsWith(path);
};
const hasAdminPermission = Array.isArray(permissions) && permissions.some(p => p.id === 'admin');
return (
<header className="sticky top-0 z-50 w-full border-b border-gray-200 bg-white">
<div className="container mx-auto px-4 py-4">
<nav className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/" className="text-xl font-semibold text-gray-800 hover:text-gray-600">
Dograh
</Link>
{backButton}
{showFeaturesNav && (
<div className="flex items-center gap-4 ml-8">
{hasAdminPermission && (
<>
<Link
href="/workflow"
className={`text-sm font-medium transition-colors hover:text-primary ${isActive('/workflow') ? 'text-primary' : 'text-gray-600'
}`}
>
Workflows
</Link>
<Link
href="/campaigns"
className={`text-sm font-medium transition-colors hover:text-primary ${isActive('/campaigns') ? 'text-primary' : 'text-gray-600'
}`}
>
Campaigns
</Link>
<Link
href="/automation"
className={`text-sm font-medium transition-colors hover:text-primary ${isActive('/automation') ? 'text-primary' : 'text-gray-600'
}`}
>
Automation
</Link>
<Link
href="/looptalk"
className={`text-sm font-medium transition-colors hover:text-primary ${isActive('/looptalk') ? 'text-primary' : 'text-gray-600'
}`}
>
LoopTalk
</Link>
</>
)}
<Link
href="/usage"
className={`text-sm font-medium transition-colors hover:text-primary ${isActive('/usage') ? 'text-primary' : 'text-gray-600'
}`}
>
Usage
</Link>
<Link
href="/reports"
className={`text-sm font-medium transition-colors hover:text-primary ${isActive('/reports') ? 'text-primary' : 'text-gray-600'
}`}
>
Reports
</Link>
<Link
href="/api-keys"
className={`text-sm font-medium transition-colors hover:text-primary ${isActive('/api-keys') ? 'text-primary' : 'text-gray-600'
}`}
>
Developers
</Link>
</div>
)}
</div>
<div className="flex-1 flex justify-center">
{headerActions}
</div>
{/* Use key to force remount when user changes to avoid hooks issues */}
<div className="flex items-center gap-5" key={user ? 'logged-in' : 'logged-out'}>
{provider === 'stack' ? (
<React.Suspense fallback={
<div className="flex items-center gap-5">
{/* Match StackTeamSwitcher's internal skeleton */}
<div className="h-9 w-40 animate-pulse bg-gray-100 rounded" />
{/* Match StackUserButton dimensions: h-[34px] w-[34px] */}
<div className="h-[34px] w-[34px] animate-pulse bg-gray-100 rounded-full" />
</div>
}>
<div className="w-40 shrink-0">
<StackTeamSwitcher
onChange={() => {
router.refresh();
}}
/>
</div>
<StackUserButton
extraItems={[{
text: 'Usage',
icon: <CircleDollarSign strokeWidth={2} size={16} />,
onClick: () => router.push('/usage')
}]}
/>
</React.Suspense>
) : (
<>
<a
href="https://github.com/dograh-hq/dograh/issues/new/choose"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
>
<HelpCircle className="w-4 h-4" />
Get Help
</a>
<a
href="https://github.com/dograh-hq/dograh"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
>
<Star className="w-4 h-4 fill-yellow-400 text-yellow-400" />
Star us on GitHub
</a>
</>
)}
</div>
</nav>
</div>
</header>
);
}

View file

@ -0,0 +1,72 @@
"use client";
import { usePathname } from "next/navigation";
import React, { ReactNode } from "react";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { AppSidebar } from "./AppSidebar";
interface AppLayoutProps {
children: ReactNode;
headerActions?: ReactNode;
stickyTabs?: ReactNode;
}
const AppLayout: React.FC<AppLayoutProps> = ({
children,
headerActions,
stickyTabs,
}) => {
const pathname = usePathname();
// Check if current route should have sidebar
// Hide sidebar for root (/) and /handler routes (Stack Auth routes)
const shouldShowSidebar = pathname !== "/" && !pathname.startsWith("/handler");
// Check if we're in workflow editor mode - collapse sidebar by default
const isWorkflowEditor = /^\/workflow\/\d+/.test(pathname);
// If no sidebar needed, just return children
if (!shouldShowSidebar) {
return <>{children}</>;
}
return (
<SidebarProvider defaultOpen={!isWorkflowEditor}>
<div className="flex h-screen w-full">
<AppSidebar />
<SidebarInset className="flex-1">
{/* Optional header area for specific pages */}
{headerActions && (
<header className="sticky top-0 z-50 w-full border-b bg-background">
<div className="container mx-auto px-4 py-4">
<div className="flex items-center justify-center">
{headerActions}
</div>
</div>
</header>
)}
{/* Optional sticky tabs */}
{stickyTabs && (
<div className="sticky top-0 z-40 bg-[#2a2e39] border-b border-gray-700">
<div className="container mx-auto px-4">
<div className="flex items-center justify-center py-2">
{stickyTabs}
</div>
</div>
</div>
)}
{/* Main content area */}
<main className="flex-1 overflow-auto">
{children}
</main>
</SidebarInset>
</div>
</SidebarProvider>
);
};
export default AppLayout;

View file

@ -0,0 +1,440 @@
"use client";
import type { Team } from "@stackframe/stack";
import {
Brain,
ChevronLeft,
ChevronRight,
CircleDollarSign,
FileText,
HelpCircle,
Home,
Key,
Megaphone,
MessageSquare,
Phone,
Star,
TrendingUp,
Workflow,
Zap,
} from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import React from "react";
import { Button } from "@/components/ui/button";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
SidebarTrigger,
useSidebar,
} from "@/components/ui/sidebar";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useAuth } from "@/lib/auth";
import { cn } from "@/lib/utils";
// Conditionally load Stack components only when using Stack auth
const StackUserButton = React.lazy(() =>
import("@stackframe/stack").then((mod) => ({ default: mod.UserButton }))
);
// Lazy load SelectedTeamSwitcher - we'll pass selectedTeam from our context
const StackTeamSwitcher = React.lazy(() =>
import("@stackframe/stack").then((mod) => ({
default: mod.SelectedTeamSwitcher,
}))
);
export function AppSidebar() {
const pathname = usePathname();
const router = useRouter();
const { state } = useSidebar();
const { provider, getSelectedTeam } = useAuth();
// Get selected team for Stack auth (cast to Team type from Stack)
const selectedTeam = provider === "stack" && getSelectedTeam ? getSelectedTeam() as Team | null : null;
const isActive = (path: string) => {
return pathname.startsWith(path);
};
// Organize navigation into sections
const overviewSection = [
{
title: "Overview",
url: "/overview",
icon: Home,
},
];
const buildSection = [
{
title: "Voice Agents",
url: "/workflow",
icon: Workflow,
},
{
title: "Campaigns",
url: "/campaigns",
icon: Megaphone,
},
{
title: "Automation",
url: "/automation",
icon: Zap,
},
{
title: "Models",
url: "/model-configurations",
icon: Brain,
},
{
title: "Telephony",
url: "/telephony-configurations",
icon: Phone,
},
// {
// title: "Integrations",
// url: "/integrations",
// icon: Plug,
// },
{
title: "Developers",
url: "/api-keys",
icon: Key,
},
];
const observeSection = [
{
title: "Usage",
url: "/usage",
icon: TrendingUp,
},
{
title: "Reports",
url: "/reports",
icon: FileText,
},
{
title: "LoopTalk",
url: "/looptalk",
icon: MessageSquare,
},
];
const SidebarLink = ({ item }: { item: typeof overviewSection[0] }) => {
const isItemActive = isActive(item.url);
const Icon = item.icon;
if (state === "collapsed") {
return (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<SidebarMenuButton
asChild
className={cn(
"hover:bg-accent hover:text-accent-foreground",
isItemActive && "bg-accent text-accent-foreground"
)}
>
<Link href={item.url}>
<Icon className="h-4 w-4" />
<span className="sr-only">{item.title}</span>
</Link>
</SidebarMenuButton>
</TooltipTrigger>
<TooltipContent side="right">
<p>{item.title}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return (
<SidebarMenuButton
asChild
className={cn(
"hover:bg-accent hover:text-accent-foreground",
isItemActive && "bg-accent text-accent-foreground"
)}
>
<Link href={item.url}>
<Icon className="h-4 w-4" />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
);
};
return (
<Sidebar collapsible="icon" className="border-r">
<SidebarHeader className="border-b px-2 py-3">
<div className="flex items-center justify-between">
{/* Logo - only show when expanded */}
{state === "expanded" && (
<Link
href="/"
className="flex items-center gap-2 px-2 text-xl font-bold"
>
Dograh
</Link>
)}
{/* Toggle button - center it when collapsed */}
<SidebarTrigger className={cn(
"hover:bg-accent",
state === "collapsed" && "mx-auto"
)}>
{state === "expanded" ? (
<ChevronLeft className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</SidebarTrigger>
</div>
{/* Team Switcher for Stack Auth - at the top */}
{provider === "stack" && state === "expanded" && (
<div className="mt-3">
<React.Suspense
fallback={
<div className="h-9 w-full animate-pulse bg-muted rounded" />
}
>
<StackTeamSwitcher
selectedTeam={selectedTeam || undefined}
onChange={() => {
router.refresh();
}}
/>
</React.Suspense>
</div>
)}
{/* Star us on GitHub for OSS mode - at the top */}
{provider !== "stack" && (
<div className="mt-3 px-2">
{state === "collapsed" ? (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="w-full hover:bg-accent hover:text-accent-foreground"
asChild
>
<a
href="https://github.com/dograh-hq/dograh"
target="_blank"
rel="noopener noreferrer"
>
<Star className="h-4 w-4 fill-yellow-400 text-yellow-400" />
<span className="sr-only">Star us on GitHub</span>
</a>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Star us on GitHub</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Button
variant="ghost"
className="w-full justify-start hover:bg-accent hover:text-accent-foreground"
asChild
>
<a
href="https://github.com/dograh-hq/dograh"
target="_blank"
rel="noopener noreferrer"
>
<Star className="h-4 w-4 fill-yellow-400 text-yellow-400" />
<span className="ml-2">Star us on GitHub</span>
</a>
</Button>
)}
</div>
)}
</SidebarHeader>
<SidebarContent className={cn(
state === "collapsed" && "px-0"
)}>
{/* Overview Section */}
<SidebarGroup className="mt-2">
<SidebarMenu>
{overviewSection.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarLink item={item} />
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroup>
{/* BUILD Section */}
{buildSection.length > 0 && (
<SidebarGroup className="mt-6">
{state === "expanded" && (
<SidebarGroupLabel className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
BUILD
</SidebarGroupLabel>
)}
<SidebarMenu>
{buildSection.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarLink item={item} />
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroup>
)}
{/* OBSERVE Section */}
<SidebarGroup className="mt-6">
{state === "expanded" && (
<SidebarGroupLabel className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
OBSERVE
</SidebarGroupLabel>
)}
<SidebarMenu>
{observeSection.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarLink item={item} />
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className={cn(
"border-t p-4",
state === "collapsed" && "p-2"
)}>
{/* Bottom Actions */}
<div className="space-y-2">
{/* Get Help - for OSS mode */}
{provider !== "stack" && (
<>
{state === "collapsed" ? (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="w-full hover:bg-accent hover:text-accent-foreground"
asChild
>
<a
href="https://github.com/dograh-hq/dograh/issues/new/choose"
target="_blank"
rel="noopener noreferrer"
>
<HelpCircle className="h-4 w-4" />
<span className="sr-only">Get Help</span>
</a>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Get Help</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Button
variant="ghost"
className="w-full justify-start hover:bg-accent hover:text-accent-foreground"
asChild
>
<a
href="https://github.com/dograh-hq/dograh/issues/new/choose"
target="_blank"
rel="noopener noreferrer"
>
<HelpCircle className="h-4 w-4" />
<span className="ml-2">Get Help</span>
</a>
</Button>
)}
</>
)}
{/* User Button for Stack Auth - at the bottom */}
{provider === "stack" && (
<React.Suspense
fallback={
<div className={cn(
"animate-pulse bg-muted rounded",
state === "collapsed" ? "h-8 w-8" : "h-[34px] w-[34px]"
)} />
}
>
<div className={cn(
"flex",
state === "collapsed" ? "justify-center" : "justify-start"
)}>
<StackUserButton
extraItems={[
{
text: "Usage",
icon: <CircleDollarSign strokeWidth={2} size={16} />,
onClick: () => router.push("/usage"),
},
]}
/>
</div>
</React.Suspense>
)}
{/* Theme Toggle - at the very bottom */}
{/* <div className={cn(
"mt-2 pt-2 border-t",
state === "collapsed" ? "flex justify-center" : ""
)}>
{state === "collapsed" ? (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<div>
<ThemeToggle
showLabel={false}
className="hover:bg-accent hover:text-accent-foreground"
/>
</div>
</TooltipTrigger>
<TooltipContent side="right">
<p>Toggle theme</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<ThemeToggle
showLabel={true}
className="hover:bg-accent hover:text-accent-foreground"
/>
)}
</div> */}
</div>
</SidebarFooter>
<SidebarRail />
</Sidebar>
);
}

View file

@ -0,0 +1,33 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleContent,CollapsibleTrigger }

View file

@ -8,7 +8,7 @@ import { Button } from "@/components/ui/button";
export function CreateWorkflowButton() {
const router = useRouter();
const handleClick = () => {
router.push('/create-workflow');
router.push('/workflow/create');
};
return (
@ -16,7 +16,7 @@ export function CreateWorkflowButton() {
onClick={handleClick}
>
<PlusIcon className="w-4 h-4" />
Create Workflow
Create Agent
</Button>
);
}

View file

@ -90,13 +90,13 @@ export function UploadWorkflowButton() {
variant="outline"
>
<Upload className="w-4 h-4 mr-2" />
Upload Workflow
Upload Agent Definition
</Button>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Upload Workflow</DialogTitle>
<DialogTitle>Upload Agent Definition</DialogTitle>
</DialogHeader>
<div
className={`mt-4 border-2 border-dashed rounded-lg p-8 text-center ${isDragging ? 'border-primary bg-primary/5' : 'border-gray-300'

View file

@ -79,11 +79,11 @@ export function WorkflowTable({ workflows, showArchived }: WorkflowTableProps) {
};
return (
<div className="bg-white border rounded-lg overflow-hidden shadow-sm">
<div className="bg-card border rounded-lg overflow-hidden shadow-sm">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="font-semibold">Workflow Name</TableHead>
<TableRow>
<TableHead className="font-semibold">Agent Name</TableHead>
<TableHead className="font-semibold">Created At</TableHead>
<TableHead className="font-semibold text-center">Total Runs</TableHead>
<TableHead className="font-semibold text-right">Actions</TableHead>
@ -93,7 +93,7 @@ export function WorkflowTable({ workflows, showArchived }: WorkflowTableProps) {
{workflows.map((workflow) => (
<TableRow
key={workflow.id}
className={`hover:bg-gray-50 transition-colors ${showArchived ? 'opacity-60' : ''}`}
className={`hover:bg-accent transition-colors ${showArchived ? 'opacity-60' : ''}`}
>
<TableCell className="font-medium">
{workflow.name}
@ -106,7 +106,7 @@ export function WorkflowTable({ workflows, showArchived }: WorkflowTableProps) {
})}
</TableCell>
<TableCell className="text-center">
<span className="inline-flex items-center justify-center min-w-[2rem] px-2 py-1 text-sm font-semibold bg-gray-100 rounded-full">
<span className="inline-flex items-center justify-center min-w-[2rem] px-2 py-1 text-sm font-semibold bg-muted rounded-full">
{workflow.total_runs || 0}
</span>
</TableCell>

View file

@ -1,6 +1,6 @@
'use client';
import { createContext, ReactNode, useCallback, useContext, useEffect, useState } from 'react';
import { createContext, ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react';
import { getUserConfigurationsApiV1UserConfigurationsUserGet, updateUserConfigurationsApiV1UserConfigurationsUserPut } from '@/client/sdk.gen';
import type { UserConfigurationRequestResponseSchema } from '@/client/types.gen';
@ -41,7 +41,7 @@ interface UserConfigContextType {
refreshConfig: () => Promise<void>;
permissions: TeamPermission[];
accessToken: string | null;
user: AuthUser | null; // Now properly typed as CurrentUser | LocalUser
user: AuthUser | null;
organizationPricing: OrganizationPricing | null;
}
@ -53,17 +53,32 @@ export function UserConfigProvider({ children }: { children: ReactNode }) {
const [error, setError] = useState<Error | null>(null);
const [accessToken, setAccessToken] = useState<string | null>(null);
const [organizationPricing, setOrganizationPricing] = useState<OrganizationPricing | null>(null);
const auth = useAuth();
const [permissions, setPermissions] = useState<TeamPermission[]>([]);
const auth = useAuth();
// Store auth functions in refs to avoid dependency issues
const authRef = useRef(auth);
authRef.current = auth;
// Track initialization
const hasFetchedConfig = useRef(false);
const hasFetchedPermissions = useRef(false);
// Fetch permissions once when auth is ready
useEffect(() => {
if (auth.loading || hasFetchedPermissions.current) {
return;
}
hasFetchedPermissions.current = true;
const fetchPermissions = async () => {
if (auth.provider === 'stack') {
const selectedTeam = auth.getSelectedTeam();
const currentAuth = authRef.current;
if (currentAuth.provider === 'stack' && currentAuth.getSelectedTeam && currentAuth.listPermissions) {
const selectedTeam = currentAuth.getSelectedTeam();
if (selectedTeam) {
try {
const perms = await auth.listPermissions(selectedTeam);
const perms = await currentAuth.listPermissions(selectedTeam);
setPermissions(Array.isArray(perms) ? perms : []);
} catch {
setPermissions([]);
@ -72,16 +87,55 @@ export function UserConfigProvider({ children }: { children: ReactNode }) {
setPermissions([]);
}
} else {
// For non-Stack providers, set default permissions
setPermissions([{ id: 'admin' }]);
}
};
if (!auth.loading) {
fetchPermissions();
}
}, [auth.loading, auth.provider, auth.getSelectedTeam, auth.listPermissions]);
fetchPermissions();
}, [auth.loading, auth.provider]);
// Fetch user config once when auth is ready
useEffect(() => {
if (auth.loading || !auth.isAuthenticated || hasFetchedConfig.current) {
return;
}
hasFetchedConfig.current = true;
const fetchUserConfig = async () => {
setLoading(true);
try {
const token = await authRef.current.getAccessToken();
setAccessToken(token);
const response = await getUserConfigurationsApiV1UserConfigurationsUserGet({
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (response.data) {
setUserConfig(response.data);
if (response.data.organization_pricing) {
setOrganizationPricing({
price_per_second_usd: response.data.organization_pricing.price_per_second_usd as number | null,
currency: response.data.organization_pricing.currency as string || 'USD',
billing_enabled: response.data.organization_pricing.billing_enabled as boolean || false
});
} else {
setOrganizationPricing(null);
}
}
setError(null);
} catch (err) {
setError(err instanceof Error ? err : new Error('Failed to fetch user configuration'));
setAccessToken(null);
} finally {
setLoading(false);
}
};
fetchUserConfig();
}, [auth.loading, auth.isAuthenticated]);
const saveUserConfig = useCallback(async (userConfigRequest: SaveUserConfigFunctionParams) => {
if (!accessToken) throw new Error('No authentication token available');
@ -93,11 +147,9 @@ export function UserConfigProvider({ children }: { children: ReactNode }) {
headers: { 'Authorization': `Bearer ${accessToken}` },
});
if (response.error) {
// Try to pull out a JSON array of { model, message } from response.error.detail
let msg = 'Failed to save user configuration';
const detail = (response.error as unknown as { detail?: { errors: { model: string; message: string }[] } }).detail;
if (Array.isArray(detail)) {
// Map each entry to "model: message" and join with \n
msg = detail
.map((e: { model: string; message: string }) => `${e.model}: ${e.message}`)
.join('\n');
@ -106,7 +158,6 @@ export function UserConfigProvider({ children }: { children: ReactNode }) {
}
setUserConfig(response.data!);
// Update organization pricing if available
if (response.data?.organization_pricing) {
setOrganizationPricing({
price_per_second_usd: response.data.organization_pricing.price_per_second_usd as number | null,
@ -116,12 +167,14 @@ export function UserConfigProvider({ children }: { children: ReactNode }) {
}
}, [accessToken, userConfig]);
const fetchUserConfig = useCallback(async () => {
const refreshConfig = useCallback(async () => {
const currentAuth = authRef.current;
if (!currentAuth.isAuthenticated) return;
setLoading(true);
try {
if (auth.loading || !auth.isAuthenticated) return;
const token = await auth.getAccessToken();
setAccessToken(token); // Set token when fetching config
const token = await currentAuth.getAccessToken();
setAccessToken(token);
const response = await getUserConfigurationsApiV1UserConfigurationsUserGet({
headers: {
@ -131,32 +184,21 @@ export function UserConfigProvider({ children }: { children: ReactNode }) {
if (response.data) {
setUserConfig(response.data);
// Extract organization pricing if available
if (response.data.organization_pricing) {
setOrganizationPricing({
price_per_second_usd: response.data.organization_pricing.price_per_second_usd as number | null,
currency: response.data.organization_pricing.currency as string || 'USD',
billing_enabled: response.data.organization_pricing.billing_enabled as boolean || false
});
} else {
setOrganizationPricing(null);
}
}
setError(null);
} catch (err) {
setError(err instanceof Error ? err : new Error('Failed to fetch user configuration'));
setAccessToken(null);
} finally {
setLoading(false);
}
}, [auth.loading, auth.isAuthenticated, auth.getAccessToken]);
useEffect(() => {
if (!auth.loading && auth.isAuthenticated) {
fetchUserConfig();
}
}, [fetchUserConfig, auth.loading, auth.isAuthenticated]);
}, []);
return (
<UserConfigContext.Provider
@ -165,10 +207,10 @@ export function UserConfigProvider({ children }: { children: ReactNode }) {
saveUserConfig,
loading,
error,
refreshConfig: fetchUserConfig,
refreshConfig,
permissions,
accessToken,
user: auth.user, // Pass the AuthUser (CurrentUser | LocalUser)
user: auth.user,
organizationPricing,
}}
>

View file

@ -1,83 +1,7 @@
'use client';
import React from 'react';
import logger from '@/lib/logger';
import { useAuthContext } from '../providers/AuthProvider';
export function useAuth() {
const renderCount = React.useRef(0);
renderCount.current++;
const context = useAuthContext();
logger.debug('[useAuth] Hook called', {
renderCount: renderCount.current,
hasUser: !!context.user,
userId: context.user?.id,
isAuthenticated: context.isAuthenticated,
loading: context.loading,
provider: context.provider
});
// Memoize functions that are recreated on every render
const logout = React.useCallback(() => context.service.logout(), [context.service]);
const redirectToLogin = React.useCallback(() => context.service.redirectToLogin(), [context.service]);
const getSelectedTeam = React.useCallback(() => context.service.getSelectedTeam?.(), [context.service]);
const listPermissions = React.useCallback(
(team?: unknown) => context.service.listPermissions?.(team) || Promise.resolve([]),
[context.service]
);
return React.useMemo(() => ({
// Core functionality
getAccessToken: context.getAccessToken,
user: context.user, // This is now AuthUser (CurrentUser | LocalUser)
isAuthenticated: context.isAuthenticated,
loading: context.loading,
// Service methods
logout,
redirectToLogin,
// Provider info
provider: context.provider,
// Stack-specific methods (optional)
getSelectedTeam,
listPermissions,
}), [
context.getAccessToken,
context.user,
context.isAuthenticated,
context.loading,
context.provider,
logout,
redirectToLogin,
getSelectedTeam,
listPermissions,
]);
return useAuthContext();
}
// Compatibility wrapper for gradual migration from useUser
export function useUser(options?: { or?: 'redirect' }) {
const auth = useAuth();
// Handle redirect option
if (options?.or === 'redirect' && !auth.isAuthenticated && !auth.loading) {
auth.redirectToLogin();
}
// Return Stack-compatible interface
return {
...auth.user,
getAuthJson: async () => ({
accessToken: await auth.getAccessToken(),
}),
selectedTeam: auth.getSelectedTeam(),
listPermissions: auth.listPermissions,
signOut: auth.logout,
};
}

View file

@ -1,4 +1,4 @@
export { useAuth, useUser } from './hooks/useAuth';
export { useAuth } from './hooks/useAuth';
export { AuthProvider } from './providers/AuthProvider';
export type {
AuthProvider as AuthProviderType,

View file

@ -3,24 +3,24 @@
import { Loader2 } from 'lucide-react';
import React, { createContext, lazy, Suspense, useContext, useEffect, useMemo, useState } from 'react';
import { createAuthService, IAuthService, StackAuthService } from '../services';
import { createAuthService } from '../services';
import type { AuthUser } from '../types';
interface AuthContextType {
service: IAuthService;
user: AuthUser | null; // Union type: CurrentUser | LocalUser
// Shared context type for both Stack and Local providers
export interface AuthContextType {
user: AuthUser | null;
isAuthenticated: boolean;
loading: boolean;
getAccessToken: () => Promise<string>;
redirectToLogin: () => void;
logout: () => Promise<void>;
provider: string;
// Stack-specific (optional)
getSelectedTeam?: () => unknown;
listPermissions?: (team?: unknown) => Promise<Array<{ id: string }>>;
}
const AuthContext = createContext<AuthContextType | null>(null);
interface AuthContextProviderProps {
service: IAuthService;
children: React.ReactNode;
}
export const AuthContext = createContext<AuthContextType | null>(null);
// Lazy load Stack components only when needed
const StackProviderWrapper = lazy(() =>
@ -29,15 +29,14 @@ const StackProviderWrapper = lazy(() =>
}))
);
// Generic context provider for non-Stack providers
function GenericAuthContextProvider({ service, children }: AuthContextProviderProps) {
// Generic context provider for non-Stack providers (local/OSS)
function LocalAuthContextProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(true);
const service = useMemo(() => createAuthService('local'), []);
useEffect(() => {
// Fetch current user
const fetchUser = async () => {
setLoading(true);
try {
const currentUser = await service.getCurrentUser();
setUser(currentUser);
@ -48,20 +47,22 @@ function GenericAuthContextProvider({ service, children }: AuthContextProviderPr
setLoading(false);
}
};
fetchUser();
}, [service]);
const getAccessToken = React.useCallback(() => service.getAccessToken(), [service]);
const redirectToLogin = React.useCallback(() => service.redirectToLogin(), [service]);
const logout = React.useCallback(() => service.logout(), [service]);
const contextValue: AuthContextType = React.useMemo(() => ({
service,
const contextValue: AuthContextType = useMemo(() => ({
user,
isAuthenticated: service.isAuthenticated(),
isAuthenticated: !!user,
loading,
getAccessToken,
provider: service.getProviderName(),
}), [service, user, loading, getAccessToken]);
redirectToLogin,
logout,
provider: 'local',
}), [user, loading, getAccessToken, redirectToLogin, logout]);
return (
<AuthContext.Provider value={contextValue}>
@ -72,62 +73,27 @@ function GenericAuthContextProvider({ service, children }: AuthContextProviderPr
export function AuthProvider({ children }: { children: React.ReactNode }) {
const authProvider = process.env.NEXT_PUBLIC_AUTH_PROVIDER || 'stack';
const authService = useMemo(() => createAuthService(authProvider), [authProvider]);
// For Stack provider, wrap with StackProvider and use Stack-specific context
if (authProvider === 'stack' && authService instanceof StackAuthService) {
// For Stack provider, use the dedicated wrapper
if (authProvider === 'stack') {
return (
<Suspense fallback={
<div className="flex items-center justify-center min-h-screen">
<Loader2 className="w-8 h-8 animate-spin text-gray-600" />
<Loader2 className="w-8 h-8 animate-spin" />
</div>
}>
<StackProviderWrapper service={authService}>
<StackProviderWrapper>
{children}
</StackProviderWrapper>
</Suspense>
);
}
// For other providers, use generic context provider
// For local/OSS provider
return (
<GenericAuthContextProvider service={authService}>
<LocalAuthContextProvider>
{children}
</GenericAuthContextProvider>
);
}
// Export the context for Stack-specific provider
export { AuthContext };
// Stack-specific context provider that uses the useUser hook
export function StackAuthContextProvider({ service, children }: AuthContextProviderProps) {
const [loading, setLoading] = useState(true);
const stackUser: AuthUser | null = null;
useEffect(() => {
// For Stack provider, we'll get the user from the StackProviderWrapper
// This is a placeholder that will be overridden by the actual implementation
if (service instanceof StackAuthService) {
setLoading(false);
}
}, [service]);
const getAccessToken = React.useCallback(() => service.getAccessToken(), [service]);
const contextValue: AuthContextType = React.useMemo(() => ({
service,
user: stackUser,
isAuthenticated: service.isAuthenticated(),
loading,
getAccessToken,
provider: service.getProviderName(),
}), [service, loading, getAccessToken]);
return (
<AuthContext.Provider value={contextValue}>
{children}
</AuthContext.Provider>
</LocalAuthContextProvider>
);
}

View file

@ -1,11 +1,8 @@
'use client';
import { StackClientApp,StackProvider, StackTheme, useUser as useStackUser } from '@stackframe/stack';
import React, { useEffect, useState } from 'react';
import { StackClientApp, StackProvider, StackTheme, useUser as useStackUser } from '@stackframe/stack';
import React, { useMemo, useRef } from 'react';
import logger from '@/lib/logger';
import { StackAuthService } from '../services';
import type { AuthUser } from '../types';
import { AuthContext } from './AuthProvider';
@ -14,7 +11,6 @@ let stackClientAppInstance: StackClientApp<true, string> | null = null;
function getStackClientApp(): StackClientApp<true, string> {
if (!stackClientAppInstance) {
logger.debug('[StackProviderWrapper] Creating singleton StackClientApp instance');
stackClientAppInstance = new StackClientApp({
tokenStore: "nextjs-cookie",
urls: {
@ -26,90 +22,82 @@ function getStackClientApp(): StackClientApp<true, string> {
}
interface StackProviderWrapperProps {
service: StackAuthService;
children: React.ReactNode;
}
// Stack-specific context provider that uses the useUser hook
function StackAuthContextProvider({ service, children }: { service: StackAuthService; children: React.ReactNode }) {
const renderCount = React.useRef(0);
const lastUserId = React.useRef<string | undefined>(undefined);
renderCount.current++;
// Simple context provider that uses Stack's useUser directly
function StackAuthContextProvider({ children }: { children: React.ReactNode }) {
const stackUser = useStackUser();
logger.debug(`[StackAuthContextProvider] Render #${renderCount.current} - Starting`);
// Store user in ref for callbacks to access latest value without creating new callbacks
const userRef = useRef(stackUser);
userRef.current = stackUser;
const stackUser = useStackUser(); // Always call the hook
const [isInitialized, setIsInitialized] = useState(false);
// Derive loading state: loading if we don't have a user yet
const isLoading = stackUser === null;
// Track if user actually changed
const userChanged = lastUserId.current !== stackUser?.id;
if (userChanged) {
lastUserId.current = stackUser?.id;
}
logger.debug(`[StackAuthContextProvider] Render #${renderCount.current} - stackUser:`, {
hasUser: !!stackUser,
userId: stackUser?.id,
isInitialized,
userChanged
});
useEffect(() => {
// Only log and update if user actually changed
if (!userChanged && isInitialized) {
return;
// Stable callbacks that use ref to access current user
const getAccessToken = React.useCallback(async () => {
const user = userRef.current;
if (!user) {
throw new Error('User not authenticated');
}
logger.debug('[StackAuthContextProvider] useEffect triggered (user changed)', {
hasUser: !!stackUser,
userId: stackUser?.id,
isInitialized,
isStackAuthService: service instanceof StackAuthService
});
// Only update the service once when user becomes available
if (!isInitialized && service instanceof StackAuthService && stackUser) {
logger.debug('[StackAuthContextProvider] Setting user instance in service', {
userId: stackUser.id
});
service.setUserInstance(stackUser);
setIsInitialized(true);
const authJson = await user.getAuthJson();
if (!authJson.accessToken) {
throw new Error('No access token available');
}
}, [service, stackUser, isInitialized, userChanged]);
return authJson.accessToken;
}, []);
const getAccessToken = React.useCallback(() => {
logger.debug('[StackAuthContextProvider] getAccessToken called');
return service.getAccessToken();
}, [service]);
const redirectToLogin = React.useCallback(() => {
if (typeof window !== 'undefined') {
window.location.href = '/handler/sign-in';
}
}, []);
// Stabilize the context value to prevent unnecessary re-renders
const contextValue = React.useMemo(() => {
const isAuth = service.isAuthenticated();
// IMPORTANT: Stay in loading state until service is initialized (has user set)
// Even if stackUser exists, we're still loading until setUserInstance is called
const loadingState = !isInitialized;
const logout = React.useCallback(async () => {
const user = userRef.current;
if (user?.signOut) {
await user.signOut();
}
}, []);
const value = {
service,
user: stackUser as AuthUser, // Pass the actual Stack CurrentUser
isAuthenticated: isAuth,
loading: loadingState, // Loading until service is initialized
getAccessToken,
provider: service.getProviderName(),
};
const getSelectedTeam = React.useCallback(() => {
return userRef.current?.selectedTeam ?? null;
}, []);
logger.debug('[StackAuthContextProvider] Context value created', {
isAuthenticated: isAuth,
loading: loadingState,
hasUser: !!value.user,
userId: stackUser?.id,
isInitialized,
provider: value.provider,
serviceHasUser: isAuth
});
const listPermissions = React.useCallback(async (team?: unknown) => {
const user = userRef.current;
if (!user?.listPermissions) {
return [];
}
const targetTeam = team || user.selectedTeam;
if (!targetTeam) {
return [];
}
try {
const perms = await user.listPermissions(targetTeam);
return Array.isArray(perms) ? perms : [];
} catch {
return [];
}
}, []);
return value;
}, [service, stackUser, isInitialized, getAccessToken]);
// IMPORTANT: Use primitive values (userId, isLoading) in deps, NOT stackUser object
// Stack's useUser() returns a new object reference on every render, which would cause infinite re-renders
const userId = stackUser?.id;
const contextValue = useMemo(() => ({
user: userRef.current as AuthUser,
isAuthenticated: !!userId,
loading: isLoading,
getAccessToken,
redirectToLogin,
logout,
provider: 'stack' as const,
getSelectedTeam,
listPermissions,
}), [userId, isLoading, getAccessToken, redirectToLogin, logout, getSelectedTeam, listPermissions]);
return (
<AuthContext.Provider value={contextValue}>
@ -118,16 +106,13 @@ function StackAuthContextProvider({ service, children }: { service: StackAuthSer
);
}
export function StackProviderWrapper({ service, children }: StackProviderWrapperProps) {
logger.debug('[StackProviderWrapper] Rendering wrapper');
// Use the singleton instance
export function StackProviderWrapper({ children }: StackProviderWrapperProps) {
const stackClientApp = getStackClientApp();
return (
<StackProvider app={stackClientApp}>
<StackTheme>
<StackAuthContextProvider service={service}>
<StackAuthContextProvider>
{children}
</StackAuthContextProvider>
</StackTheme>

View file

@ -12,6 +12,7 @@ export interface BaseUser {
export interface LocalUser extends BaseUser {
provider: 'local';
organizationId?: string;
displayName?: string;
}
// Union type for all user types

View file

@ -91,14 +91,14 @@ export async function getRedirectUrl(token: string, permissions: { id: string }[
console.log('[getRedirectUrl] User has workflows, redirecting to /workflow');
return "/workflow";
} else {
console.log('[getRedirectUrl] No workflows found, redirecting to /create-workflow');
return "/create-workflow";
console.log('[getRedirectUrl] No workflows found, redirecting to /workflow/create');
return "/workflow/create";
}
} catch (error) {
console.error('[getRedirectUrl] Error checking workflows:', error);
// If we can't check workflows, default to create-workflow
console.log('[getRedirectUrl] Defaulting to /create-workflow due to error');
return "/create-workflow";
// If we can't check workflows, default to /workflow/create
console.log('[getRedirectUrl] Defaulting to /workflow/create due to error');
return "/workflow/create";
}
} catch (error) {
console.error("[getRedirectUrl] Failed to fetch auth user:", error);