diff --git a/frontend/src/components/tasks/ConversationViewer.tsx b/frontend/src/components/tasks/ConversationViewer.tsx new file mode 100644 index 00000000..0c4bc19f --- /dev/null +++ b/frontend/src/components/tasks/ConversationViewer.tsx @@ -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>( + 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 ( + + +

+ No valid JSONL data found +

+
+
+ ); + } + + const latestTokenUsage = + conversation.tokenUsages[conversation.tokenUsages.length - 1]; + + return ( +
+ {/* Header with token usage */} +
+
+ + LLM Conversation +
+
+ {latestTokenUsage && ( + + + {latestTokenUsage.used.toLocaleString()} /{" "} + {latestTokenUsage.maxAvailable.toLocaleString()} tokens + + )} + +
+
+ + {/* Token usage details */} + {showTokenUsage && conversation.tokenUsages.length > 0 && ( + + + Token Usage Timeline + + +
+ {conversation.tokenUsages.map((usage, index) => ( +
+ + Step {index + 1} + + + {usage.used.toLocaleString()} /{" "} + {usage.maxAvailable.toLocaleString()} + +
+ ))} +
+
+
+ )} + + {/* Conversation items (messages and tool rejections) */} +
+ {conversation.items.map((item, index) => { + if (item.type === "parse-error") { + return ( + + +
+ + + Parse Error + +
+
+

+ Raw JSONL: +

+
+                      {safeRenderString(item.rawLine)}
+                    
+
+
+
+ ); + } + + 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 ( + + +
+ + + Unknown + +
+
+

JSONL:

+
+                      {safeRenderString(prettyJson)}
+                    
+
+
+
+ ); + } + + if (item.type === "tool-rejection") { + return ( + + +
+ + + Tool Rejected + + + {safeRenderString(item.tool)} + +
+
+
+

+ Command: +

+
+                        {safeRenderString(item.command)}
+                      
+
+
+

+ Message: +

+

+ {safeRenderString(item.message)} +

+
+
+
+
+ ); + } + + if (item.type === "message") { + const messageId = `message-${index}`; + const isExpanded = expandedMessages.has(messageId); + const hasThinking = item.content?.some( + (c: any) => c.type === "thinking" + ); + + return ( + + +
+ + {item.role} + + {item.timestamp && ( +
+ + {new Date(item.timestamp).toLocaleTimeString()} +
+ )} + {hasThinking && ( + + )} +
+ +
+ {item.content?.map((content: any, contentIndex: number) => { + if (content.type === "text") { + return ( +
+

+ {safeRenderString(content.text)} +

+
+ ); + } + + if (content.type === "thinking" && isExpanded) { + return ( +
+
+ + 💭 Thinking + +
+
+ {safeRenderString(content.thinking)} +
+
+ ); + } + + if (content.type === "tool_use") { + return ( +
+
+ + + {safeRenderString(content.name)} + +
+ {content.input && ( +
+                                {formatToolInput(content.input)}
+                              
+ )} +
+ ); + } + + if (content.type === "tool_result") { + return ( +
+
+
+ {content.run?.status && ( +
+ )} +
+ + Result + + {content.run?.status && ( + + ({safeRenderString(content.run.status)}) + + )} +
+ {content.run?.result && ( +
+                                {safeRenderString(content.run.result)}
+                              
+ )} + {content.run?.toAllow && ( +
+

+ Commands to allow: +

+
+ {content.run.toAllow.map( + (cmd: string, i: number) => ( + + {safeRenderString(cmd)} + + ) + )} +
+
+ )} +
+ ); + } + + return null; + })} +
+ + + ); + } + + return null; + })} +
+
+ ); +} diff --git a/frontend/src/components/tasks/ExecutionOutputViewer.tsx b/frontend/src/components/tasks/ExecutionOutputViewer.tsx new file mode 100644 index 00000000..38300bb4 --- /dev/null +++ b/frontend/src/components/tasks/ExecutionOutputViewer.tsx @@ -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 ( + + +
+ Waiting for output... +
+
+
+ ); + } + + return ( + + +
+ {/* View mode toggle for Amp executor with valid JSONL */} + {isAmpExecutor && isValidJsonl && hasStdout && ( +
+
+ + {executor} output + +
+
+ + +
+
+ )} + + {/* Output content */} + {hasStdout && ( +
+ {isAmpExecutor && isValidJsonl && viewMode === "conversation" ? ( + + ) : ( +
+
+                    {executionProcess.stdout}
+                  
+
+ )} +
+ )} + + {hasStderr && ( +
+
+                {executionProcess.stderr}
+              
+
+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/tasks/TaskDetailsPanel.tsx b/frontend/src/components/tasks/TaskDetailsPanel.tsx index 87c93e91..fbf8ff2e 100644 --- a/frontend/src/components/tasks/TaskDetailsPanel.tsx +++ b/frontend/src/components/tasks/TaskDetailsPanel.tsx @@ -11,10 +11,11 @@ import { Settings2, } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; + import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Chip } from "@/components/ui/chip"; +import { ExecutionOutputViewer } from "./ExecutionOutputViewer"; import { DropdownMenu, @@ -578,52 +579,12 @@ export function TaskDetailsPanel({ executionProcesses[ activity.execution_process_id ] && ( - - - {executionProcesses[ - activity.execution_process_id - ].stdout && ( -
-
-                                              {
-                                                executionProcesses[
-                                                  activity.execution_process_id
-                                                ].stdout
-                                              }
-                                            
- - stdout - -
- )} - {executionProcesses[ - activity.execution_process_id - ].stderr && ( -
-
-                                              {
-                                                executionProcesses[
-                                                  activity.execution_process_id
-                                                ].stderr
-                                              }
-                                            
- - stdout - -
- )} - {!executionProcesses[ - activity.execution_process_id - ].stdout && - !executionProcesses[ - activity.execution_process_id - ].stderr && ( -
- Waiting for output... -
- )} -
-
+
+ +
)}
))}