feat: add AI agent dispatch panel with floating button and new task button on agents page

This commit is contained in:
2026-07-29 19:27:17 +00:00
parent d951348372
commit 449eade4b6
4 changed files with 278 additions and 6 deletions
+10 -5
View File
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { DispatchPanel } from '@/components/agents/dispatch-panel';
interface Agent { interface Agent {
id: string; id: string;
@@ -164,11 +165,15 @@ export default function AgentsPage() {
return ( return (
<div> <div>
<div className="mb-6"> <div className="mb-6 flex items-start justify-between">
<h1 className="text-2xl font-bold">Agent Activity</h1> <div>
<p className="mt-1 text-muted-foreground"> <h1 className="text-2xl font-bold">Agent Activity</h1>
Every agent action, visible and reversible. <p className="mt-1 text-muted-foreground">
</p> Every agent action, visible and reversible.
</p>
</div>
<DispatchPanel triggerLabel="+ New task" triggerVariant="default" />
</div>
{feedback && ( {feedback && (
<p <p
className={`mt-3 text-sm ${ className={`mt-3 text-sm ${
+9
View File
@@ -4,6 +4,7 @@ import { NetworkErrorBanner } from '@/components/network-error-banner';
import { KeyboardShortcutsProvider } from '@/components/keyboard-shortcuts-provider'; import { KeyboardShortcutsProvider } from '@/components/keyboard-shortcuts-provider';
import { WebVitalsTracker } from '@/components/web-vitals-tracker'; import { WebVitalsTracker } from '@/components/web-vitals-tracker';
import { MobileBottomNav } from '@/components/mobile-bottom-nav'; import { MobileBottomNav } from '@/components/mobile-bottom-nav';
import { DispatchPanel } from '@/components/agents/dispatch-panel';
export default function DashboardLayout({ children }: { children: React.ReactNode }) { export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return ( return (
@@ -23,6 +24,14 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
</div> </div>
</div> </div>
<MobileBottomNav /> <MobileBottomNav />
{/* Floating AI dispatch button */}
<div className="fixed bottom-6 right-6 z-50">
<DispatchPanel
triggerLabel="Ask AI"
triggerVariant="default"
triggerClassName="h-12 w-12 rounded-full shadow-lg md:h-auto md:w-auto md:rounded-md md:px-4 md:py-2"
/>
</div>
<div className="sr-only" aria-live="polite" aria-atomic="true" id="a11y-announcer" /> <div className="sr-only" aria-live="polite" aria-atomic="true" id="a11y-announcer" />
</KeyboardShortcutsProvider> </KeyboardShortcutsProvider>
); );
+24 -1
View File
@@ -4,8 +4,10 @@
// See AGENTS.md for full rules. // See AGENTS.md for full rules.
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { withAuth } from '@/lib/auth'; import { withAuth, createErrorResponse } from '@/lib/auth';
import { createPocketBaseClient } from '@/lib/pocketbase'; import { createPocketBaseClient } from '@/lib/pocketbase';
import { createAgentTaskSchema } from '@project-e/shared';
import { z } from 'zod';
// GET /api/agent-tasks — List agent tasks // GET /api/agent-tasks — List agent tasks
export const GET = withAuth(async (request: NextRequest) => { export const GET = withAuth(async (request: NextRequest) => {
@@ -27,3 +29,24 @@ export const GET = withAuth(async (request: NextRequest) => {
perPage: result.perPage, perPage: result.perPage,
}); });
}); });
// POST /api/agent-tasks — Create a new agent task
export const POST = withAuth(async (request: NextRequest) => {
try {
const body = await request.json();
const data = createAgentTaskSchema.parse(body);
const pb = createPocketBaseClient();
const task = await pb.collection('agent_tasks').create({
...data,
status: 'pending',
});
return NextResponse.json(task, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
throw error;
}
});
@@ -0,0 +1,235 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Bot, Sparkles, X, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet';
interface Agent {
id: string;
name: string;
description?: string;
status: 'active' | 'disabled';
permission_tier: string;
}
interface DispatchPanelProps {
/** Optional trigger label override */
triggerLabel?: string;
/** Optional variant for the trigger button */
triggerVariant?: 'default' | 'outline' | 'ghost';
/** Optional class name for the trigger button */
triggerClassName?: string;
}
export function DispatchPanel({
triggerLabel = 'Ask AI',
triggerVariant = 'default',
triggerClassName,
}: DispatchPanelProps) {
const [open, setOpen] = useState(false);
const [agents, setAgents] = useState<Agent[]>([]);
const [selectedAgentId, setSelectedAgentId] = useState<string>('');
const [prompt, setPrompt] = useState('');
const [entityRef, setEntityRef] = useState('');
const [dispatching, setDispatching] = useState(false);
const [loadingAgents, setLoadingAgents] = useState(false);
const fetchAgents = useCallback(async () => {
setLoadingAgents(true);
try {
const res = await fetch('/api/agents');
if (!res.ok) throw new Error('Failed to fetch agents');
const data = await res.json();
const activeAgents = (data.items || []).filter(
(a: Agent) => a.status === 'active'
);
setAgents(activeAgents);
if (activeAgents.length > 0 && !selectedAgentId) {
setSelectedAgentId(activeAgents[0].id);
}
} catch (err) {
console.error('Failed to fetch agents:', err);
} finally {
setLoadingAgents(false);
}
}, [selectedAgentId]);
useEffect(() => {
if (open) {
fetchAgents();
}
}, [open, fetchAgents]);
async function handleDispatch() {
if (!selectedAgentId || !prompt.trim()) {
toast.error('Please select an agent and enter a prompt');
return;
}
setDispatching(true);
try {
const body: Record<string, unknown> = {
agent_id: selectedAgentId,
task_type: 'custom',
input: { prompt: prompt.trim() },
};
if (entityRef.trim()) {
// Parse entity reference: "type:id" or just a free-form reference
body.entity_type = 'reference';
body.entity_id = entityRef.trim();
}
const res = await fetch('/api/agent-tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
console.error('Failed to dispatch task:', text);
toast.error('Failed to dispatch task');
return;
}
toast.success('Task dispatched — see Agent Activity for results');
setPrompt('');
setEntityRef('');
setOpen(false);
} catch (err) {
console.error('Failed to dispatch task:', err);
toast.error('Failed to dispatch task');
} finally {
setDispatching(false);
}
}
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button
variant={triggerVariant}
className={triggerClassName}
aria-label={triggerLabel}
>
<Sparkles className="mr-2 h-4 w-4" />
{triggerLabel}
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-full sm:max-w-md">
<SheetHeader>
<SheetTitle className="flex items-center gap-2">
<Bot className="h-5 w-5" />
Dispatch AI Agent
</SheetTitle>
<SheetDescription>
Send a task to an AI agent and view results in Agent Activity.
</SheetDescription>
</SheetHeader>
<div className="mt-6 space-y-4">
{/* Agent selector */}
<div className="space-y-2">
<label className="text-sm font-medium">Agent</label>
{loadingAgents ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading agents...
</div>
) : agents.length === 0 ? (
<p className="text-sm text-muted-foreground">
No active agents available
</p>
) : (
<Select
value={selectedAgentId}
onValueChange={setSelectedAgentId}
>
<SelectTrigger>
<SelectValue placeholder="Select an agent" />
</SelectTrigger>
<SelectContent>
{agents.map((agent) => (
<SelectItem key={agent.id} value={agent.id}>
<div className="flex items-center gap-2">
<span>{agent.name}</span>
<span className="text-xs text-muted-foreground">
({agent.permission_tier})
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* Prompt */}
<div className="space-y-2">
<label className="text-sm font-medium">Prompt</label>
<Textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="What should the agent do?"
rows={5}
/>
</div>
{/* Entity reference (optional) */}
<div className="space-y-2">
<label className="text-sm font-medium">
Entity reference{' '}
<span className="text-xs text-muted-foreground">(optional)</span>
</label>
<Input
value={entityRef}
onChange={(e) => setEntityRef(e.target.value)}
placeholder="e.g. task:abc123 or project:xyz"
/>
<p className="text-xs text-muted-foreground">
Reference a specific entity the agent should work on.
</p>
</div>
{/* Dispatch button */}
<Button
className="w-full"
onClick={handleDispatch}
disabled={dispatching || !selectedAgentId || !prompt.trim()}
>
{dispatching ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Dispatching...
</>
) : (
<>
<Sparkles className="mr-2 h-4 w-4" />
Dispatch
</>
)}
</Button>
</div>
</SheetContent>
</Sheet>
);
}