Add conversation view for Claude

This commit is contained in:
Louis Knight-Webb
2025-06-22 23:27:39 +01:00
parent 551daaf16d
commit 2036e312bc
2 changed files with 152 additions and 19 deletions

View File

@@ -17,6 +17,7 @@ import {
interface JSONLLine {
type: string;
threadID?: string;
// Amp format
messages?: [
number,
{
@@ -63,7 +64,31 @@ interface JSONLLine {
state?: string;
tool?: string;
command?: string;
message?: string;
// Claude format
message?: {
role: "user" | "assistant" | "system";
content: Array<{
type: "text" | "tool_use" | "tool_result";
text?: string;
id?: string;
name?: string;
input?: any;
tool_use_id?: string;
content?: any;
is_error?: boolean;
}> | string;
};
// Tool rejection message (string format)
rejectionMessage?: string;
usage?: {
input_tokens: number;
output_tokens: number;
cache_creation_input_tokens?: number;
cache_read_input_tokens?: number;
};
result?: any;
duration_ms?: number;
total_cost_usd?: number;
error?: string; // For parse errors
}
@@ -89,6 +114,21 @@ const isValidMessage = (data: any): boolean => {
);
};
const isValidClaudeMessage = (data: any): boolean => {
return (
typeof data.role === "string" &&
(typeof data.content === "string" ||
(Array.isArray(data.content) &&
data.content.every(
(item: any) =>
typeof item.type === "string" &&
(item.type !== "text" || typeof item.text === "string") &&
(item.type !== "tool_use" || typeof item.name === "string") &&
(item.type !== "tool_result" || typeof item.content !== "undefined")
)))
);
};
const isValidTokenUsage = (data: any): boolean => {
return (
data &&
@@ -97,11 +137,19 @@ const isValidTokenUsage = (data: any): boolean => {
);
};
const isValidClaudeUsage = (data: any): boolean => {
return (
data &&
typeof data.input_tokens === "number" &&
typeof data.output_tokens === "number"
);
};
const isValidToolRejection = (data: any): boolean => {
return (
typeof data.tool === "string" &&
typeof data.command === "string" &&
typeof data.message === "string"
(typeof data.message === "string" || typeof data.rejectionMessage === "string")
);
};
@@ -200,19 +248,50 @@ export function ConversationViewer({ jsonlOutput }: ConversationViewerProps) {
isValidMessagesLine(line) &&
line.messages
) {
// Amp format
for (const [messageIndex, message] of line.messages) {
items.push({
type: "message",
...message,
role: message.role,
content: message.content,
timestamp: message.meta?.sentAt,
messageIndex,
lineIndex: line._lineIndex,
});
}
} else if (
(line.type === "user" || line.type === "assistant" || line.type === "system") &&
line.message &&
isValidClaudeMessage(line.message)
) {
// Claude format
const content = typeof line.message.content === "string"
? [{ type: "text", text: line.message.content }]
: line.message.content;
items.push({
type: "message",
role: line.message.role === "system" ? "assistant" : line.message.role,
content: content,
lineIndex: line._lineIndex,
});
} else if (
line.type === "result" &&
line.usage &&
isValidClaudeUsage(line.usage)
) {
// Claude usage info
tokenUsages.push({
used: line.usage.input_tokens + line.usage.output_tokens,
maxAvailable: line.usage.input_tokens + line.usage.output_tokens + 100000, // Approximate
lineIndex: line._lineIndex,
});
} else if (
line.type === "token-usage" &&
line.tokenUsage &&
isValidTokenUsage(line.tokenUsage)
) {
// Amp format
tokenUsages.push({
used: line.tokenUsage.used,
maxAvailable: line.tokenUsage.maxAvailable,
@@ -231,7 +310,7 @@ export function ConversationViewer({ jsonlOutput }: ConversationViewerProps) {
type: "tool-rejection",
tool: line.tool,
command: line.command,
message: line.message,
message: typeof line.message === "string" ? line.message : line.rejectionMessage || "Tool rejected",
lineIndex: line._lineIndex,
});
} else {
@@ -602,12 +681,16 @@ export function ConversationViewer({ jsonlOutput }: ConversationViewerProps) {
<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 && (
{content.run?.status ? (
<div
className={`w-2 h-2 rounded-full ${getToolStatusColor(
content.run.status
)}`}
/>
) : content.is_error ? (
<div className="w-2 h-2 rounded-full bg-red-500" />
) : (
<div className="w-2 h-2 rounded-full bg-green-500" />
)}
</div>
<span className="text-sm text-muted-foreground">
@@ -618,12 +701,24 @@ export function ConversationViewer({ jsonlOutput }: ConversationViewerProps) {
({safeRenderString(content.run.status)})
</span>
)}
{content.is_error && (
<span className="text-xs text-red-500">
(Error)
</span>
)}
</div>
{/* Amp format result */}
{content.run?.result && (
<pre className="text-xs bg-muted/50 p-2 rounded overflow-x-auto max-h-32">
{safeRenderString(content.run.result)}
</pre>
)}
{/* Claude format result */}
{content.content && !content.run && (
<pre className="text-xs bg-muted/50 p-2 rounded overflow-x-auto max-h-32">
{safeRenderString(content.content)}
</pre>
)}
{content.run?.toAllow && (
<div className="mt-2">
<p className="text-xs text-muted-foreground mb-1">

View File

@@ -1,4 +1,4 @@
import { useState, useMemo } from "react";
import { useState, useMemo, useEffect } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -15,27 +15,28 @@ export function ExecutionOutputViewer({
executionProcess,
executor,
}: ExecutionOutputViewerProps) {
const [viewMode, setViewMode] = useState<"conversation" | "raw">(
executor === "amp" ? "conversation" : "raw"
);
const [viewMode, setViewMode] = useState<"conversation" | "raw">("raw");
const isAmpExecutor = executor === "amp";
const isClaudeExecutor = executor === "claude";
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;
// Check if stdout looks like JSONL (for Amp or Claude executor)
const { isValidJsonl, jsonlFormat } = useMemo(() => {
if ((!isAmpExecutor && !isClaudeExecutor) || !executionProcess.stdout) {
return { isValidJsonl: false, jsonlFormat: null };
}
try {
const lines = executionProcess.stdout
.split("\n")
.filter((line) => line.trim());
if (lines.length === 0) return false;
if (lines.length === 0) return { isValidJsonl: false, jsonlFormat: null };
// 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) => {
const allValid = testLines.every((line) => {
try {
JSON.parse(line);
return true;
@@ -43,10 +44,42 @@ export function ExecutionOutputViewer({
return false;
}
});
if (!allValid) return { isValidJsonl: false, jsonlFormat: null };
// Detect format by checking for Amp vs Claude structure
let hasAmpFormat = false;
let hasClaudeFormat = false;
for (const line of testLines) {
try {
const parsed = JSON.parse(line);
if (parsed.type === "messages" || parsed.type === "token-usage") {
hasAmpFormat = true;
}
if (parsed.type === "user" || parsed.type === "assistant" || parsed.type === "system" || parsed.type === "result") {
hasClaudeFormat = true;
}
} catch {
// Skip invalid lines
}
}
return {
isValidJsonl: true,
jsonlFormat: hasAmpFormat ? "amp" : hasClaudeFormat ? "claude" : "unknown"
};
} catch {
return false;
return { isValidJsonl: false, jsonlFormat: null };
}
}, [isAmpExecutor, executionProcess.stdout]);
}, [isAmpExecutor, isClaudeExecutor, executionProcess.stdout]);
// Set initial view mode based on JSONL detection
useEffect(() => {
if (isValidJsonl) {
setViewMode("conversation");
}
}, [isValidJsonl]);
if (!hasStdout && !hasStderr) {
return (
@@ -64,13 +97,18 @@ export function ExecutionOutputViewer({
<Card className="">
<CardContent className="p-3">
<div className="space-y-3">
{/* View mode toggle for Amp executor with valid JSONL */}
{isAmpExecutor && isValidJsonl && hasStdout && (
{/* View mode toggle for executors with valid JSONL */}
{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>
{jsonlFormat && (
<Badge variant="secondary" className="text-xs">
{jsonlFormat} format
</Badge>
)}
</div>
<div className="flex items-center gap-1">
<Button
@@ -98,7 +136,7 @@ export function ExecutionOutputViewer({
{/* Output content */}
{hasStdout && (
<div>
{isAmpExecutor && isValidJsonl && viewMode === "conversation" ? (
{isValidJsonl && viewMode === "conversation" ? (
<ConversationViewer
jsonlOutput={executionProcess.stdout || ""}
/>