Files
kandev/apps/web/components/task/chat/messages/agent-status.tsx
T

287 lines
9.6 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState, useCallback } from "react";
import { IconAlertCircle, IconAlertTriangle, IconChevronDown } from "@tabler/icons-react";
import type { Message, TaskSessionState } from "@/lib/types/http";
import { useSessionTurn } from "@/hooks/domains/session/use-session-turn";
import { useAppStore } from "@/components/state-provider";
import { GridSpinner } from "@/components/grid-spinner";
import { resolveAgentErrorLabel } from "./agent-error-label";
type AgentStatusProps = {
sessionState?: TaskSessionState;
sessionId: string | null;
messages?: Message[];
};
const STATE_CONFIG: Record<
TaskSessionState,
{ label: string; dynamicLabel?: boolean; icon: "spinner" | "error" | "warning" | null }
> = {
CREATED: { label: "", icon: null },
STARTING: { label: "Agent is starting", dynamicLabel: true, icon: "spinner" },
RUNNING: { label: "Agent is running", icon: "spinner" },
IDLE: { label: "", icon: null },
WAITING_FOR_INPUT: { label: "", icon: null },
COMPLETED: { label: "", icon: null },
FAILED: { label: "Agent has encountered an error", icon: "error" },
CANCELLED: { label: "", icon: null },
};
/**
* Format duration in seconds to a human-readable string.
*/
function formatDuration(seconds: number): string {
if (seconds < 0) return "0s";
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) return `${hours}h ${minutes}m ${secs}s`;
if (minutes > 0) return `${minutes}m ${secs}s`;
return `${secs}s`;
}
/**
* Calculate turn duration from messages as a fallback when turn data is not available.
* Finds the last user message and the last agent message to estimate duration.
*/
function calculateTurnDurationFromMessages(messages: Message[]): string | null {
if (messages.length < 2) return null;
// Find last agent message
let lastAgentMsg: Message | null = null;
let lastUserMsgBeforeAgent: Message | null = null;
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (!lastAgentMsg && msg.author_type === "agent") {
lastAgentMsg = msg;
} else if (lastAgentMsg && msg.author_type === "user") {
lastUserMsgBeforeAgent = msg;
break;
}
}
if (!lastAgentMsg || !lastUserMsgBeforeAgent) return null;
const startTime = new Date(lastUserMsgBeforeAgent.created_at).getTime();
const endTime = new Date(lastAgentMsg.created_at).getTime();
const durationSeconds = Math.floor((endTime - startTime) / 1000);
if (durationSeconds < 0) return null;
return formatDuration(durationSeconds);
}
/**
* Hook to track elapsed time while agent is running.
* Uses the turn's started_at timestamp to calculate elapsed time, so it persists across page refreshes.
*/
function useRunningTimer(isRunning: boolean, turnStartedAt: string | null) {
const [elapsedSeconds, setElapsedSeconds] = useState(() => {
if (!isRunning || !turnStartedAt) return 0;
return Math.floor((Date.now() - new Date(turnStartedAt).getTime()) / 1000);
});
useEffect(() => {
if (!isRunning || !turnStartedAt) {
return;
}
const startTime = new Date(turnStartedAt).getTime();
const updateElapsed = () => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
setElapsedSeconds(elapsed);
};
// Update in the interval callback (not synchronously in effect)
const interval = setInterval(updateElapsed, 1000);
// Also update once immediately, but in the next tick to avoid sync update
const timeoutId = setTimeout(updateElapsed, 0);
return () => {
clearInterval(interval);
clearTimeout(timeoutId);
};
}, [isRunning, turnStartedAt]);
// Keep showing elapsed time while isRunning, even if turnStartedAt was cleared
const displaySeconds = isRunning ? elapsedSeconds : 0;
// Format as Xs or XmXs
const formatted = useMemo(() => {
return formatDuration(displaySeconds);
}, [displaySeconds]);
return { elapsedSeconds: displaySeconds, formatted };
}
function useActiveTurn(sessionId: string | null) {
const turns = useAppStore((state) => (sessionId ? state.turns.bySession[sessionId] : undefined));
const activeTurnId = useAppStore((state) =>
sessionId ? state.turns.activeBySession[sessionId] : null,
);
return useMemo(() => {
if (!turns || !activeTurnId) return null;
return turns.find((t) => t.id === activeTurnId) ?? null;
}, [turns, activeTurnId]);
}
function AgentErrorStatus({
config,
sessionId,
}: {
config: { label: string };
sessionId: string | null;
}) {
const errorMessage = useAppStore((state) =>
sessionId
? (state.taskSessions.items[sessionId]?.error_message as string | undefined)
: undefined,
);
const [userCollapsed, setUserCollapsed] = useState(false);
const expanded = !!errorMessage && !userCollapsed;
const toggle = useCallback(() => setUserCollapsed((v) => !v), []);
const displayLabel = resolveAgentErrorLabel(errorMessage, config.label);
const hasDetails = !!errorMessage;
return (
<div
className="rounded-lg text-xs bg-destructive/10 text-destructive border border-destructive/20"
role="status"
aria-label={displayLabel}
>
<button
type="button"
className={`flex w-full items-center gap-2 px-3 py-2 ${hasDetails ? "cursor-pointer" : ""}`}
onClick={hasDetails ? toggle : undefined}
disabled={!hasDetails}
>
<IconAlertCircle className="h-3.5 w-3.5 flex-shrink-0" aria-hidden="true" />
<span className="font-medium">{displayLabel}</span>
{hasDetails && (
<IconChevronDown
className={`ml-auto h-3.5 w-3.5 transition-transform ${expanded ? "rotate-180" : ""}`}
/>
)}
</button>
{expanded && errorMessage && (
<pre className="px-3 pb-2 whitespace-pre-wrap break-words text-[11px] text-destructive/80 max-h-40 overflow-y-auto">
{errorMessage}
</pre>
)}
</div>
);
}
function AgentWarningStatus({ config }: { config: { label: string } }) {
return (
<div
className="flex items-center gap-2 px-3 py-2 rounded-lg text-xs bg-yellow-500/10 text-yellow-600 dark:text-yellow-500 border border-yellow-500/20"
role="status"
aria-label={config.label}
>
<IconAlertTriangle className="h-3.5 w-3.5 flex-shrink-0" aria-hidden="true" />
<span className="font-medium">{config.label}</span>
</div>
);
}
function AgentRunningStatus({
config,
elapsedSeconds,
runningDuration,
}: {
config: { label: string };
elapsedSeconds: number;
runningDuration: string;
}) {
return (
<div className="py-2" role="status" aria-label={config.label}>
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
{config.label}
<GridSpinner className="text-muted-foreground" />
{elapsedSeconds > 0 && (
<span className="text-muted-foreground/60 tabular-nums">{runningDuration}</span>
)}
</span>
</div>
);
}
function useAgentStatusData(sessionId: string | null, messages: Message[], isRunning: boolean) {
const { lastTurnDuration, isActive: isTurnActive } = useSessionTurn(sessionId);
const activeTurn = useActiveTurn(sessionId);
const { formatted: runningDuration, elapsedSeconds } = useRunningTimer(
isRunning,
activeTurn?.started_at ?? null,
);
const fallbackDuration = useMemo(() => {
if (lastTurnDuration) return null;
return calculateTurnDurationFromMessages(messages);
}, [messages, lastTurnDuration]);
const displayDuration = lastTurnDuration?.formatted ?? fallbackDuration;
return { isTurnActive, runningDuration, elapsedSeconds, displayDuration };
}
function renderActiveStatus(
config: { label: string; icon: string },
sessionId: string | null,
runningData: ReturnType<typeof useAgentStatusData>,
): React.ReactNode {
switch (config.icon) {
case "error":
return <AgentErrorStatus config={config} sessionId={sessionId} />;
case "warning":
return <AgentWarningStatus config={config} />;
case "spinner":
return (
<AgentRunningStatus
config={config}
elapsedSeconds={runningData.elapsedSeconds}
runningDuration={runningData.runningDuration}
/>
);
default:
return null;
}
}
function useAgentLabel(sessionId: string | null, dynamicLabel?: boolean): string | null {
const agentProfileId = useAppStore((state) =>
sessionId ? state.taskSessions.items[sessionId]?.agent_profile_id : undefined,
) as string | undefined;
const agentProfiles = useAppStore((state) => state.agentProfiles.items);
if (!dynamicLabel || !agentProfileId) return null;
const profile = agentProfiles.find((p) => p.id === agentProfileId);
return profile ? profile.label.split(" \u2022 ")[0] : null;
}
export function AgentStatus({ sessionState, sessionId, messages = [] }: AgentStatusProps) {
const config = sessionState ? STATE_CONFIG[sessionState] : null;
const isRunning = config?.icon === "spinner";
const agentLabel = useAgentLabel(sessionId, config?.dynamicLabel);
const runningData = useAgentStatusData(sessionId, messages, isRunning);
if (config?.icon) {
const label = agentLabel ? `Starting ${agentLabel}` : config.label;
return renderActiveStatus({ label, icon: config.icon }, sessionId, runningData);
}
const displayDuration = runningData.displayDuration;
if (!runningData.isTurnActive && displayDuration) {
return (
<div className="flex items-center gap-2 py-2">
<span className="inline-flex items-center gap-2 text-xs text-muted-foreground tabular-nums">
{displayDuration}
</span>
</div>
);
}
return null;
}