feat: Add Agent Logs tab to task slide-over

Add getTaskAgent tRPC procedure to find the most recent agent for a task.
TaskSlideOver now has Details/Agent Logs tabs — logs tab renders
AgentOutputViewer when an agent exists, or an empty state otherwise.
This commit is contained in:
Lukas May
2026-03-06 13:10:46 +01:00
parent deffdc7c4f
commit 3f3954672e
2 changed files with 156 additions and 71 deletions

View File

@@ -184,6 +184,17 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) {
return candidates[0] ?? null; return candidates[0] ?? null;
}), }),
getTaskAgent: publicProcedure
.input(z.object({ taskId: z.string().min(1) }))
.query(async ({ ctx, input }): Promise<AgentInfo | null> => {
const agentManager = requireAgentManager(ctx);
const all = await agentManager.list();
const matches = all
.filter(a => a.taskId === input.taskId)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
return matches[0] ?? null;
}),
getActiveConflictAgent: publicProcedure getActiveConflictAgent: publicProcedure
.input(z.object({ initiativeId: z.string().min(1) })) .input(z.object({ initiativeId: z.string().min(1) }))
.query(async ({ ctx, input }): Promise<AgentInfo | null> => { .query(async ({ ctx, input }): Promise<AgentInfo | null> => {

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useMemo } from "react"; import { useCallback, useEffect, useRef, useMemo, useState } from "react";
import { motion, AnimatePresence } from "motion/react"; import { motion, AnimatePresence } from "motion/react";
import { X, Trash2, MessageCircle, RotateCw } from "lucide-react"; import { X, Trash2, MessageCircle, RotateCw } from "lucide-react";
import type { ChatTarget } from "@/components/chat/ChatSlideOver"; import type { ChatTarget } from "@/components/chat/ChatSlideOver";
@@ -7,12 +7,15 @@ import { Button } from "@/components/ui/button";
import { StatusBadge } from "@/components/StatusBadge"; import { StatusBadge } from "@/components/StatusBadge";
import { StatusDot } from "@/components/StatusDot"; import { StatusDot } from "@/components/StatusDot";
import { TiptapEditor } from "@/components/editor/TiptapEditor"; import { TiptapEditor } from "@/components/editor/TiptapEditor";
import { AgentOutputViewer } from "@/components/AgentOutputViewer";
import { getCategoryConfig } from "@/lib/category"; import { getCategoryConfig } from "@/lib/category";
import { markdownToTiptapJson } from "@/lib/markdown-to-tiptap"; import { markdownToTiptapJson } from "@/lib/markdown-to-tiptap";
import { useExecutionContext } from "./ExecutionContext"; import { useExecutionContext } from "./ExecutionContext";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type SlideOverTab = "details" | "logs";
interface TaskSlideOverProps { interface TaskSlideOverProps {
onOpenChat?: (target: ChatTarget) => void; onOpenChat?: (target: ChatTarget) => void;
} }
@@ -24,8 +27,15 @@ export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) {
const deleteTaskMutation = trpc.deleteTask.useMutation(); const deleteTaskMutation = trpc.deleteTask.useMutation();
const updateTaskMutation = trpc.updateTask.useMutation(); const updateTaskMutation = trpc.updateTask.useMutation();
const [tab, setTab] = useState<SlideOverTab>("details");
const close = useCallback(() => setSelectedTaskId(null), [setSelectedTaskId]); const close = useCallback(() => setSelectedTaskId(null), [setSelectedTaskId]);
// Reset tab when task changes
useEffect(() => {
setTab("details");
}, [selectedEntry?.task?.id]);
// Escape key closes // Escape key closes
useEffect(() => { useEffect(() => {
if (!selectedEntry) return; if (!selectedEntry) return;
@@ -152,80 +162,107 @@ export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) {
</button> </button>
</div> </div>
{/* Tab bar */}
<div className="flex gap-4 border-b border-border px-5">
{(["details", "logs"] as const).map((t) => (
<button
key={t}
className={cn(
"relative pb-2 pt-3 text-sm font-medium transition-colors",
tab === t
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
onClick={() => setTab(t)}
>
{t === "details" ? "Details" : "Agent Logs"}
{tab === t && (
<span className="absolute inset-x-0 bottom-0 h-0.5 bg-primary" />
)}
</button>
))}
</div>
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-5"> <div className="flex-1 overflow-y-auto">
{/* Metadata grid */} {tab === "details" ? (
<div className="grid grid-cols-2 gap-3 text-sm"> <div className="px-5 py-4 space-y-5">
<MetaField label="Status"> {/* Metadata grid */}
<StatusBadge status={task.status} /> <div className="grid grid-cols-2 gap-3 text-sm">
</MetaField> <MetaField label="Status">
<MetaField label="Category"> <StatusBadge status={task.status} />
<CategoryBadge category={task.category} /> </MetaField>
</MetaField> <MetaField label="Category">
<MetaField label="Priority"> <CategoryBadge category={task.category} />
<PriorityText priority={task.priority} /> </MetaField>
</MetaField> <MetaField label="Priority">
<MetaField label="Type"> <PriorityText priority={task.priority} />
<span className="font-medium">{task.type}</span> </MetaField>
</MetaField> <MetaField label="Type">
<MetaField label="Agent" span={2}> <span className="font-medium">{task.type}</span>
<span className="font-medium"> </MetaField>
{selectedEntry.agentName ?? "Unassigned"} <MetaField label="Agent" span={2}>
</span> <span className="font-medium">
</MetaField> {selectedEntry.agentName ?? "Unassigned"}
</div> </span>
</MetaField>
</div>
{/* Description — editable tiptap */} {/* Description — editable tiptap */}
<Section title="Description"> <Section title="Description">
<TiptapEditor <TiptapEditor
entityId={task.id} entityId={task.id}
content={editorContent} content={editorContent}
onUpdate={handleDescriptionUpdate} onUpdate={handleDescriptionUpdate}
enablePageLinks={false} enablePageLinks={false}
/> />
</Section> </Section>
{/* Dependencies */} {/* Dependencies */}
<Section title="Blocked By"> <Section title="Blocked By">
{dependencies.length === 0 ? ( {dependencies.length === 0 ? (
<p className="text-sm text-muted-foreground">None</p> <p className="text-sm text-muted-foreground">None</p>
) : ( ) : (
<ul className="space-y-1.5"> <ul className="space-y-1.5">
{dependencies.map((dep) => ( {dependencies.map((dep) => (
<li <li
key={dep.name} key={dep.name}
className="flex items-center gap-2 text-sm" className="flex items-center gap-2 text-sm"
> >
<StatusDot status={dep.status} size="sm" /> <StatusDot status={dep.status} size="sm" />
<span className="min-w-0 flex-1 truncate"> <span className="min-w-0 flex-1 truncate">
{dep.name} {dep.name}
</span> </span>
</li> </li>
))} ))}
</ul> </ul>
)} )}
</Section> </Section>
{/* Blocks */} {/* Blocks */}
<Section title="Blocks"> <Section title="Blocks">
{dependents.length === 0 ? ( {dependents.length === 0 ? (
<p className="text-sm text-muted-foreground">None</p> <p className="text-sm text-muted-foreground">None</p>
) : ( ) : (
<ul className="space-y-1.5"> <ul className="space-y-1.5">
{dependents.map((dep) => ( {dependents.map((dep) => (
<li <li
key={dep.name} key={dep.name}
className="flex items-center gap-2 text-sm" className="flex items-center gap-2 text-sm"
> >
<StatusDot status={dep.status} size="sm" /> <StatusDot status={dep.status} size="sm" />
<span className="min-w-0 flex-1 truncate"> <span className="min-w-0 flex-1 truncate">
{dep.name} {dep.name}
</span> </span>
</li> </li>
))} ))}
</ul> </ul>
)} )}
</Section> </Section>
</div>
) : (
<AgentLogsTab taskId={task.id} />
)}
</div> </div>
{/* Footer */} {/* Footer */}
@@ -293,6 +330,43 @@ export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) {
); );
} }
// ---------------------------------------------------------------------------
// Agent Logs Tab
// ---------------------------------------------------------------------------
function AgentLogsTab({ taskId }: { taskId: string }) {
const { data: agent, isLoading } = trpc.getTaskAgent.useQuery(
{ taskId },
{ refetchOnWindowFocus: false },
);
if (isLoading) {
return (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
Loading...
</div>
);
}
if (!agent) {
return (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
No agent has been assigned to this task yet.
</div>
);
}
return (
<div className="h-full">
<AgentOutputViewer
agentId={agent.id}
agentName={agent.name ?? undefined}
status={agent.status}
/>
</div>
);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Small helpers // Small helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------