119 lines
4.0 KiB
TypeScript
119 lines
4.0 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef } from "react";
|
|
import { formatDistanceToNow } from "date-fns";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import type { BadgeVariant } from "@/components/ui/badge";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import {
|
|
useSessionStream,
|
|
type MissionControlConnectionStatus,
|
|
type MissionControlMessageRole,
|
|
} from "@/hooks/useMissionControl";
|
|
|
|
const ROLE_BADGE_VARIANT: Record<MissionControlMessageRole, BadgeVariant> = {
|
|
user: "badge-blue",
|
|
assistant: "status-success",
|
|
tool: "badge-amber",
|
|
system: "badge-muted",
|
|
};
|
|
|
|
const CONNECTION_DOT_CLASS: Record<MissionControlConnectionStatus, string> = {
|
|
connected: "bg-emerald-500",
|
|
connecting: "bg-amber-500",
|
|
error: "bg-red-500",
|
|
};
|
|
|
|
const CONNECTION_TEXT: Record<MissionControlConnectionStatus, string> = {
|
|
connected: "Connected",
|
|
connecting: "Connecting",
|
|
error: "Error",
|
|
};
|
|
|
|
export interface OrchestratorPanelProps {
|
|
sessionId?: string;
|
|
}
|
|
|
|
function formatRelativeTimestamp(timestamp: string): string {
|
|
const parsedDate = new Date(timestamp);
|
|
if (Number.isNaN(parsedDate.getTime())) {
|
|
return "just now";
|
|
}
|
|
|
|
return formatDistanceToNow(parsedDate, { addSuffix: true });
|
|
}
|
|
|
|
export function OrchestratorPanel({ sessionId }: OrchestratorPanelProps): React.JSX.Element {
|
|
const { messages, status, error } = useSessionStream(sessionId ?? "");
|
|
const bottomAnchorRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
useEffect(() => {
|
|
bottomAnchorRef.current?.scrollIntoView({ block: "end" });
|
|
}, [messages.length]);
|
|
|
|
if (!sessionId) {
|
|
return (
|
|
<Card className="flex h-full min-h-[220px] flex-col">
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Orchestrator Panel</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
|
Select an agent to view its stream
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Card className="flex h-full min-h-[220px] flex-col">
|
|
<CardHeader className="space-y-2">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<CardTitle className="text-base">Orchestrator Panel</CardTitle>
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<span
|
|
className={`h-2.5 w-2.5 rounded-full ${CONNECTION_DOT_CLASS[status]} ${
|
|
status === "connecting" ? "animate-pulse" : ""
|
|
}`}
|
|
aria-hidden="true"
|
|
/>
|
|
<span>{CONNECTION_TEXT[status]}</span>
|
|
</div>
|
|
</div>
|
|
<p className="truncate text-xs text-muted-foreground">Session: {sessionId}</p>
|
|
</CardHeader>
|
|
<CardContent className="flex min-h-0 flex-1 p-0">
|
|
<ScrollArea className="h-full w-full">
|
|
<div className="flex min-h-full flex-col gap-3 p-4">
|
|
{messages.length === 0 ? (
|
|
<p className="mt-6 text-center text-sm text-muted-foreground">
|
|
{error ?? "Waiting for messages..."}
|
|
</p>
|
|
) : (
|
|
messages.map((message) => (
|
|
<article
|
|
key={message.id}
|
|
className="rounded-lg border border-border/70 bg-card px-3 py-2"
|
|
>
|
|
<div className="mb-2 flex items-center justify-between gap-2">
|
|
<Badge variant={ROLE_BADGE_VARIANT[message.role]} className="uppercase">
|
|
{message.role}
|
|
</Badge>
|
|
<time className="text-xs text-muted-foreground">
|
|
{formatRelativeTimestamp(message.timestamp)}
|
|
</time>
|
|
</div>
|
|
<p className="whitespace-pre-wrap break-words text-sm text-foreground">
|
|
{message.content}
|
|
</p>
|
|
</article>
|
|
))
|
|
)}
|
|
<div ref={bottomAnchorRef} />
|
|
</div>
|
|
</ScrollArea>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|