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,10 +1,12 @@
import { Suspense } from 'react';
import { getWorkflowsApiV1WorkflowFetchGet } from '@/client/sdk.gen';
import type { WorkflowListResponse } from '@/client/types.gen';
import { getWorkflowsApiV1WorkflowFetchGet, listFoldersApiV1FolderGet } from '@/client/sdk.gen';
import type { FolderResponse, WorkflowListResponse } from '@/client/types.gen';
import { CreateWorkflowButton } from "@/components/workflow/CreateWorkflowButton";
import { AgentFolderView } from '@/components/workflow/folders/AgentFolderView';
import { CreateFolderButton } from '@/components/workflow/folders/CreateFolderButton';
import { FolderSection } from '@/components/workflow/folders/FolderSection';
import { UploadWorkflowButton } from '@/components/workflow/UploadWorkflowButton';
import { WorkflowTable } from "@/components/workflow/WorkflowTable";
import { getServerAccessToken, getServerAuthProvider } from '@/lib/auth/server';
import logger from '@/lib/logger';
@ -54,13 +56,27 @@ async function WorkflowList() {
.filter((w: WorkflowListResponse) => w.status === 'archived')
.sort((a: WorkflowListResponse, b: WorkflowListResponse) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
// Fetch folders for grouping active agents. A failure here shouldn't
// break the page — fall back to an empty list (flat, ungrouped view).
let folders: FolderResponse[] = [];
try {
const foldersResponse = await listFoldersApiV1FolderGet({
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
folders = foldersResponse.data ?? [];
} catch (folderErr) {
logger.error(`Error fetching folders: ${folderErr}`);
}
return (
<>
{/* Active Workflows Section */}
<div className="mb-8">
<h2 className="text-xl font-semibold mb-4">Active Agents</h2>
{activeWorkflows.length > 0 ? (
<WorkflowTable workflows={activeWorkflows} showArchived={false} />
{activeWorkflows.length > 0 || folders.length > 0 ? (
<AgentFolderView workflows={activeWorkflows} folders={folders} />
) : (
<div className="text-muted-foreground bg-muted rounded-lg p-8 text-center">
No active workflows found. Create your first workflow to get started.
@ -68,11 +84,10 @@ async function WorkflowList() {
)}
</div>
{/* Archived Workflows Section */}
{/* Archived Section — collapsible, same design as the folder/Uncategorized sections */}
{archivedWorkflows.length > 0 && (
<div className="mb-8">
<h2 className="text-xl font-semibold mb-4 text-muted-foreground">Archived Workflows</h2>
<WorkflowTable workflows={archivedWorkflows} showArchived={true} />
<FolderSection kind="archived" workflows={archivedWorkflows} />
</div>
)}
</>
@ -99,6 +114,7 @@ async function PageContent() {
<h1 className="text-2xl font-bold">Your Agents</h1>
<div className="flex gap-2">
<UploadWorkflowButton />
<CreateFolderButton />
<CreateWorkflowButton />
</div>
</div>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -864,6 +864,16 @@ export type CreateCredentialRequest = {
};
};
/**
* CreateFolderRequest
*/
export type CreateFolderRequest = {
/**
* Name
*/
name: string;
};
/**
* CreateServiceKeyRequest
*/
@ -1718,6 +1728,24 @@ export type FileMetadataResponse = {
} | null;
};
/**
* FolderResponse
*/
export type FolderResponse = {
/**
* Id
*/
id: number;
/**
* Name
*/
name: string;
/**
* Created At
*/
created_at: string;
};
/**
* GraphConstraints
*
@ -2165,6 +2193,18 @@ export type McpToolDefinition = {
config: McpToolConfig;
};
/**
* MoveWorkflowToFolderRequest
*
* Move a workflow into a folder, or to "Uncategorized" when null.
*/
export type MoveWorkflowToFolderRequest = {
/**
* Folder Id
*/
folder_id?: number | null;
};
/**
* NodeCategory
*
@ -3865,6 +3905,16 @@ export type UpdateCredentialRequest = {
} | null;
};
/**
* UpdateFolderRequest
*/
export type UpdateFolderRequest = {
/**
* Name
*/
name: string;
};
/**
* UpdateToolRequest
*
@ -4359,6 +4409,10 @@ export type WorkflowListResponse = {
* Total Runs
*/
total_runs: number;
/**
* Folder Id
*/
folder_id?: number | null;
};
/**
@ -5962,6 +6016,50 @@ export type UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutResponses = {
export type UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutResponse = UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutResponses[keyof UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutResponses];
export type MoveWorkflowToFolderApiV1WorkflowWorkflowIdFolderPutData = {
body: MoveWorkflowToFolderRequest;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
/**
* X-Api-Key
*/
'X-API-Key'?: string | null;
};
path: {
/**
* Workflow Id
*/
workflow_id: number;
};
query?: never;
url: '/api/v1/workflow/{workflow_id}/folder';
};
export type MoveWorkflowToFolderApiV1WorkflowWorkflowIdFolderPutErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type MoveWorkflowToFolderApiV1WorkflowWorkflowIdFolderPutError = MoveWorkflowToFolderApiV1WorkflowWorkflowIdFolderPutErrors[keyof MoveWorkflowToFolderApiV1WorkflowWorkflowIdFolderPutErrors];
export type MoveWorkflowToFolderApiV1WorkflowWorkflowIdFolderPutResponses = {
/**
* Successful Response
*/
200: WorkflowListResponse;
};
export type MoveWorkflowToFolderApiV1WorkflowWorkflowIdFolderPutResponse = MoveWorkflowToFolderApiV1WorkflowWorkflowIdFolderPutResponses[keyof MoveWorkflowToFolderApiV1WorkflowWorkflowIdFolderPutResponses];
export type UpdateWorkflowApiV1WorkflowWorkflowIdPutData = {
body: UpdateWorkflowRequest;
headers?: {
@ -10680,6 +10778,178 @@ export type TranscribeAudioApiV1WorkflowRecordingsTranscribePostResponses = {
200: unknown;
};
export type ListFoldersApiV1FolderGetData = {
body?: never;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
/**
* X-Api-Key
*/
'X-API-Key'?: string | null;
};
path?: never;
query?: never;
url: '/api/v1/folder/';
};
export type ListFoldersApiV1FolderGetErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type ListFoldersApiV1FolderGetError = ListFoldersApiV1FolderGetErrors[keyof ListFoldersApiV1FolderGetErrors];
export type ListFoldersApiV1FolderGetResponses = {
/**
* Response List Folders Api V1 Folder Get
*
* Successful Response
*/
200: Array<FolderResponse>;
};
export type ListFoldersApiV1FolderGetResponse = ListFoldersApiV1FolderGetResponses[keyof ListFoldersApiV1FolderGetResponses];
export type CreateFolderApiV1FolderPostData = {
body: CreateFolderRequest;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
/**
* X-Api-Key
*/
'X-API-Key'?: string | null;
};
path?: never;
query?: never;
url: '/api/v1/folder/';
};
export type CreateFolderApiV1FolderPostErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type CreateFolderApiV1FolderPostError = CreateFolderApiV1FolderPostErrors[keyof CreateFolderApiV1FolderPostErrors];
export type CreateFolderApiV1FolderPostResponses = {
/**
* Successful Response
*/
200: FolderResponse;
};
export type CreateFolderApiV1FolderPostResponse = CreateFolderApiV1FolderPostResponses[keyof CreateFolderApiV1FolderPostResponses];
export type DeleteFolderApiV1FolderFolderIdDeleteData = {
body?: never;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
/**
* X-Api-Key
*/
'X-API-Key'?: string | null;
};
path: {
/**
* Folder Id
*/
folder_id: number;
};
query?: never;
url: '/api/v1/folder/{folder_id}';
};
export type DeleteFolderApiV1FolderFolderIdDeleteErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type DeleteFolderApiV1FolderFolderIdDeleteError = DeleteFolderApiV1FolderFolderIdDeleteErrors[keyof DeleteFolderApiV1FolderFolderIdDeleteErrors];
export type DeleteFolderApiV1FolderFolderIdDeleteResponses = {
/**
* Response Delete Folder Api V1 Folder Folder Id Delete
*
* Successful Response
*/
200: {
[key: string]: boolean;
};
};
export type DeleteFolderApiV1FolderFolderIdDeleteResponse = DeleteFolderApiV1FolderFolderIdDeleteResponses[keyof DeleteFolderApiV1FolderFolderIdDeleteResponses];
export type RenameFolderApiV1FolderFolderIdPutData = {
body: UpdateFolderRequest;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
/**
* X-Api-Key
*/
'X-API-Key'?: string | null;
};
path: {
/**
* Folder Id
*/
folder_id: number;
};
query?: never;
url: '/api/v1/folder/{folder_id}';
};
export type RenameFolderApiV1FolderFolderIdPutErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type RenameFolderApiV1FolderFolderIdPutError = RenameFolderApiV1FolderFolderIdPutErrors[keyof RenameFolderApiV1FolderFolderIdPutErrors];
export type RenameFolderApiV1FolderFolderIdPutResponses = {
/**
* Successful Response
*/
200: FolderResponse;
};
export type RenameFolderApiV1FolderFolderIdPutResponse = RenameFolderApiV1FolderFolderIdPutResponses[keyof RenameFolderApiV1FolderFolderIdPutResponses];
export type SignupApiV1AuthSignupPostData = {
body: SignupRequest;
path?: never;

View file

@ -1,6 +1,6 @@
"use client";
import { Plus, X } from "lucide-react";
import { ExternalLink, Plus, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
@ -32,9 +32,13 @@ interface SchemaProperty {
description?: string;
format?: string;
multiline?: boolean;
docs_url?: string;
}
interface ProviderSchema {
title?: string;
description?: string;
provider_docs_url?: string;
properties: Record<string, SchemaProperty>;
required?: string[];
$defs?: Record<string, SchemaProperty>;
@ -88,12 +92,24 @@ export interface ServiceConfigurationFormProps {
submitLabel?: string;
}
function getGlobalSummary(config: Record<string, unknown> | null | undefined): string {
function getProviderDisplayName(
provider: string | undefined,
providerSchema: ProviderSchema | undefined,
): string | undefined {
if (!provider) return provider;
return providerSchema?.title || provider;
}
function getGlobalSummary(
config: Record<string, unknown> | null | undefined,
providerSchema: ProviderSchema | undefined,
): string {
if (!config) return "Not configured";
const provider = config.provider as string | undefined;
const model = config.model as string | undefined;
if (!provider) return "Not configured";
return model ? `${provider} / ${model}` : provider;
const providerLabel = getProviderDisplayName(provider, providerSchema);
return model ? `${providerLabel} / ${model}` : providerLabel || provider;
}
export function ServiceConfigurationForm({
@ -486,11 +502,26 @@ export function ServiceConfigurationForm({
<SelectContent>
{availableProviders.map((provider) => (
<SelectItem key={provider} value={provider}>
{provider}
{getProviderDisplayName(provider, schemas?.[service]?.[provider])}
</SelectItem>
))}
</SelectContent>
</Select>
{(providerSchema?.description || providerSchema?.provider_docs_url) && (
<p className="text-xs text-muted-foreground">
{providerSchema?.description}{" "}
{providerSchema?.provider_docs_url && (
<a
href={providerSchema.provider_docs_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 underline"
>
Learn more <ExternalLink className="h-3 w-3" />
</a>
)}
</p>
)}
</div>
{currentProvider && providerSchema && configFields[0] && (
@ -580,9 +611,21 @@ export function ServiceConfigurationForm({
const actualSchema = schema.$ref && providerSchema.$defs
? providerSchema.$defs[schema.$ref.split('/').pop() || '']
: schema;
if (!actualSchema?.description) return null;
if (!actualSchema?.description && !actualSchema?.docs_url) return null;
return (
<p className="text-xs text-muted-foreground">{actualSchema.description}</p>
<p className="text-xs text-muted-foreground">
{actualSchema?.description}{" "}
{actualSchema?.docs_url && (
<a
href={actualSchema.docs_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 underline"
>
Supported languages <ExternalLink className="h-3 w-3" />
</a>
)}
</p>
);
};
@ -763,6 +806,8 @@ export function ServiceConfigurationForm({
const renderOverrideToggle = (service: ServiceSegment, label: string) => {
const globalVal = (userConfig as Record<string, unknown> | null)?.[service] as Record<string, unknown> | null | undefined;
const isEnabled = enabledOverrides[service];
const globalProvider = globalVal?.provider as string | undefined;
const globalProviderSchema = globalProvider ? schemas?.[service]?.[globalProvider] : undefined;
return (
<div className="flex items-center justify-between p-3 border rounded-md bg-muted/20 mb-4">
@ -772,7 +817,7 @@ export function ServiceConfigurationForm({
</Label>
{!isEnabled && (
<p className="text-xs text-muted-foreground">
Using global: {getGlobalSummary(globalVal)}
Using global: {getGlobalSummary(globalVal, globalProviderSchema)}
</p>
)}
</div>

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