-
Notifications
You must be signed in to change notification settings - Fork 13
Move sandbox run details to compact end-of-message card #1610
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <Collapsible | ||
| open={isOpen} | ||
| onOpenChange={onOpenChange} | ||
| className="w-full overflow-hidden rounded-2xl border bg-background/80 shadow-sm" | ||
| > | ||
| <CollapsibleTrigger asChild> | ||
| <button | ||
| type="button" | ||
| className="flex w-full cursor-pointer items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/40" | ||
| > | ||
| <div className="shrink-0">{statusIcon}</div> | ||
| <div className="min-w-0 flex-1"> | ||
| <div className="flex flex-wrap items-center gap-2"> | ||
| <p className="text-sm font-semibold text-foreground"> | ||
| {displayName} | ||
| </p> | ||
| <span | ||
| className={cn( | ||
| "inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium", | ||
| badgeClassName, | ||
| )} | ||
| > | ||
| {statusLabel} | ||
| </span> | ||
| </div> | ||
| <div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground"> | ||
| <span>{formatTimestamp(data.createdAt)}</span> | ||
| {displayDuration !== null && ( | ||
| <span>{formatDuration(displayDuration)}</span> | ||
| )} | ||
| {summaryText && ( | ||
| <span className="min-w-0 max-w-full truncate"> | ||
| {summaryText} | ||
| </span> | ||
| )} | ||
| </div> | ||
| </div> | ||
| <ChevronDown | ||
| className={cn( | ||
| "size-4 shrink-0 text-muted-foreground transition-transform", | ||
| isOpen && "rotate-180", | ||
| )} | ||
| /> | ||
| </button> | ||
| </CollapsibleTrigger> | ||
| <CollapsibleContent className="border-t bg-background/50"> | ||
| <div className="flex flex-col gap-6 p-4"> | ||
| <div className="flex items-center justify-between gap-3"> | ||
| <p className="text-xs font-medium uppercase tracking-wider text-muted-foreground"> | ||
| Run Details | ||
| </p> | ||
| <Link | ||
| href={`/tasks/${runId}`} | ||
| target="_blank" | ||
| className="text-xs font-medium text-muted-foreground transition-colors hover:text-foreground" | ||
| > | ||
| Open full run | ||
| </Link> | ||
| </div> | ||
| {detailsContent} | ||
| </div> | ||
| </CollapsibleContent> | ||
| </Collapsible> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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", | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why are we adding a |
||
| }: 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 ( | ||
| <div className="mx-auto flex flex-col gap-6 p-6"> | ||
| <div className="flex items-center gap-3"> | ||
| {config.icon} | ||
| <div> | ||
| {isOnRunPage ? ( | ||
| <h1 className="text-lg font-semibold">{displayName}</h1> | ||
| ) : ( | ||
| <Link | ||
| href={`/tasks/${runId}`} | ||
| target="_blank" | ||
| className="text-lg font-semibold hover:underline" | ||
| > | ||
| {displayName} | ||
| </Link> | ||
| )} | ||
| <p className={`text-sm ${config.color}`}>{config.label}</p> | ||
| </div> | ||
| </div> | ||
| useEffect(() => { | ||
| if (!isTerminal) { | ||
| setIsOpen(true); | ||
| } | ||
| }, [isTerminal]); | ||
|
Comment on lines
+35
to
+50
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Open Closed Principle
|
||
|
|
||
| const detailsContent = ( | ||
| <> | ||
| <RunTimeline | ||
| createdAt={data.createdAt} | ||
| startedAt={data.startedAt} | ||
|
|
@@ -78,6 +85,48 @@ export default function RunDetails({ runId, data }: RunDetailsProps) { | |
| <div className="text-xs text-muted-foreground"> | ||
| <AccountIdDisplay accountId={runId} label="Run" /> | ||
| </div> | ||
| </> | ||
| ); | ||
|
|
||
| if (variant === "chat-compact") { | ||
| return ( | ||
sweetmantech marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| <CompactRunDetails | ||
| runId={runId} | ||
| displayName={displayName} | ||
| displayDuration={displayDuration} | ||
| statusLabel={config.label} | ||
| statusIcon={config.icon} | ||
| badgeClassName={badgeClassName} | ||
| summaryText={summaryText} | ||
| isOpen={isOpen} | ||
| onOpenChange={setIsOpen} | ||
| detailsContent={detailsContent} | ||
| data={data} | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <div className="mx-auto flex flex-col gap-6 p-6"> | ||
| <div className="flex items-center gap-3"> | ||
| {config.icon} | ||
| <div> | ||
| {isOnRunPage ? ( | ||
| <h1 className="text-lg font-semibold">{displayName}</h1> | ||
| ) : ( | ||
| <Link | ||
| href={`/tasks/${runId}`} | ||
| target="_blank" | ||
| className="text-lg font-semibold hover:underline" | ||
| > | ||
| {displayName} | ||
| </Link> | ||
| )} | ||
| <p className={`text-sm ${config.color}`}>{config.label}</p> | ||
| </div> | ||
| </div> | ||
|
|
||
| {detailsContent} | ||
| </div> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| export const TERMINAL_STATUSES = new Set([ | ||
| "COMPLETED", | ||
| "FAILED", | ||
| "CRASHED", | ||
| "CANCELED", | ||
| "SYSTEM_FAILURE", | ||
| "INTERRUPTED", | ||
| ]); | ||
|
|
||
| export const STATUS_BADGE_CLASSES: Record<string, string> = { | ||
| 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", | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
| ); | ||
|
Comment on lines
+30
to
+37
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Verify that reordered parts are rendered from `orderedParts`
# while the row key still derives from the rendered index.
# Expected: one match for `getOrderedMessageParts(...)` and one match
# showing `message-${message.id}-part-${partIndex}`.
rg -n -C2 'getOrderedMessageParts|message-\$\{message\.id\}-part-\$\{partIndex\}' components/VercelChat/MessageParts.tsxRepository: recoupable/chat Length of output: 799 🏁 Script executed: # Check the implementation of getOrderedMessageParts
find . -name "*getOrderedMessageParts*" -type fRepository: recoupable/chat Length of output: 108 🏁 Script executed: # Also check the complete key derivation logic and map structure
rg -n "orderedParts\.map|getOrderedMessagePart" components/VercelChat/MessageParts.tsx -A 5Repository: recoupable/chat Length of output: 822 🏁 Script executed: cat components/VercelChat/getOrderedMessageParts.tsRepository: recoupable/chat Length of output: 653 Use stable keys based on part identity, not rendered position. The Preserve the original index through reordering and use it as the key source instead: ♻️ Suggested approach+export interface OrderedMessagePart {
+ part: UIMessagePart<UIDataTypes, UITools>;
+ originalIndex: number;
+}
+
export function getOrderedMessageParts(
parts: UIMessagePart<UIDataTypes, UITools>[],
-) {
- const regularParts: UIMessagePart<UIDataTypes, UITools>[] = [];
- const deferredSandboxParts: UIMessagePart<UIDataTypes, UITools>[] = [];
+): OrderedMessagePart[] {
+ const regularParts: OrderedMessagePart[] = [];
+ const deferredSandboxParts: OrderedMessagePart[] = [];
- for (const part of parts) {
+ for (const [originalIndex, part] of parts.entries()) {
+ const orderedPart = { part, originalIndex };
if (isDeferredSandboxResultPart(part)) {
- deferredSandboxParts.push(part);
+ deferredSandboxParts.push(orderedPart);
continue;
}
- regularParts.push(part);
+ regularParts.push(orderedPart);
}
return [...regularParts, ...deferredSandboxParts];
}Then update the render loop: - {orderedParts.map(
- (part: UIMessagePart<UIDataTypes, UITools>, partIndex) => {
+ {orderedParts.map(({ part, originalIndex }, partIndex) => {
const { type } = part;
- const key = `message-${message.id}-part-${partIndex}`;
+ const key = `message-${message.id}-part-${originalIndex}`;🤖 Prompt for AI Agents |
||
|
|
||
| return ( | ||
| <div className={cn("flex flex-col gap-4 w-full group")}> | ||
| {message.parts?.map( | ||
| {orderedParts.map( | ||
| (part: UIMessagePart<UIDataTypes, UITools>, 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 ( | ||
|
|
||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Functions should be stored in
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| import { UIDataTypes, UIMessagePart, UITools } from "ai"; | ||
| import { isDeferredSandboxResultPart } from "./isDeferredSandboxResultPart"; | ||
|
|
||
| export function getOrderedMessageParts( | ||
| parts: UIMessagePart<UIDataTypes, UITools>[], | ||
| ) { | ||
| const regularParts: UIMessagePart<UIDataTypes, UITools>[] = []; | ||
| const deferredSandboxParts: UIMessagePart<UIDataTypes, UITools>[] = []; | ||
|
|
||
| for (const part of parts) { | ||
| if (isDeferredSandboxResultPart(part)) { | ||
| deferredSandboxParts.push(part); | ||
| continue; | ||
| } | ||
|
|
||
| regularParts.push(part); | ||
| } | ||
|
|
||
| return [...regularParts, ...deferredSandboxParts]; | ||
| } |
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Functions should be stored in lib, not components.
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<UIDataTypes, UITools>, | ||
| ) { | ||
| if (!isToolOrDynamicToolUIPart(part)) { | ||
| return false; | ||
| } | ||
|
|
||
| return ( | ||
| (part as ToolUIPart).state === "output-available" && | ||
| DEFERRED_SANDBOX_TOOL_NAMES.has(getToolOrDynamicToolName(part)) | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| export default function CompactRunSkeleton() { | ||
| return ( | ||
| <div className="w-full rounded-2xl border bg-background/80 px-4 py-3 shadow-sm"> | ||
| <div className="flex items-center gap-3"> | ||
| <div className="size-5 animate-pulse rounded-full bg-muted" /> | ||
| <div className="min-w-0 flex-1"> | ||
| <div className="h-4 w-36 animate-pulse rounded bg-muted" /> | ||
| <div className="mt-2 h-3 w-56 animate-pulse rounded bg-muted" /> | ||
| </div> | ||
| <div className="size-4 animate-pulse rounded bg-muted" /> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add
relfor the new-tab link.Line 93 opens a new tab but omits
rel. Please addrel="noopener noreferrer"to harden against tabnabbing.🔐 Suggested fix
<Link href={`/tasks/${runId}`} target="_blank" + rel="noopener noreferrer" className="text-xs font-medium text-muted-foreground transition-colors hover:text-foreground" >🤖 Prompt for AI Agents