Frontend render conversations nicely

This commit is contained in:
Louis Knight-Webb
2025-06-21 22:12:32 +01:00
parent 83a9c1b796
commit 5405fac819
3 changed files with 802 additions and 47 deletions

View 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>
);
}

View 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>
);
}

View File

@@ -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
] && (
<Card className="mt-2 bg-muted border-none">
<CardContent className="p-3">
{executionProcesses[
activity.execution_process_id
].stdout && (
<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 className="mt-2">
<ExecutionOutputViewer
executionProcess={executionProcesses[activity.execution_process_id]}
executor={selectedAttempt?.executor || undefined}
/>
</div>
)}
</div>
))}