Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions components/TasksPage/Run/CompactRunDetails.tsx
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"
>
Comment on lines +91 to +95
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add rel for the new-tab link.

Line 93 opens a new tab but omits rel. Please add rel="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
Verify each finding against the current code and only fix it if needed.

In `@components/TasksPage/Run/CompactRunDetails.tsx` around lines 91 - 95, The
external link rendering in the Link component (the JSX element with
href={`/tasks/${runId}`} and target="_blank`} in CompactRunDetails) is missing
the rel attribute; update that Link to include rel="noopener noreferrer" to
prevent tabnabbing and ensure safe external/new-tab behavior.

Open full run
</Link>
</div>
{detailsContent}
</div>
</CollapsibleContent>
</Collapsible>
);
}
89 changes: 69 additions & 20 deletions components/TasksPage/Run/RunDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we adding a variant at all? What problem is this solving for musicians using Recoup?

}: 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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Open Closed Principle

  • actual: new state added to existing component.
  • required: new hook file for state needed in this component.


const detailsContent = (
<>
<RunTimeline
createdAt={data.createdAt}
startedAt={data.startedAt}
Expand Down Expand Up @@ -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 (
<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>
);
}
35 changes: 35 additions & 0 deletions components/TasksPage/Run/runDetailsConstants.ts
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",
};
19 changes: 13 additions & 6 deletions components/VercelChat/MessageParts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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.tsx

Repository: recoupable/chat

Length of output: 799


🏁 Script executed:

# Check the implementation of getOrderedMessageParts
find . -name "*getOrderedMessageParts*" -type f

Repository: 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 5

Repository: recoupable/chat

Length of output: 822


🏁 Script executed:

cat components/VercelChat/getOrderedMessageParts.ts

Repository: recoupable/chat

Length of output: 653


Use stable keys based on part identity, not rendered position.

The getOrderedMessageParts function reorders parts by moving deferred sandbox results to the end of the array. Since the key is derived from partIndex (the current rendered index), React will remount components when parts shift—resetting local UI state in stateful children like the expandable run card.

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
Verify each finding against the current code and only fix it if needed.

In `@components/VercelChat/MessageParts.tsx` around lines 30 - 37, The render keys
are unstable because getOrderedMessageParts reorders originalParts but keys are
derived from the rendered partIndex; change getOrderedMessageParts to attach the
original index (e.g., add originalIndex or sourceIndex property to each returned
part) and update all consumers (the render loop and lastTextPartIndex
computation) to use that preserved originalIndex as the React key and for
identity checks instead of the current partIndex; this ensures components (e.g.,
expandable run card) keep stable identity when parts are moved.


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}`;
Expand All @@ -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}
/>
);
Expand All @@ -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 (
Expand Down
20 changes: 20 additions & 0 deletions components/VercelChat/getOrderedMessageParts.ts
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Functions should be stored in lib, not components.

  • actual: components/VercelChat/getOrderedMessageParts.ts
  • required: lib/vercel/getOrderedMessageParts.ts

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];
}
26 changes: 26 additions & 0 deletions components/VercelChat/isDeferredSandboxResultPart.ts
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Functions should be stored in lib, not components.

  • actual: components/VercelChat/isDeferredSandboxResultPart.ts
  • required: lib/vercel/isDeferredSandboxResultPart.ts

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))
);
}
14 changes: 14 additions & 0 deletions components/VercelChat/tools/sandbox/CompactRunSkeleton.tsx
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>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 <RunPageSkeleton />;
return <CompactRunSkeleton />;
}

return <RunDetails runId={runId} data={data} />;
return <RunDetails runId={runId} data={data} variant="chat-compact" />;
}
Loading