mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
feat: improve agent output modal with parsed log viewer
Add a parsed log viewer to the agent output modal that groups and colors log entries by type (tool calls, phases, errors, success messages, etc.) similar to Coolify logs. Includes: - New log-parser.ts utility for parsing agent output into structured entries - New LogViewer component with collapsible entries and colored badges - Toggle between Parsed and Raw view modes in the modal header - Type-specific colors (amber for tools, cyan for phases, red for errors) - Expand/Collapse all buttons for better navigation - JSON content detection and formatting within entries Generated with Claude Code Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -44,5 +44,12 @@
|
||||
"try to scroll "
|
||||
],
|
||||
"status": "verified"
|
||||
},
|
||||
{
|
||||
"id": "feature-1765310151816-plx1pxl0z",
|
||||
"category": "Kanban",
|
||||
"description": "So i want to improve the look of the view of agent output modal its just plain text and im thinking to parse it better and kinda make it look like the last image of coolify logs nice colorded and somehow grouped into some types of info / debug so in our case like prompt / tool call etc",
|
||||
"steps": [],
|
||||
"status": "verified"
|
||||
}
|
||||
]
|
||||
269
app/src/components/ui/log-viewer.tsx
Normal file
269
app/src/components/ui/log-viewer.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
MessageSquare,
|
||||
Wrench,
|
||||
Zap,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
Bug,
|
||||
Info,
|
||||
FileOutput,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
parseLogOutput,
|
||||
getLogTypeColors,
|
||||
type LogEntry,
|
||||
type LogEntryType,
|
||||
} from "@/lib/log-parser";
|
||||
|
||||
interface LogViewerProps {
|
||||
output: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const getLogIcon = (type: LogEntryType) => {
|
||||
switch (type) {
|
||||
case "prompt":
|
||||
return <MessageSquare className="w-4 h-4" />;
|
||||
case "tool_call":
|
||||
return <Wrench className="w-4 h-4" />;
|
||||
case "tool_result":
|
||||
return <FileOutput className="w-4 h-4" />;
|
||||
case "phase":
|
||||
return <Zap className="w-4 h-4" />;
|
||||
case "error":
|
||||
return <AlertCircle className="w-4 h-4" />;
|
||||
case "success":
|
||||
return <CheckCircle2 className="w-4 h-4" />;
|
||||
case "warning":
|
||||
return <AlertTriangle className="w-4 h-4" />;
|
||||
case "debug":
|
||||
return <Bug className="w-4 h-4" />;
|
||||
default:
|
||||
return <Info className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
interface LogEntryItemProps {
|
||||
entry: LogEntry;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
|
||||
const colors = getLogTypeColors(entry.type);
|
||||
const hasContent = entry.content.length > 100;
|
||||
|
||||
// Format content - detect and highlight JSON
|
||||
const formattedContent = useMemo(() => {
|
||||
const content = entry.content;
|
||||
|
||||
// Try to find and format JSON blocks
|
||||
const jsonRegex = /(\{[\s\S]*?\}|\[[\s\S]*?\])/g;
|
||||
let lastIndex = 0;
|
||||
const parts: { type: "text" | "json"; content: string }[] = [];
|
||||
|
||||
let match;
|
||||
while ((match = jsonRegex.exec(content)) !== null) {
|
||||
// Add text before JSON
|
||||
if (match.index > lastIndex) {
|
||||
parts.push({
|
||||
type: "text",
|
||||
content: content.slice(lastIndex, match.index),
|
||||
});
|
||||
}
|
||||
|
||||
// Try to parse and format JSON
|
||||
try {
|
||||
const parsed = JSON.parse(match[1]);
|
||||
parts.push({
|
||||
type: "json",
|
||||
content: JSON.stringify(parsed, null, 2),
|
||||
});
|
||||
} catch {
|
||||
// Not valid JSON, treat as text
|
||||
parts.push({ type: "text", content: match[1] });
|
||||
}
|
||||
|
||||
lastIndex = match.index + match[1].length;
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if (lastIndex < content.length) {
|
||||
parts.push({ type: "text", content: content.slice(lastIndex) });
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts : [{ type: "text" as const, content }];
|
||||
}, [entry.content]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border-l-4 transition-all duration-200",
|
||||
colors.bg,
|
||||
colors.border,
|
||||
"hover:brightness-110"
|
||||
)}
|
||||
data-testid={`log-entry-${entry.type}`}
|
||||
>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full px-3 py-2 flex items-center gap-2 text-left"
|
||||
data-testid={`log-entry-toggle-${entry.id}`}
|
||||
>
|
||||
{hasContent ? (
|
||||
isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-zinc-400 flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-zinc-400 flex-shrink-0" />
|
||||
)
|
||||
) : (
|
||||
<span className="w-4 flex-shrink-0" />
|
||||
)}
|
||||
|
||||
<span className={cn("flex-shrink-0", colors.icon)}>
|
||||
{getLogIcon(entry.type)}
|
||||
</span>
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium px-2 py-0.5 rounded-full flex-shrink-0",
|
||||
colors.badge
|
||||
)}
|
||||
data-testid="log-entry-badge"
|
||||
>
|
||||
{entry.title}
|
||||
</span>
|
||||
|
||||
<span className="text-sm text-zinc-400 truncate flex-1 ml-2">
|
||||
{!isExpanded &&
|
||||
entry.content.slice(0, 80) +
|
||||
(entry.content.length > 80 ? "..." : "")}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{(isExpanded || !hasContent) && (
|
||||
<div
|
||||
className="px-4 pb-3 pt-1"
|
||||
data-testid={`log-entry-content-${entry.id}`}
|
||||
>
|
||||
<div className="font-mono text-sm space-y-1">
|
||||
{formattedContent.map((part, index) => (
|
||||
<div key={index}>
|
||||
{part.type === "json" ? (
|
||||
<pre className="bg-zinc-900/50 rounded p-2 overflow-x-auto text-xs text-purple-300">
|
||||
{part.content}
|
||||
</pre>
|
||||
) : (
|
||||
<pre
|
||||
className={cn(
|
||||
"whitespace-pre-wrap break-words",
|
||||
colors.text
|
||||
)}
|
||||
>
|
||||
{part.content}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LogViewer({ output, className }: LogViewerProps) {
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const entries = useMemo(() => parseLogOutput(output), [output]);
|
||||
|
||||
const toggleEntry = (id: string) => {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const expandAll = () => {
|
||||
setExpandedIds(new Set(entries.map((e) => e.id)));
|
||||
};
|
||||
|
||||
const collapseAll = () => {
|
||||
setExpandedIds(new Set());
|
||||
};
|
||||
|
||||
if (entries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Count entries by type
|
||||
const typeCounts = entries.reduce((acc, entry) => {
|
||||
acc[entry.type] = (acc[entry.type] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-2", className)}>
|
||||
{/* Header with controls */}
|
||||
<div className="flex items-center justify-between px-1" data-testid="log-viewer-header">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{Object.entries(typeCounts).map(([type, count]) => {
|
||||
const colors = getLogTypeColors(type as LogEntryType);
|
||||
return (
|
||||
<span
|
||||
key={type}
|
||||
className={cn(
|
||||
"text-xs px-2 py-0.5 rounded-full",
|
||||
colors.badge
|
||||
)}
|
||||
data-testid={`log-type-count-${type}`}
|
||||
>
|
||||
{type}: {count}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={expandAll}
|
||||
className="text-xs text-zinc-400 hover:text-zinc-200 px-2 py-1 rounded hover:bg-zinc-800/50 transition-colors"
|
||||
data-testid="log-expand-all"
|
||||
>
|
||||
Expand All
|
||||
</button>
|
||||
<button
|
||||
onClick={collapseAll}
|
||||
className="text-xs text-zinc-400 hover:text-zinc-200 px-2 py-1 rounded hover:bg-zinc-800/50 transition-colors"
|
||||
data-testid="log-collapse-all"
|
||||
>
|
||||
Collapse All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log entries */}
|
||||
<div className="space-y-2" data-testid="log-entries-container">
|
||||
{entries.map((entry) => (
|
||||
<LogEntryItem
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
isExpanded={expandedIds.has(entry.id)}
|
||||
onToggle={() => toggleEntry(entry.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,8 +8,9 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Loader2, List, FileText } from "lucide-react";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { LogViewer } from "@/components/ui/log-viewer";
|
||||
|
||||
interface AgentOutputModalProps {
|
||||
open: boolean;
|
||||
@@ -20,6 +21,8 @@ interface AgentOutputModalProps {
|
||||
onNumberKeyPress?: (key: string) => void;
|
||||
}
|
||||
|
||||
type ViewMode = "parsed" | "raw";
|
||||
|
||||
export function AgentOutputModal({
|
||||
open,
|
||||
onClose,
|
||||
@@ -29,6 +32,7 @@ export function AgentOutputModal({
|
||||
}: AgentOutputModalProps) {
|
||||
const [output, setOutput] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("parsed");
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const autoScrollRef = useRef(true);
|
||||
const projectPathRef = useRef<string>("");
|
||||
@@ -202,10 +206,38 @@ export function AgentOutputModal({
|
||||
data-testid="agent-output-modal"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Loader2 className="w-5 h-5 text-purple-500 animate-spin" />
|
||||
Agent Output
|
||||
</DialogTitle>
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Loader2 className="w-5 h-5 text-purple-500 animate-spin" />
|
||||
Agent Output
|
||||
</DialogTitle>
|
||||
<div className="flex items-center gap-1 bg-zinc-900/50 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setViewMode("parsed")}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
||||
viewMode === "parsed"
|
||||
? "bg-purple-500/20 text-purple-300 shadow-sm"
|
||||
: "text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/50"
|
||||
}`}
|
||||
data-testid="view-mode-parsed"
|
||||
>
|
||||
<List className="w-3.5 h-3.5" />
|
||||
Parsed
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("raw")}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
||||
viewMode === "raw"
|
||||
? "bg-purple-500/20 text-purple-300 shadow-sm"
|
||||
: "text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/50"
|
||||
}`}
|
||||
data-testid="view-mode-raw"
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
Raw
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogDescription className="mt-1">
|
||||
{featureDescription}
|
||||
</DialogDescription>
|
||||
@@ -225,6 +257,8 @@ export function AgentOutputModal({
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
No output yet. The agent will stream output here as it works.
|
||||
</div>
|
||||
) : viewMode === "parsed" ? (
|
||||
<LogViewer output={output} />
|
||||
) : (
|
||||
<div className="whitespace-pre-wrap break-words text-zinc-300">
|
||||
{output}
|
||||
|
||||
341
app/src/lib/log-parser.ts
Normal file
341
app/src/lib/log-parser.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* Log Parser Utility
|
||||
* Parses agent output into structured sections for display
|
||||
*/
|
||||
|
||||
export type LogEntryType =
|
||||
| "prompt"
|
||||
| "tool_call"
|
||||
| "tool_result"
|
||||
| "phase"
|
||||
| "error"
|
||||
| "success"
|
||||
| "info"
|
||||
| "debug"
|
||||
| "warning";
|
||||
|
||||
export interface LogEntry {
|
||||
id: string;
|
||||
type: LogEntryType;
|
||||
title: string;
|
||||
content: string;
|
||||
timestamp?: string;
|
||||
collapsed?: boolean;
|
||||
metadata?: {
|
||||
toolName?: string;
|
||||
phase?: string;
|
||||
[key: string]: string | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
const generateId = () => Math.random().toString(36).substring(2, 9);
|
||||
|
||||
/**
|
||||
* Detects the type of log entry based on content patterns
|
||||
*/
|
||||
function detectEntryType(content: string): LogEntryType {
|
||||
const trimmed = content.trim();
|
||||
|
||||
// Tool calls
|
||||
if (trimmed.startsWith("🔧 Tool:") || trimmed.match(/^Tool:\s*/)) {
|
||||
return "tool_call";
|
||||
}
|
||||
|
||||
// Tool results / Input
|
||||
if (trimmed.startsWith("Input:") || trimmed.startsWith("Result:") || trimmed.startsWith("Output:")) {
|
||||
return "tool_result";
|
||||
}
|
||||
|
||||
// Phase changes
|
||||
if (
|
||||
trimmed.startsWith("📋") ||
|
||||
trimmed.startsWith("⚡") ||
|
||||
trimmed.startsWith("✅") ||
|
||||
trimmed.match(/^(Planning|Action|Verification)/i)
|
||||
) {
|
||||
return "phase";
|
||||
}
|
||||
|
||||
// Errors
|
||||
if (trimmed.startsWith("❌") || trimmed.toLowerCase().includes("error:")) {
|
||||
return "error";
|
||||
}
|
||||
|
||||
// Success messages
|
||||
if (
|
||||
trimmed.startsWith("✅") ||
|
||||
trimmed.toLowerCase().includes("success") ||
|
||||
trimmed.toLowerCase().includes("completed")
|
||||
) {
|
||||
return "success";
|
||||
}
|
||||
|
||||
// Warnings
|
||||
if (trimmed.startsWith("⚠️") || trimmed.toLowerCase().includes("warning:")) {
|
||||
return "warning";
|
||||
}
|
||||
|
||||
// Debug info (JSON, stack traces, etc.)
|
||||
if (
|
||||
trimmed.startsWith("{") ||
|
||||
trimmed.startsWith("[") ||
|
||||
trimmed.includes("at ") ||
|
||||
trimmed.match(/^\s*\d+\s*\|/)
|
||||
) {
|
||||
return "debug";
|
||||
}
|
||||
|
||||
// Default to info
|
||||
return "info";
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts tool name from a tool call entry
|
||||
*/
|
||||
function extractToolName(content: string): string | undefined {
|
||||
const match = content.match(/🔧\s*Tool:\s*(\S+)/);
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts phase name from a phase entry
|
||||
*/
|
||||
function extractPhase(content: string): string | undefined {
|
||||
if (content.includes("📋")) return "planning";
|
||||
if (content.includes("⚡")) return "action";
|
||||
if (content.includes("✅")) return "verification";
|
||||
|
||||
const match = content.match(/^(Planning|Action|Verification)/i);
|
||||
return match?.[1]?.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a title for a log entry
|
||||
*/
|
||||
function generateTitle(type: LogEntryType, content: string): string {
|
||||
switch (type) {
|
||||
case "tool_call": {
|
||||
const toolName = extractToolName(content);
|
||||
return toolName ? `Tool Call: ${toolName}` : "Tool Call";
|
||||
}
|
||||
case "tool_result":
|
||||
return "Tool Input/Result";
|
||||
case "phase": {
|
||||
const phase = extractPhase(content);
|
||||
return phase ? `Phase: ${phase.charAt(0).toUpperCase() + phase.slice(1)}` : "Phase Change";
|
||||
}
|
||||
case "error":
|
||||
return "Error";
|
||||
case "success":
|
||||
return "Success";
|
||||
case "warning":
|
||||
return "Warning";
|
||||
case "debug":
|
||||
return "Debug Info";
|
||||
case "prompt":
|
||||
return "Prompt";
|
||||
default:
|
||||
return "Info";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses raw log output into structured entries
|
||||
*/
|
||||
export function parseLogOutput(rawOutput: string): LogEntry[] {
|
||||
if (!rawOutput || !rawOutput.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries: LogEntry[] = [];
|
||||
const lines = rawOutput.split("\n");
|
||||
|
||||
let currentEntry: LogEntry | null = null;
|
||||
let currentContent: string[] = [];
|
||||
|
||||
const finalizeEntry = () => {
|
||||
if (currentEntry && currentContent.length > 0) {
|
||||
currentEntry.content = currentContent.join("\n").trim();
|
||||
if (currentEntry.content) {
|
||||
entries.push(currentEntry);
|
||||
}
|
||||
}
|
||||
currentContent = [];
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
// Skip empty lines at the beginning
|
||||
if (!trimmedLine && !currentEntry) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detect if this line starts a new entry
|
||||
const lineType = detectEntryType(trimmedLine);
|
||||
const isNewEntry =
|
||||
trimmedLine.startsWith("🔧") ||
|
||||
trimmedLine.startsWith("📋") ||
|
||||
trimmedLine.startsWith("⚡") ||
|
||||
trimmedLine.startsWith("✅") ||
|
||||
trimmedLine.startsWith("❌") ||
|
||||
trimmedLine.startsWith("⚠️") ||
|
||||
(trimmedLine.startsWith("Input:") && currentEntry?.type === "tool_call");
|
||||
|
||||
if (isNewEntry) {
|
||||
// Finalize previous entry
|
||||
finalizeEntry();
|
||||
|
||||
// Start new entry
|
||||
currentEntry = {
|
||||
id: generateId(),
|
||||
type: lineType,
|
||||
title: generateTitle(lineType, trimmedLine),
|
||||
content: "",
|
||||
metadata: {
|
||||
toolName: extractToolName(trimmedLine),
|
||||
phase: extractPhase(trimmedLine),
|
||||
},
|
||||
};
|
||||
currentContent.push(trimmedLine);
|
||||
} else if (currentEntry) {
|
||||
// Continue current entry
|
||||
currentContent.push(line);
|
||||
} else {
|
||||
// No current entry, create a default info entry
|
||||
currentEntry = {
|
||||
id: generateId(),
|
||||
type: "info",
|
||||
title: "Info",
|
||||
content: "",
|
||||
};
|
||||
currentContent.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize last entry
|
||||
finalizeEntry();
|
||||
|
||||
// Merge consecutive entries of the same type if they're both debug or info
|
||||
const mergedEntries = mergeConsecutiveEntries(entries);
|
||||
|
||||
return mergedEntries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges consecutive entries of the same type for cleaner display
|
||||
*/
|
||||
function mergeConsecutiveEntries(entries: LogEntry[]): LogEntry[] {
|
||||
if (entries.length <= 1) return entries;
|
||||
|
||||
const merged: LogEntry[] = [];
|
||||
let current: LogEntry | null = null;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (
|
||||
current &&
|
||||
(current.type === "debug" || current.type === "info") &&
|
||||
current.type === entry.type
|
||||
) {
|
||||
// Merge into current
|
||||
current.content += "\n\n" + entry.content;
|
||||
} else {
|
||||
if (current) {
|
||||
merged.push(current);
|
||||
}
|
||||
current = { ...entry };
|
||||
}
|
||||
}
|
||||
|
||||
if (current) {
|
||||
merged.push(current);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the color classes for a log entry type
|
||||
*/
|
||||
export function getLogTypeColors(type: LogEntryType): {
|
||||
bg: string;
|
||||
border: string;
|
||||
text: string;
|
||||
icon: string;
|
||||
badge: string;
|
||||
} {
|
||||
switch (type) {
|
||||
case "prompt":
|
||||
return {
|
||||
bg: "bg-blue-500/10",
|
||||
border: "border-l-blue-500",
|
||||
text: "text-blue-300",
|
||||
icon: "text-blue-400",
|
||||
badge: "bg-blue-500/20 text-blue-300",
|
||||
};
|
||||
case "tool_call":
|
||||
return {
|
||||
bg: "bg-amber-500/10",
|
||||
border: "border-l-amber-500",
|
||||
text: "text-amber-300",
|
||||
icon: "text-amber-400",
|
||||
badge: "bg-amber-500/20 text-amber-300",
|
||||
};
|
||||
case "tool_result":
|
||||
return {
|
||||
bg: "bg-slate-500/10",
|
||||
border: "border-l-slate-400",
|
||||
text: "text-slate-300",
|
||||
icon: "text-slate-400",
|
||||
badge: "bg-slate-500/20 text-slate-300",
|
||||
};
|
||||
case "phase":
|
||||
return {
|
||||
bg: "bg-cyan-500/10",
|
||||
border: "border-l-cyan-500",
|
||||
text: "text-cyan-300",
|
||||
icon: "text-cyan-400",
|
||||
badge: "bg-cyan-500/20 text-cyan-300",
|
||||
};
|
||||
case "error":
|
||||
return {
|
||||
bg: "bg-red-500/10",
|
||||
border: "border-l-red-500",
|
||||
text: "text-red-300",
|
||||
icon: "text-red-400",
|
||||
badge: "bg-red-500/20 text-red-300",
|
||||
};
|
||||
case "success":
|
||||
return {
|
||||
bg: "bg-emerald-500/10",
|
||||
border: "border-l-emerald-500",
|
||||
text: "text-emerald-300",
|
||||
icon: "text-emerald-400",
|
||||
badge: "bg-emerald-500/20 text-emerald-300",
|
||||
};
|
||||
case "warning":
|
||||
return {
|
||||
bg: "bg-orange-500/10",
|
||||
border: "border-l-orange-500",
|
||||
text: "text-orange-300",
|
||||
icon: "text-orange-400",
|
||||
badge: "bg-orange-500/20 text-orange-300",
|
||||
};
|
||||
case "debug":
|
||||
return {
|
||||
bg: "bg-purple-500/10",
|
||||
border: "border-l-purple-500",
|
||||
text: "text-purple-300",
|
||||
icon: "text-purple-400",
|
||||
badge: "bg-purple-500/20 text-purple-300",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
bg: "bg-zinc-500/10",
|
||||
border: "border-l-zinc-500",
|
||||
text: "text-zinc-300",
|
||||
icon: "text-zinc-400",
|
||||
badge: "bg-zinc-500/20 text-zinc-300",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1941,3 +1941,184 @@ export async function isElementVisibleInScrollContainer(
|
||||
elementBox.y + elementBox.height <= containerBox.y + containerBox.height
|
||||
);
|
||||
}
|
||||
|
||||
// ============ Log Viewer Utilities ============
|
||||
|
||||
/**
|
||||
* Get the log viewer header element (contains type counts and expand/collapse buttons)
|
||||
*/
|
||||
export async function getLogViewerHeader(page: Page): Promise<Locator> {
|
||||
return page.locator('[data-testid="log-viewer-header"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the log viewer header is visible
|
||||
*/
|
||||
export async function isLogViewerHeaderVisible(page: Page): Promise<boolean> {
|
||||
const header = page.locator('[data-testid="log-viewer-header"]');
|
||||
return await header.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the log entries container element
|
||||
*/
|
||||
export async function getLogEntriesContainer(page: Page): Promise<Locator> {
|
||||
return page.locator('[data-testid="log-entries-container"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a log entry by its type
|
||||
*/
|
||||
export async function getLogEntryByType(
|
||||
page: Page,
|
||||
type: string
|
||||
): Promise<Locator> {
|
||||
return page.locator(`[data-testid="log-entry-${type}"]`).first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all log entries of a specific type
|
||||
*/
|
||||
export async function getAllLogEntriesByType(
|
||||
page: Page,
|
||||
type: string
|
||||
): Promise<Locator> {
|
||||
return page.locator(`[data-testid="log-entry-${type}"]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count log entries of a specific type
|
||||
*/
|
||||
export async function countLogEntriesByType(
|
||||
page: Page,
|
||||
type: string
|
||||
): Promise<number> {
|
||||
const entries = page.locator(`[data-testid="log-entry-${type}"]`);
|
||||
return await entries.count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the log type count badge by type
|
||||
*/
|
||||
export async function getLogTypeCountBadge(
|
||||
page: Page,
|
||||
type: string
|
||||
): Promise<Locator> {
|
||||
return page.locator(`[data-testid="log-type-count-${type}"]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a log type count badge is visible
|
||||
*/
|
||||
export async function isLogTypeCountBadgeVisible(
|
||||
page: Page,
|
||||
type: string
|
||||
): Promise<boolean> {
|
||||
const badge = page.locator(`[data-testid="log-type-count-${type}"]`);
|
||||
return await badge.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the expand all button in the log viewer
|
||||
*/
|
||||
export async function clickLogExpandAll(page: Page): Promise<void> {
|
||||
await clickElement(page, "log-expand-all");
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the collapse all button in the log viewer
|
||||
*/
|
||||
export async function clickLogCollapseAll(page: Page): Promise<void> {
|
||||
await clickElement(page, "log-collapse-all");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a log entry badge element
|
||||
*/
|
||||
export async function getLogEntryBadge(page: Page): Promise<Locator> {
|
||||
return page.locator('[data-testid="log-entry-badge"]').first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any log entry badge is visible
|
||||
*/
|
||||
export async function isLogEntryBadgeVisible(page: Page): Promise<boolean> {
|
||||
const badge = page.locator('[data-testid="log-entry-badge"]').first();
|
||||
return await badge.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the view mode toggle button (parsed/raw)
|
||||
*/
|
||||
export async function getViewModeButton(
|
||||
page: Page,
|
||||
mode: "parsed" | "raw"
|
||||
): Promise<Locator> {
|
||||
return page.locator(`[data-testid="view-mode-${mode}"]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click a view mode toggle button
|
||||
*/
|
||||
export async function clickViewModeButton(
|
||||
page: Page,
|
||||
mode: "parsed" | "raw"
|
||||
): Promise<void> {
|
||||
await clickElement(page, `view-mode-${mode}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a view mode button is active (selected)
|
||||
*/
|
||||
export async function isViewModeActive(
|
||||
page: Page,
|
||||
mode: "parsed" | "raw"
|
||||
): Promise<boolean> {
|
||||
const button = page.locator(`[data-testid="view-mode-${mode}"]`);
|
||||
const classes = await button.getAttribute("class");
|
||||
return classes?.includes("text-purple-300") ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a mock project with agent output content in the context file
|
||||
*/
|
||||
export async function setupMockProjectWithAgentOutput(
|
||||
page: Page,
|
||||
featureId: string,
|
||||
outputContent: string
|
||||
): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ featureId, outputContent }: { featureId: string; outputContent: string }) => {
|
||||
const mockProject = {
|
||||
id: "test-project-1",
|
||||
name: "Test Project",
|
||||
path: "/mock/test-project",
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const mockState = {
|
||||
state: {
|
||||
projects: [mockProject],
|
||||
currentProject: mockProject,
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: 3,
|
||||
},
|
||||
version: 0,
|
||||
};
|
||||
|
||||
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
|
||||
|
||||
// Set up mock file system with output content for the feature
|
||||
(window as any).__mockContextFile = {
|
||||
featureId,
|
||||
path: `/mock/test-project/.automaker/agents-context/${featureId}.md`,
|
||||
content: outputContent,
|
||||
};
|
||||
},
|
||||
{ featureId, outputContent }
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user