feat: add google stt and tts. add folders to organize agents

This commit is contained in:
Abhishek Kumar 2026-05-22 14:36:50 +05:30
parent 21951eca18
commit ad2fa07058
52 changed files with 3412 additions and 621 deletions

View file

@ -1,12 +1,32 @@
'use client';
import { Archive, Pencil, RotateCcw } from 'lucide-react';
import {
Archive,
Check,
Folder as FolderIcon,
FolderInput,
Inbox,
Pencil,
RotateCcw,
} from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { toast } from 'sonner';
import { updateWorkflowStatusApiV1WorkflowWorkflowIdStatusPut } from '@/client/sdk.gen';
import {
moveWorkflowToFolderApiV1WorkflowWorkflowIdFolderPut,
updateWorkflowStatusApiV1WorkflowWorkflowIdStatusPut,
} from '@/client/sdk.gen';
import type { FolderResponse } from '@/client/types.gen';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Table,
TableBody,
@ -21,17 +41,31 @@ interface Workflow {
status: string;
created_at: string;
total_runs?: number | null;
folder_id?: number | null;
}
interface WorkflowTableProps {
workflows: Workflow[];
showArchived: boolean;
/**
* When provided, each row gets a "Move to folder" action listing these
* folders. Omit it (e.g. for the archived list) to hide the control.
*/
folders?: FolderResponse[];
/** The folder this table is rendered under; null means "Uncategorized". */
currentFolderId?: number | null;
}
export function WorkflowTable({ workflows, showArchived }: WorkflowTableProps) {
export function WorkflowTable({
workflows,
showArchived,
folders,
currentFolderId = null,
}: WorkflowTableProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [loadingWorkflowId, setLoadingWorkflowId] = useState<number | null>(null);
const [movingWorkflowId, setMovingWorkflowId] = useState<number | null>(null);
const handleEdit = (id: number) => {
router.push(`/workflow/${id}`);
@ -67,6 +101,30 @@ export function WorkflowTable({ workflows, showArchived }: WorkflowTableProps) {
}
};
const handleMove = async (id: number, folderId: number | null) => {
setMovingWorkflowId(id);
try {
const response = await moveWorkflowToFolderApiV1WorkflowWorkflowIdFolderPut({
path: { workflow_id: id },
body: { folder_id: folderId },
});
if (response.error) {
throw new Error('Failed to move agent');
}
toast.success(
folderId === null ? 'Moved to Uncategorized' : 'Agent moved',
);
startTransition(() => {
router.refresh();
});
} catch (error) {
console.error('Error moving workflow:', error);
toast.error('Failed to move agent');
} finally {
setMovingWorkflowId(null);
}
};
return (
<div className="bg-card border rounded-lg overflow-hidden shadow-sm">
<Table>
@ -114,6 +172,52 @@ export function WorkflowTable({ workflows, showArchived }: WorkflowTableProps) {
<Pencil size={16} />
Edit
</Button>
{folders && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={movingWorkflowId === workflow.id || isPending}
className="flex items-center gap-2"
>
{movingWorkflowId === workflow.id ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<FolderInput size={16} />
)}
Move
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuLabel>Move to folder</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={currentFolderId === null}
onClick={() => handleMove(workflow.id, null)}
>
<Inbox size={14} className="mr-2" />
Uncategorized
{currentFolderId === null && (
<Check size={14} className="ml-auto" />
)}
</DropdownMenuItem>
{folders.map((folder) => (
<DropdownMenuItem
key={folder.id}
disabled={folder.id === currentFolderId}
onClick={() => handleMove(workflow.id, folder.id)}
>
<FolderIcon size={14} className="mr-2" />
<span className="truncate">{folder.name}</span>
{folder.id === currentFolderId && (
<Check size={14} className="ml-auto shrink-0" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
<Button
variant={showArchived ? "default" : "outline"}
size="sm"

View file

@ -0,0 +1,63 @@
'use client';
import type { FolderResponse, WorkflowListResponse } from '@/client/types.gen';
import { WorkflowTable } from '../WorkflowTable';
import { FolderSection } from './FolderSection';
interface AgentFolderViewProps {
/** Active (non-archived) agents only. */
workflows: WorkflowListResponse[];
folders: FolderResponse[];
}
/**
* Renders active agents grouped into collapsible folder sections.
*
* When the organization has no folders yet, this falls back to the original
* flat table so the feature stays invisible until someone creates a folder.
*/
export function AgentFolderView({ workflows, folders }: AgentFolderViewProps) {
// No folders → keep the original flat list (no folder chrome, nowhere to move to).
if (folders.length === 0) {
return <WorkflowTable workflows={workflows} showArchived={false} />;
}
// Group agents by folder. Agents whose folder_id is null — or points at a
// folder we didn't get back — fall into "Uncategorized".
const folderIds = new Set(folders.map((f) => f.id));
const byFolder = new Map<number, WorkflowListResponse[]>();
const uncategorized: WorkflowListResponse[] = [];
for (const wf of workflows) {
if (wf.folder_id != null && folderIds.has(wf.folder_id)) {
const bucket = byFolder.get(wf.folder_id) ?? [];
bucket.push(wf);
byFolder.set(wf.folder_id, bucket);
} else {
uncategorized.push(wf);
}
}
return (
<div className="space-y-1">
{folders.map((folder) => (
<FolderSection
key={folder.id}
kind="folder"
folder={folder}
workflows={byFolder.get(folder.id) ?? []}
allFolders={folders}
defaultOpen={false}
/>
))}
{uncategorized.length > 0 && (
<FolderSection
kind="uncategorized"
workflows={uncategorized}
allFolders={folders}
/>
)}
</div>
);
}

View file

@ -0,0 +1,46 @@
'use client';
import { FolderPlus } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'sonner';
import { createFolderApiV1FolderPost } from '@/client/sdk.gen';
import { Button } from '@/components/ui/button';
import { FolderFormDialog } from './FolderFormDialog';
export function CreateFolderButton() {
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);
const handleCreate = async (name: string) => {
const response = await createFolderApiV1FolderPost({ body: { name } });
if (response.error) {
// 409 = duplicate name; surface the server's message when present.
const detail =
(response.error as { detail?: string })?.detail ??
'Failed to create folder';
toast.error(detail);
throw new Error(detail);
}
toast.success(`Folder "${name}" created`);
router.refresh();
};
return (
<>
<Button variant="outline" onClick={() => setIsOpen(true)}>
<FolderPlus className="w-4 h-4 mr-2" />
New Folder
</Button>
<FolderFormDialog
open={isOpen}
onOpenChange={setIsOpen}
title="Create folder"
submitLabel="Create"
onSubmit={handleCreate}
/>
</>
);
}

View file

@ -0,0 +1,97 @@
'use client';
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
interface FolderFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
/** Pre-fill the input (used when renaming). */
initialName?: string;
submitLabel: string;
/** Resolve to close the dialog; reject/throw to keep it open (e.g. on error). */
onSubmit: (name: string) => Promise<void>;
}
/**
* Shared single-field dialog used for both creating and renaming a folder.
* Keeps name validation and the pending state in one place.
*/
export function FolderFormDialog({
open,
onOpenChange,
title,
initialName = '',
submitLabel,
onSubmit,
}: FolderFormDialogProps) {
const [name, setName] = useState(initialName);
const [isSubmitting, setIsSubmitting] = useState(false);
// Reset the field whenever the dialog (re)opens.
useEffect(() => {
if (open) setName(initialName);
}, [open, initialName]);
const trimmed = name.trim();
const canSubmit = trimmed.length > 0 && trimmed !== initialName.trim() && !isSubmitting;
const handleSubmit = async () => {
if (!canSubmit) return;
setIsSubmitting(true);
try {
await onSubmit(trimmed);
onOpenChange(false);
} catch {
// onSubmit surfaces its own error toast; keep the dialog open.
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="space-y-2 py-2">
<Label htmlFor="folder-name">Folder name</Label>
<Input
id="folder-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Sales, Support, Onboarding"
maxLength={100}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSubmit();
}
}}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={!canSubmit}>
{isSubmitting ? 'Saving...' : submitLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,255 @@
'use client';
import {
Archive,
ChevronRight,
Folder as FolderIcon,
FolderOpen,
Inbox,
MoreVertical,
Pencil,
Trash2,
} from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'sonner';
import {
deleteFolderApiV1FolderFolderIdDelete,
renameFolderApiV1FolderFolderIdPut,
} from '@/client/sdk.gen';
import type { FolderResponse, WorkflowListResponse } from '@/client/types.gen';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import logger from '@/lib/logger';
import { cn } from '@/lib/utils';
import { WorkflowTable } from '../WorkflowTable';
import { FolderFormDialog } from './FolderFormDialog';
/**
* - `folder` a real, renameable/deletable folder of active agents
* - `uncategorized` active agents with no folder
* - `archived` archived agents (restore-only; not a move target)
*/
type SectionKind = 'folder' | 'uncategorized' | 'archived';
interface FolderSectionProps {
kind: SectionKind;
/** Required when kind === 'folder'; ignored otherwise. */
folder?: FolderResponse | null;
workflows: WorkflowListResponse[];
/** All folders, passed through so each row's "Move to folder" menu has targets. */
allFolders?: FolderResponse[];
/** Defaults to open only for Uncategorized; folders and Archived start collapsed. */
defaultOpen?: boolean;
}
export function FolderSection({
kind,
folder = null,
workflows,
allFolders = [],
defaultOpen,
}: FolderSectionProps) {
const router = useRouter();
const [open, setOpen] = useState(defaultOpen ?? kind === 'uncategorized');
const [isRenaming, setIsRenaming] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const isFolder = kind === 'folder';
const isArchived = kind === 'archived';
const count = workflows.length;
const title = isFolder ? (folder?.name ?? '') : isArchived ? 'Archived' : 'Uncategorized';
const handleRename = async (name: string) => {
if (!folder) return;
const response = await renameFolderApiV1FolderFolderIdPut({
path: { folder_id: folder.id },
body: { name },
});
if (response.error) {
const detail =
(response.error as { detail?: string })?.detail ??
'Failed to rename folder';
toast.error(detail);
throw new Error(detail);
}
toast.success('Folder renamed');
router.refresh();
};
const handleDelete = async () => {
if (!folder) return;
setIsDeleting(true);
try {
const response = await deleteFolderApiV1FolderFolderIdDelete({
path: { folder_id: folder.id },
});
if (response.error) {
throw new Error('Failed to delete folder');
}
toast.success(`Folder "${folder.name}" deleted`);
setConfirmDelete(false);
router.refresh();
} catch (err) {
logger.error(`Error deleting folder: ${err}`);
toast.error('Failed to delete folder');
} finally {
setIsDeleting(false);
}
};
return (
<div className="mb-3">
<Collapsible open={open} onOpenChange={setOpen}>
<div className="flex items-center gap-1">
<CollapsibleTrigger asChild>
<button
className="group flex flex-1 items-center gap-2.5 rounded-md px-2 py-2 text-left transition-colors hover:bg-accent"
aria-label={`Toggle ${title}`}
>
<ChevronRight
size={16}
className={cn(
'shrink-0 text-muted-foreground transition-transform duration-200',
open && 'rotate-90',
)}
/>
{isFolder ? (
open ? (
<FolderOpen size={17} className="shrink-0 text-amber-500" />
) : (
<FolderIcon size={17} className="shrink-0 text-amber-500" />
)
) : isArchived ? (
<Archive size={16} className="shrink-0 text-muted-foreground" />
) : (
<Inbox size={17} className="shrink-0 text-muted-foreground" />
)}
<span
className={cn('font-medium', !isFolder && 'text-muted-foreground')}
>
{title}
</span>
<Badge variant="secondary" className="ml-1 font-normal">
{count}
</Badge>
</button>
</CollapsibleTrigger>
{isFolder && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground"
aria-label="Folder actions"
>
<MoreVertical size={16} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setIsRenaming(true)}>
<Pencil size={14} className="mr-2" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setConfirmDelete(true)}
className="text-destructive focus:text-destructive"
>
<Trash2 size={14} className="mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<CollapsibleContent className="data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-1">
<div className="pl-7 pt-2">
{count > 0 ? (
<WorkflowTable
workflows={workflows}
showArchived={isArchived}
// Archived agents are restore-only — not a move target.
folders={isArchived ? undefined : allFolders}
currentFolderId={folder?.id ?? null}
/>
) : (
<div className="rounded-lg border border-dashed bg-muted/30 p-6 text-center text-sm text-muted-foreground">
{isArchived
? 'No archived agents.'
: isFolder
? 'This folder is empty. Use “Move to folder” on an agent to add it here.'
: 'No uncategorized agents.'}
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
{isFolder && folder && (
<>
<FolderFormDialog
open={isRenaming}
onOpenChange={setIsRenaming}
title="Rename folder"
initialName={folder.name}
submitLabel="Rename"
onSubmit={handleRename}
/>
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {folder.name}?</AlertDialogTitle>
<AlertDialogDescription>
The {count} agent{count === 1 ? '' : 's'} in this folder
wont be deleted theyll move to Uncategorized.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleDelete();
}}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? 'Deleting...' : 'Delete folder'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)}
</div>
);
}