218 lines
7.0 KiB
TypeScript
218 lines
7.0 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useRef, useState } from "react";
|
||
import { formatDistanceToNow } from "date-fns";
|
||
import { BargeInInput } from "@/components/mission-control/BargeInInput";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import type { BadgeVariant } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { PanelControls } from "@/components/mission-control/PanelControls";
|
||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||
import {
|
||
useSessionStream,
|
||
useSessions,
|
||
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;
|
||
onClose?: () => void;
|
||
closeDisabled?: boolean;
|
||
onExpand?: () => void;
|
||
expanded?: boolean;
|
||
}
|
||
|
||
interface PanelHeaderActionsProps {
|
||
onClose?: () => void;
|
||
closeDisabled?: boolean;
|
||
onExpand?: () => void;
|
||
expanded?: boolean;
|
||
}
|
||
|
||
function PanelHeaderActions({
|
||
onClose,
|
||
closeDisabled = false,
|
||
onExpand,
|
||
expanded = false,
|
||
}: PanelHeaderActionsProps): React.JSX.Element | null {
|
||
if (!onClose && !onExpand) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<div className="flex items-center gap-1">
|
||
{onExpand ? (
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-7 w-7"
|
||
onClick={onExpand}
|
||
aria-label={expanded ? "Collapse panel" : "Expand panel"}
|
||
title={expanded ? "Collapse panel" : "Expand panel"}
|
||
>
|
||
<span aria-hidden="true" className="text-base leading-none">
|
||
{expanded ? "↙" : "↗"}
|
||
</span>
|
||
</Button>
|
||
) : null}
|
||
{onClose ? (
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-7 w-7"
|
||
onClick={onClose}
|
||
disabled={closeDisabled}
|
||
aria-label="Remove panel"
|
||
title="Remove panel"
|
||
>
|
||
<span aria-hidden="true" className="text-base leading-none">
|
||
×
|
||
</span>
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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,
|
||
onClose,
|
||
closeDisabled,
|
||
onExpand,
|
||
expanded,
|
||
}: OrchestratorPanelProps): React.JSX.Element {
|
||
const { messages, status, error } = useSessionStream(sessionId ?? "");
|
||
const { sessions } = useSessions();
|
||
const bottomAnchorRef = useRef<HTMLDivElement | null>(null);
|
||
const [optimisticStatus, setOptimisticStatus] = useState<string | null>(null);
|
||
|
||
const selectedSessionStatus = sessions.find((session) => session.id === sessionId)?.status;
|
||
const controlsStatus = optimisticStatus ?? selectedSessionStatus ?? "unknown";
|
||
const panelHeaderActionProps = {
|
||
...(onClose !== undefined ? { onClose } : {}),
|
||
...(closeDisabled !== undefined ? { closeDisabled } : {}),
|
||
...(onExpand !== undefined ? { onExpand } : {}),
|
||
...(expanded !== undefined ? { expanded } : {}),
|
||
};
|
||
|
||
useEffect(() => {
|
||
bottomAnchorRef.current?.scrollIntoView({ block: "end" });
|
||
}, [messages.length]);
|
||
|
||
useEffect(() => {
|
||
setOptimisticStatus(null);
|
||
}, [sessionId, selectedSessionStatus]);
|
||
|
||
if (!sessionId) {
|
||
return (
|
||
<Card className="flex h-full min-h-[220px] flex-col">
|
||
<CardHeader>
|
||
<div className="flex items-start justify-between gap-2">
|
||
<CardTitle className="text-base">Orchestrator Panel</CardTitle>
|
||
<PanelHeaderActions {...panelHeaderActionProps} />
|
||
</div>
|
||
</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-start justify-between gap-2">
|
||
<CardTitle className="text-base">Orchestrator Panel</CardTitle>
|
||
<PanelHeaderActions {...panelHeaderActionProps} />
|
||
</div>
|
||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||
<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>
|
||
<PanelControls
|
||
sessionId={sessionId}
|
||
status={controlsStatus}
|
||
onStatusChange={setOptimisticStatus}
|
||
/>
|
||
</div>
|
||
<p className="truncate text-xs text-muted-foreground">Session: {sessionId}</p>
|
||
</CardHeader>
|
||
<CardContent className="flex min-h-0 flex-1 flex-col p-0">
|
||
<div className="min-h-0 flex-1">
|
||
<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>
|
||
</div>
|
||
<div className="border-t border-border/70 p-3">
|
||
<BargeInInput sessionId={sessionId} />
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|