Frontend render conversations nicely
This commit is contained in:
667
frontend/src/components/tasks/ConversationViewer.tsx
Normal file
667
frontend/src/components/tasks/ConversationViewer.tsx
Normal file
@@ -0,0 +1,667 @@
|
|||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
import {
|
||||||
|
PersonStanding,
|
||||||
|
Brain,
|
||||||
|
Wrench as Tool,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Clock,
|
||||||
|
Zap,
|
||||||
|
AlertTriangle,
|
||||||
|
FileText,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface JSONLLine {
|
||||||
|
type: string;
|
||||||
|
threadID?: string;
|
||||||
|
messages?: [
|
||||||
|
number,
|
||||||
|
{
|
||||||
|
role: "user" | "assistant";
|
||||||
|
content: Array<{
|
||||||
|
type: "text" | "thinking" | "tool_use" | "tool_result";
|
||||||
|
text?: string;
|
||||||
|
thinking?: string;
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
input?: any;
|
||||||
|
toolUseID?: string;
|
||||||
|
run?: {
|
||||||
|
status: string;
|
||||||
|
result?: string;
|
||||||
|
toAllow?: string[];
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
meta?: {
|
||||||
|
sentAt: number;
|
||||||
|
};
|
||||||
|
state?: {
|
||||||
|
type: string;
|
||||||
|
stopReason?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
][];
|
||||||
|
toolResults?: Array<{
|
||||||
|
type: "tool_use" | "tool_result";
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
input?: any;
|
||||||
|
toolUseID?: string;
|
||||||
|
run?: {
|
||||||
|
status: string;
|
||||||
|
result?: string;
|
||||||
|
toAllow?: string[];
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
tokenUsage?: {
|
||||||
|
used: number;
|
||||||
|
maxAvailable: number;
|
||||||
|
};
|
||||||
|
state?: string;
|
||||||
|
tool?: string;
|
||||||
|
command?: string;
|
||||||
|
message?: string;
|
||||||
|
error?: string; // For parse errors
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConversationViewerProps {
|
||||||
|
jsonlOutput: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation functions
|
||||||
|
const isValidMessage = (data: any): boolean => {
|
||||||
|
return (
|
||||||
|
typeof data.role === "string" &&
|
||||||
|
Array.isArray(data.content) &&
|
||||||
|
data.content.every(
|
||||||
|
(item: any) =>
|
||||||
|
typeof item.type === "string" &&
|
||||||
|
(item.type !== "text" || typeof item.text === "string") &&
|
||||||
|
(item.type !== "thinking" || typeof item.thinking === "string") &&
|
||||||
|
(item.type !== "tool_use" || typeof item.name === "string") &&
|
||||||
|
(item.type !== "tool_result" ||
|
||||||
|
!item.run ||
|
||||||
|
typeof item.run.status === "string")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValidTokenUsage = (data: any): boolean => {
|
||||||
|
return (
|
||||||
|
data &&
|
||||||
|
typeof data.used === "number" &&
|
||||||
|
typeof data.maxAvailable === "number"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValidToolRejection = (data: any): boolean => {
|
||||||
|
return (
|
||||||
|
typeof data.tool === "string" &&
|
||||||
|
typeof data.command === "string" &&
|
||||||
|
typeof data.message === "string"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValidMessagesLine = (line: any): boolean => {
|
||||||
|
return (
|
||||||
|
Array.isArray(line.messages) &&
|
||||||
|
line.messages.every(
|
||||||
|
(msg: any) =>
|
||||||
|
Array.isArray(msg) &&
|
||||||
|
msg.length >= 2 &&
|
||||||
|
typeof msg[0] === "number" &&
|
||||||
|
isValidMessage(msg[1])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ConversationViewer({ jsonlOutput }: ConversationViewerProps) {
|
||||||
|
const [expandedMessages, setExpandedMessages] = useState<Set<string>>(
|
||||||
|
new Set()
|
||||||
|
);
|
||||||
|
const [showTokenUsage, setShowTokenUsage] = useState(false);
|
||||||
|
|
||||||
|
const parsedLines = useMemo(() => {
|
||||||
|
try {
|
||||||
|
return jsonlOutput
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.trim())
|
||||||
|
.map((line, index) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(line);
|
||||||
|
return {
|
||||||
|
...parsed,
|
||||||
|
_lineIndex: index,
|
||||||
|
_rawLine: line,
|
||||||
|
} as JSONLLine & { _lineIndex: number; _rawLine: string };
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
type: "parse-error",
|
||||||
|
_lineIndex: index,
|
||||||
|
_rawLine: line,
|
||||||
|
error: "Failed to parse JSON",
|
||||||
|
} as JSONLLine & {
|
||||||
|
_lineIndex: number;
|
||||||
|
_rawLine: string;
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [jsonlOutput]);
|
||||||
|
|
||||||
|
const conversation = useMemo(() => {
|
||||||
|
const items: Array<{
|
||||||
|
type: "message" | "tool-rejection" | "parse-error" | "unknown";
|
||||||
|
role?: "user" | "assistant";
|
||||||
|
content?: Array<{
|
||||||
|
type: string;
|
||||||
|
text?: string;
|
||||||
|
thinking?: string;
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
input?: any;
|
||||||
|
toolUseID?: string;
|
||||||
|
run?: any;
|
||||||
|
}>;
|
||||||
|
timestamp?: number;
|
||||||
|
messageIndex?: number;
|
||||||
|
lineIndex?: number;
|
||||||
|
tool?: string;
|
||||||
|
command?: string;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
rawLine?: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
const tokenUsages: Array<{
|
||||||
|
used: number;
|
||||||
|
maxAvailable: number;
|
||||||
|
lineIndex: number;
|
||||||
|
}> = [];
|
||||||
|
const states: Array<{ state: string; lineIndex: number }> = [];
|
||||||
|
|
||||||
|
for (const line of parsedLines) {
|
||||||
|
try {
|
||||||
|
if (line.type === "parse-error") {
|
||||||
|
items.push({
|
||||||
|
type: "parse-error",
|
||||||
|
error: line.error,
|
||||||
|
rawLine: line._rawLine,
|
||||||
|
lineIndex: line._lineIndex,
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
|
line.type === "messages" &&
|
||||||
|
isValidMessagesLine(line) &&
|
||||||
|
line.messages
|
||||||
|
) {
|
||||||
|
for (const [messageIndex, message] of line.messages) {
|
||||||
|
items.push({
|
||||||
|
type: "message",
|
||||||
|
...message,
|
||||||
|
messageIndex,
|
||||||
|
lineIndex: line._lineIndex,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
line.type === "token-usage" &&
|
||||||
|
line.tokenUsage &&
|
||||||
|
isValidTokenUsage(line.tokenUsage)
|
||||||
|
) {
|
||||||
|
tokenUsages.push({
|
||||||
|
used: line.tokenUsage.used,
|
||||||
|
maxAvailable: line.tokenUsage.maxAvailable,
|
||||||
|
lineIndex: line._lineIndex,
|
||||||
|
});
|
||||||
|
} else if (line.type === "state" && typeof line.state === "string") {
|
||||||
|
states.push({
|
||||||
|
state: line.state,
|
||||||
|
lineIndex: line._lineIndex,
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
|
line.type === "tool-rejected" &&
|
||||||
|
isValidToolRejection(line)
|
||||||
|
) {
|
||||||
|
items.push({
|
||||||
|
type: "tool-rejection",
|
||||||
|
tool: line.tool,
|
||||||
|
command: line.command,
|
||||||
|
message: line.message,
|
||||||
|
lineIndex: line._lineIndex,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Unknown line type or invalid structure - add as unknown for fallback rendering
|
||||||
|
items.push({
|
||||||
|
type: "unknown",
|
||||||
|
rawLine: line._rawLine,
|
||||||
|
lineIndex: line._lineIndex,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// If anything goes wrong processing a line, treat it as unknown
|
||||||
|
items.push({
|
||||||
|
type: "unknown",
|
||||||
|
rawLine: line._rawLine,
|
||||||
|
lineIndex: line._lineIndex,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by messageIndex for messages, then by lineIndex for everything else
|
||||||
|
items.sort((a, b) => {
|
||||||
|
if (a.type === "message" && b.type === "message") {
|
||||||
|
return (a.messageIndex || 0) - (b.messageIndex || 0);
|
||||||
|
}
|
||||||
|
return (a.lineIndex || 0) - (b.lineIndex || 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
tokenUsages,
|
||||||
|
states,
|
||||||
|
};
|
||||||
|
}, [parsedLines]);
|
||||||
|
|
||||||
|
const toggleMessage = (messageId: string) => {
|
||||||
|
const newExpanded = new Set(expandedMessages);
|
||||||
|
if (newExpanded.has(messageId)) {
|
||||||
|
newExpanded.delete(messageId);
|
||||||
|
} else {
|
||||||
|
newExpanded.add(messageId);
|
||||||
|
}
|
||||||
|
setExpandedMessages(newExpanded);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatToolInput = (input: any): string => {
|
||||||
|
try {
|
||||||
|
if (input === null || input === undefined) {
|
||||||
|
return String(input);
|
||||||
|
}
|
||||||
|
if (typeof input === "object") {
|
||||||
|
// Try to stringify, but handle circular references and complex objects
|
||||||
|
return JSON.stringify(input);
|
||||||
|
}
|
||||||
|
return String(input);
|
||||||
|
} catch (error) {
|
||||||
|
// If anything goes wrong, return a safe fallback
|
||||||
|
return `[Unable to display input: ${String(input).substring(0, 100)}...]`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const safeRenderString = (value: any): string => {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
if (typeof value === "object") {
|
||||||
|
try {
|
||||||
|
// Use the same safe JSON.stringify logic as formatToolInput
|
||||||
|
return "(RAW)" + JSON.stringify(value);
|
||||||
|
} catch (error) {
|
||||||
|
return `[Object - serialization failed: ${String(value).substring(
|
||||||
|
0,
|
||||||
|
50
|
||||||
|
)}...]`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getToolStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case "done":
|
||||||
|
return "bg-green-500";
|
||||||
|
case "rejected-by-user":
|
||||||
|
case "blocked-on-user":
|
||||||
|
return "bg-yellow-500";
|
||||||
|
case "error":
|
||||||
|
return "bg-red-500";
|
||||||
|
default:
|
||||||
|
return "bg-blue-500";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (parsedLines.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No valid JSONL data found
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestTokenUsage =
|
||||||
|
conversation.tokenUsages[conversation.tokenUsages.length - 1];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header with token usage */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Brain className="h-4 w-4" />
|
||||||
|
<span className="text-sm font-medium">LLM Conversation</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{latestTokenUsage && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
<Zap className="h-3 w-3 mr-1" />
|
||||||
|
{latestTokenUsage.used.toLocaleString()} /{" "}
|
||||||
|
{latestTokenUsage.maxAvailable.toLocaleString()} tokens
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowTokenUsage(!showTokenUsage)}
|
||||||
|
>
|
||||||
|
{showTokenUsage ? (
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Token usage details */}
|
||||||
|
{showTokenUsage && conversation.tokenUsages.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm">Token Usage Timeline</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{conversation.tokenUsages.map((usage, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center justify-between text-xs"
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Step {index + 1}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{usage.used.toLocaleString()} /{" "}
|
||||||
|
{usage.maxAvailable.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Conversation items (messages and tool rejections) */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{conversation.items.map((item, index) => {
|
||||||
|
if (item.type === "parse-error") {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={`error-${index}`}
|
||||||
|
className="bg-yellow-50 border-yellow-200"
|
||||||
|
>
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-yellow-600" />
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
Parse Error
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground mb-1">
|
||||||
|
Raw JSONL:
|
||||||
|
</p>
|
||||||
|
<pre className="text-xs bg-white p-2 rounded border overflow-x-auto whitespace-pre-wrap">
|
||||||
|
{safeRenderString(item.rawLine)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.type === "unknown") {
|
||||||
|
let prettyJson = item.rawLine;
|
||||||
|
try {
|
||||||
|
prettyJson = JSON.stringify(
|
||||||
|
JSON.parse(item.rawLine || "{}"),
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Keep as is if can't prettify
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={`unknown-${index}`}
|
||||||
|
className="bg-gray-50 border-gray-200"
|
||||||
|
>
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<FileText className="h-4 w-4 text-gray-600" />
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
Unknown
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground mb-1">JSONL:</p>
|
||||||
|
<pre className="text-xs bg-white p-2 rounded border overflow-x-auto whitespace-pre-wrap">
|
||||||
|
{safeRenderString(prettyJson)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.type === "tool-rejection") {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={`rejection-${index}`}
|
||||||
|
className="bg-red-50 border-red-200"
|
||||||
|
>
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-red-600" />
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
Tool Rejected
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{safeRenderString(item.tool)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground mb-1">
|
||||||
|
Command:
|
||||||
|
</p>
|
||||||
|
<pre className="text-xs bg-white p-2 rounded border overflow-x-auto">
|
||||||
|
{safeRenderString(item.command)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground mb-1">
|
||||||
|
Message:
|
||||||
|
</p>
|
||||||
|
<p className="text-xs bg-white p-2 rounded border">
|
||||||
|
{safeRenderString(item.message)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.type === "message") {
|
||||||
|
const messageId = `message-${index}`;
|
||||||
|
const isExpanded = expandedMessages.has(messageId);
|
||||||
|
const hasThinking = item.content?.some(
|
||||||
|
(c: any) => c.type === "thinking"
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={messageId}
|
||||||
|
className={`${
|
||||||
|
item.role === "user"
|
||||||
|
? "bg-blue-50 border-blue-200 ml-12"
|
||||||
|
: "bg-gray-50 border-gray-200 mr-12"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-sm font-medium capitalize">
|
||||||
|
{item.role}
|
||||||
|
</span>
|
||||||
|
{item.timestamp && (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{new Date(item.timestamp).toLocaleTimeString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasThinking && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => toggleMessage(messageId)}
|
||||||
|
className="h-6 px-2 text-xs"
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<>
|
||||||
|
<ChevronUp className="h-3 w-3 mr-1" />
|
||||||
|
Hide thinking
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChevronDown className="h-3 w-3 mr-1" />
|
||||||
|
Show thinking
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{item.content?.map((content: any, contentIndex: number) => {
|
||||||
|
if (content.type === "text") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={contentIndex}
|
||||||
|
className="prose prose-sm max-w-none"
|
||||||
|
>
|
||||||
|
<p className="text-sm whitespace-pre-wrap">
|
||||||
|
{safeRenderString(content.text)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content.type === "thinking" && isExpanded) {
|
||||||
|
return (
|
||||||
|
<div key={contentIndex} className="mt-3">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
💭 Thinking
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground italic whitespace-pre-wrap">
|
||||||
|
{safeRenderString(content.thinking)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content.type === "tool_use") {
|
||||||
|
return (
|
||||||
|
<div key={contentIndex} className="mt-3">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Tool className="h-4 w-4 text-green-600" />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{safeRenderString(content.name)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{content.input && (
|
||||||
|
<pre className="text-xs bg-muted/50 p-2 rounded overflow-x-auto max-h-32">
|
||||||
|
{formatToolInput(content.input)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content.type === "tool_result") {
|
||||||
|
return (
|
||||||
|
<div key={contentIndex} className="mt-3">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<div className="w-4 h-4 flex items-center justify-center">
|
||||||
|
{content.run?.status && (
|
||||||
|
<div
|
||||||
|
className={`w-2 h-2 rounded-full ${getToolStatusColor(
|
||||||
|
content.run.status
|
||||||
|
)}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Result
|
||||||
|
</span>
|
||||||
|
{content.run?.status && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
({safeRenderString(content.run.status)})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{content.run?.result && (
|
||||||
|
<pre className="text-xs bg-muted/50 p-2 rounded overflow-x-auto max-h-32">
|
||||||
|
{safeRenderString(content.run.result)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
{content.run?.toAllow && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="text-xs text-muted-foreground mb-1">
|
||||||
|
Commands to allow:
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{content.run.toAllow.map(
|
||||||
|
(cmd: string, i: number) => (
|
||||||
|
<code
|
||||||
|
key={i}
|
||||||
|
className="text-xs bg-muted px-1 rounded"
|
||||||
|
>
|
||||||
|
{safeRenderString(cmd)}
|
||||||
|
</code>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
frontend/src/components/tasks/ExecutionOutputViewer.tsx
Normal file
127
frontend/src/components/tasks/ExecutionOutputViewer.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Chip } from "@/components/ui/chip";
|
||||||
|
import { FileText, MessageSquare } from "lucide-react";
|
||||||
|
import { ConversationViewer } from "./ConversationViewer";
|
||||||
|
import type { ExecutionProcess } from "shared/types";
|
||||||
|
|
||||||
|
interface ExecutionOutputViewerProps {
|
||||||
|
executionProcess: ExecutionProcess;
|
||||||
|
executor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExecutionOutputViewer({
|
||||||
|
executionProcess,
|
||||||
|
executor,
|
||||||
|
}: ExecutionOutputViewerProps) {
|
||||||
|
const [viewMode, setViewMode] = useState<"conversation" | "raw">(
|
||||||
|
executor === "amp" ? "conversation" : "raw"
|
||||||
|
);
|
||||||
|
|
||||||
|
const isAmpExecutor = executor === "amp";
|
||||||
|
const hasStdout = !!executionProcess.stdout;
|
||||||
|
const hasStderr = !!executionProcess.stderr;
|
||||||
|
|
||||||
|
// Check if stdout looks like JSONL (for Amp executor)
|
||||||
|
const isValidJsonl = useMemo(() => {
|
||||||
|
if (!isAmpExecutor || !executionProcess.stdout) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const lines = executionProcess.stdout
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.trim());
|
||||||
|
if (lines.length === 0) return false;
|
||||||
|
|
||||||
|
// Try to parse at least the first few lines as JSON
|
||||||
|
const testLines = lines.slice(0, Math.min(3, lines.length));
|
||||||
|
return testLines.every((line) => {
|
||||||
|
try {
|
||||||
|
JSON.parse(line);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [isAmpExecutor, executionProcess.stdout]);
|
||||||
|
|
||||||
|
if (!hasStdout && !hasStderr) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-muted border-none">
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<div className="text-xs text-muted-foreground italic text-center">
|
||||||
|
Waiting for output...
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-muted border-none">
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* View mode toggle for Amp executor with valid JSONL */}
|
||||||
|
{isAmpExecutor && isValidJsonl && hasStdout && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{executor} output
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant={viewMode === "conversation" ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setViewMode("conversation")}
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
>
|
||||||
|
<MessageSquare className="h-3 w-3 mr-1" />
|
||||||
|
Conversation
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={viewMode === "raw" ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setViewMode("raw")}
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
>
|
||||||
|
<FileText className="h-3 w-3 mr-1" />
|
||||||
|
Raw
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Output content */}
|
||||||
|
{hasStdout && (
|
||||||
|
<div>
|
||||||
|
{isAmpExecutor && isValidJsonl && viewMode === "conversation" ? (
|
||||||
|
<ConversationViewer
|
||||||
|
jsonlOutput={executionProcess.stdout || ""}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<pre className="text-xs overflow-x-auto whitespace-pre-wrap p-2">
|
||||||
|
{executionProcess.stdout}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasStderr && (
|
||||||
|
<div>
|
||||||
|
<pre className="text-xs overflow-x-auto whitespace-pre-wrap p-2 text-red-600">
|
||||||
|
{executionProcess.stderr}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,10 +11,11 @@ import {
|
|||||||
Settings2,
|
Settings2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Chip } from "@/components/ui/chip";
|
import { Chip } from "@/components/ui/chip";
|
||||||
|
import { ExecutionOutputViewer } from "./ExecutionOutputViewer";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -578,52 +579,12 @@ export function TaskDetailsPanel({
|
|||||||
executionProcesses[
|
executionProcesses[
|
||||||
activity.execution_process_id
|
activity.execution_process_id
|
||||||
] && (
|
] && (
|
||||||
<Card className="mt-2 bg-muted border-none">
|
<div className="mt-2">
|
||||||
<CardContent className="p-3">
|
<ExecutionOutputViewer
|
||||||
{executionProcesses[
|
executionProcess={executionProcesses[activity.execution_process_id]}
|
||||||
activity.execution_process_id
|
executor={selectedAttempt?.executor || undefined}
|
||||||
].stdout && (
|
/>
|
||||||
<div>
|
</div>
|
||||||
<pre className="text-xs rounded overflow-x-auto whitespace-pre-wrap mb-2">
|
|
||||||
{
|
|
||||||
executionProcesses[
|
|
||||||
activity.execution_process_id
|
|
||||||
].stdout
|
|
||||||
}
|
|
||||||
</pre>
|
|
||||||
<Chip dotColor="bg-green-600">
|
|
||||||
stdout
|
|
||||||
</Chip>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{executionProcesses[
|
|
||||||
activity.execution_process_id
|
|
||||||
].stderr && (
|
|
||||||
<div>
|
|
||||||
<pre className="text-xs rounded border overflow-x-auto whitespace-pre-wrap mb-2">
|
|
||||||
{
|
|
||||||
executionProcesses[
|
|
||||||
activity.execution_process_id
|
|
||||||
].stderr
|
|
||||||
}
|
|
||||||
</pre>
|
|
||||||
<Chip dotColor="bg-red-600">
|
|
||||||
stdout
|
|
||||||
</Chip>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!executionProcesses[
|
|
||||||
activity.execution_process_id
|
|
||||||
].stdout &&
|
|
||||||
!executionProcesses[
|
|
||||||
activity.execution_process_id
|
|
||||||
].stderr && (
|
|
||||||
<div className="text-xs text-muted-foreground italic">
|
|
||||||
Waiting for output...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user