diff --git a/components/TasksPage/Run/CompactRunDetails.tsx b/components/TasksPage/Run/CompactRunDetails.tsx new file mode 100644 index 000000000..8ddc4c044 --- /dev/null +++ b/components/TasksPage/Run/CompactRunDetails.tsx @@ -0,0 +1,104 @@ +"use client"; + +import Link from "next/link"; +import type { ReactNode } from "react"; +import { ChevronDown } from "lucide-react"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import type { TaskRunStatus } from "@/lib/tasks/getTaskRunStatus"; +import { formatTimestamp } from "@/lib/tasks/formatTimestamp"; +import { formatDuration } from "@/lib/tasks/formatDuration"; +import { cn } from "@/lib/utils"; + +interface CompactRunDetailsProps { + runId: string; + displayName: string; + displayDuration: number | null; + statusLabel: string; + statusIcon: ReactNode; + badgeClassName: string; + summaryText?: string; + isOpen: boolean; + onOpenChange: (open: boolean) => void; + detailsContent: ReactNode; + data: TaskRunStatus; +} + +export default function CompactRunDetails({ + runId, + displayName, + displayDuration, + statusLabel, + statusIcon, + badgeClassName, + summaryText, + isOpen, + onOpenChange, + detailsContent, + data, +}: CompactRunDetailsProps) { + return ( + + + + + +
+
+

+ Run Details +

+ + Open full run + +
+ {detailsContent} +
+
+
+ ); +} diff --git a/components/TasksPage/Run/RunDetails.tsx b/components/TasksPage/Run/RunDetails.tsx index 40dae5966..d17c56a37 100644 --- a/components/TasksPage/Run/RunDetails.tsx +++ b/components/TasksPage/Run/RunDetails.tsx @@ -2,48 +2,55 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; +import { useEffect, useState } from "react"; import type { TaskRunStatus } from "@/lib/tasks/getTaskRunStatus"; import { getTaskDisplayName } from "@/lib/tasks/getTaskDisplayName"; +import { useElapsedMs } from "@/hooks/useElapsedMs"; import { ERROR_STATUSES, STATUS_CONFIG, FALLBACK_CONFIG } from "./statusConfig"; import RunLogsList from "./RunLogsList"; import RunTimeline from "./RunTimeline"; import RunOutput from "./RunOutput"; import RunErrorDetails from "./RunErrorDetails"; import AccountIdDisplay from "@/components/ArtistSetting/AccountIdDisplay"; +import { STATUS_BADGE_CLASSES, TERMINAL_STATUSES } from "./runDetailsConstants"; +import CompactRunDetails from "./CompactRunDetails"; interface RunDetailsProps { runId: string; data: TaskRunStatus; + variant?: "full" | "chat-compact"; } -export default function RunDetails({ runId, data }: RunDetailsProps) { +export default function RunDetails({ + runId, + data, + variant = "full", +}: RunDetailsProps) { const config = STATUS_CONFIG[data.status] ?? FALLBACK_CONFIG; const logs = data.metadata?.logs ?? []; const currentStep = data.metadata?.currentStep; const pathname = usePathname(); const isOnRunPage = pathname === `/tasks/${runId}`; const displayName = getTaskDisplayName(data.taskIdentifier); + const displayDuration = useElapsedMs(data.startedAt, data.durationMs); + const isTerminal = TERMINAL_STATUSES.has(data.status); + const [isOpen, setIsOpen] = useState(!isTerminal); + const badgeClassName = + STATUS_BADGE_CLASSES[data.status] ?? + "border-border bg-muted/60 text-muted-foreground"; + const summaryText = + ERROR_STATUSES.has(data.status) && data.error?.message + ? data.error.message + : currentStep; - return ( -
-
- {config.icon} -
- {isOnRunPage ? ( -

{displayName}

- ) : ( - - {displayName} - - )} -

{config.label}

-
-
+ useEffect(() => { + if (!isTerminal) { + setIsOpen(true); + } + }, [isTerminal]); + const detailsContent = ( + <>
+ + ); + + if (variant === "chat-compact") { + return ( + + ); + } + + return ( +
+
+ {config.icon} +
+ {isOnRunPage ? ( +

{displayName}

+ ) : ( + + {displayName} + + )} +

{config.label}

+
+
+ + {detailsContent}
); } diff --git a/components/TasksPage/Run/runDetailsConstants.ts b/components/TasksPage/Run/runDetailsConstants.ts new file mode 100644 index 000000000..35c60153d --- /dev/null +++ b/components/TasksPage/Run/runDetailsConstants.ts @@ -0,0 +1,35 @@ +export const TERMINAL_STATUSES = new Set([ + "COMPLETED", + "FAILED", + "CRASHED", + "CANCELED", + "SYSTEM_FAILURE", + "INTERRUPTED", +]); + +export const STATUS_BADGE_CLASSES: Record = { + COMPLETED: + "border-green-200 bg-green-50 text-green-700 dark:border-green-900/60 dark:bg-green-950/50 dark:text-green-300", + FAILED: + "border-red-200 bg-red-50 text-red-700 dark:border-red-900/60 dark:bg-red-950/50 dark:text-red-300", + CRASHED: + "border-red-200 bg-red-50 text-red-700 dark:border-red-900/60 dark:bg-red-950/50 dark:text-red-300", + SYSTEM_FAILURE: + "border-red-200 bg-red-50 text-red-700 dark:border-red-900/60 dark:bg-red-950/50 dark:text-red-300", + INTERRUPTED: + "border-red-200 bg-red-50 text-red-700 dark:border-red-900/60 dark:bg-red-950/50 dark:text-red-300", + CANCELED: + "border-gray-200 bg-gray-50 text-gray-700 dark:border-gray-800 dark:bg-gray-950/50 dark:text-gray-300", + EXECUTING: + "border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-900/60 dark:bg-yellow-950/50 dark:text-yellow-300", + REATTEMPTING: + "border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-900/60 dark:bg-yellow-950/50 dark:text-yellow-300", + QUEUED: + "border-gray-200 bg-gray-50 text-gray-700 dark:border-gray-800 dark:bg-gray-950/50 dark:text-gray-300", + DELAYED: + "border-gray-200 bg-gray-50 text-gray-700 dark:border-gray-800 dark:bg-gray-950/50 dark:text-gray-300", + FROZEN: + "border-gray-200 bg-gray-50 text-gray-700 dark:border-gray-800 dark:bg-gray-950/50 dark:text-gray-300", + PENDING_VERSION: + "border-gray-200 bg-gray-50 text-gray-700 dark:border-gray-800 dark:bg-gray-950/50 dark:text-gray-300", +}; diff --git a/components/VercelChat/MessageParts.tsx b/components/VercelChat/MessageParts.tsx index 1e522c846..107129926 100644 --- a/components/VercelChat/MessageParts.tsx +++ b/components/VercelChat/MessageParts.tsx @@ -17,6 +17,7 @@ import { Actions, Action } from "@/components/actions"; import { RefreshCcwIcon, Pencil } from "lucide-react"; import CopyAction from "./CopyAction"; import { useVercelChatContext } from "@/providers/VercelChatProvider"; +import { getOrderedMessageParts } from "./getOrderedMessageParts"; interface MessagePartsProps { message: UIMessage; @@ -26,9 +27,18 @@ interface MessagePartsProps { export function MessageParts({ message, mode, setMode }: MessagePartsProps) { const { status, reload } = useVercelChatContext(); + const originalParts = message.parts ?? []; + const orderedParts = getOrderedMessageParts(originalParts); + const lastOriginalPart = originalParts[originalParts.length - 1]; + const lastTextPartIndex = orderedParts.reduce( + (lastIndex, part, partIndex) => + part.type === "text" ? partIndex : lastIndex, + -1, + ); + return (
- {message.parts?.map( + {orderedParts.map( (part: UIMessagePart, partIndex) => { const { type } = part; const key = `message-${message.id}-part-${partIndex}`; @@ -39,10 +49,7 @@ export function MessageParts({ message, mode, setMode }: MessagePartsProps) { key={key} className="w-full" content={part.text} - isStreaming={ - status === "streaming" && - partIndex === message.parts.length - 1 - } + isStreaming={status === "streaming" && part === lastOriginalPart} defaultOpen={true} /> ); @@ -56,7 +63,7 @@ export function MessageParts({ message, mode, setMode }: MessagePartsProps) { const isLastMessage = message.role === "assistant" && status !== "streaming" && - partIndex === message.parts.length - 1; + partIndex === lastTextPartIndex; if (mode === "view") { return ( diff --git a/components/VercelChat/getOrderedMessageParts.ts b/components/VercelChat/getOrderedMessageParts.ts new file mode 100644 index 000000000..8f9ce239b --- /dev/null +++ b/components/VercelChat/getOrderedMessageParts.ts @@ -0,0 +1,20 @@ +import { UIDataTypes, UIMessagePart, UITools } from "ai"; +import { isDeferredSandboxResultPart } from "./isDeferredSandboxResultPart"; + +export function getOrderedMessageParts( + parts: UIMessagePart[], +) { + const regularParts: UIMessagePart[] = []; + const deferredSandboxParts: UIMessagePart[] = []; + + for (const part of parts) { + if (isDeferredSandboxResultPart(part)) { + deferredSandboxParts.push(part); + continue; + } + + regularParts.push(part); + } + + return [...regularParts, ...deferredSandboxParts]; +} diff --git a/components/VercelChat/isDeferredSandboxResultPart.ts b/components/VercelChat/isDeferredSandboxResultPart.ts new file mode 100644 index 000000000..53db49e7e --- /dev/null +++ b/components/VercelChat/isDeferredSandboxResultPart.ts @@ -0,0 +1,26 @@ +import { + ToolUIPart, + UIDataTypes, + UIMessagePart, + UITools, + getToolOrDynamicToolName, + isToolOrDynamicToolUIPart, +} from "ai"; + +const DEFERRED_SANDBOX_TOOL_NAMES = new Set([ + "get_task_run_status", + "prompt_sandbox", +]); + +export function isDeferredSandboxResultPart( + part: UIMessagePart, +) { + if (!isToolOrDynamicToolUIPart(part)) { + return false; + } + + return ( + (part as ToolUIPart).state === "output-available" && + DEFERRED_SANDBOX_TOOL_NAMES.has(getToolOrDynamicToolName(part)) + ); +} diff --git a/components/VercelChat/tools/sandbox/CompactRunSkeleton.tsx b/components/VercelChat/tools/sandbox/CompactRunSkeleton.tsx new file mode 100644 index 000000000..cf16ce359 --- /dev/null +++ b/components/VercelChat/tools/sandbox/CompactRunSkeleton.tsx @@ -0,0 +1,14 @@ +export default function CompactRunSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+ ); +} diff --git a/components/VercelChat/tools/sandbox/RunSandboxCommandResultWithPolling.tsx b/components/VercelChat/tools/sandbox/RunSandboxCommandResultWithPolling.tsx index 5e3ed4e9c..92cd332fe 100644 --- a/components/VercelChat/tools/sandbox/RunSandboxCommandResultWithPolling.tsx +++ b/components/VercelChat/tools/sandbox/RunSandboxCommandResultWithPolling.tsx @@ -2,14 +2,14 @@ import { useTaskRunStatus } from "@/hooks/useTaskRunStatus"; import RunDetails from "@/components/TasksPage/Run/RunDetails"; -import RunPageSkeleton from "@/components/TasksPage/Run/RunPageSkeleton"; +import CompactRunSkeleton from "./CompactRunSkeleton"; export default function RunSandboxCommandResultWithPolling({ runId }: { runId: string }) { const { data, isLoading } = useTaskRunStatus(runId); if (isLoading || !data) { - return ; + return ; } - return ; + return ; }