mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
Merge branch 'main' of github.com:webdevcody/automaker
This commit is contained in:
@@ -33,5 +33,23 @@
|
||||
"description": "-o should actually open the select folder prompt. Right now when you click o it goes to like the overview page. That's not the correct experience I'm looking for. Also just clicking on the top left open folder icon should do the same thing of opening the system prompt so they can select a project.",
|
||||
"steps": [],
|
||||
"status": "verified"
|
||||
},
|
||||
{
|
||||
"id": "feature-1765305181443-qze22t1hl",
|
||||
"category": "Other",
|
||||
"description": "the settings view is not allowing us to scroll to see rest of the content ",
|
||||
"steps": [
|
||||
"start the project",
|
||||
"open Setting view",
|
||||
"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}
|
||||
|
||||
@@ -89,7 +89,7 @@ export function SettingsView() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col content-bg" data-testid="settings-view">
|
||||
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="settings-view">
|
||||
{/* Header Section */}
|
||||
<div className="flex-shrink-0 border-b border-white/10 bg-zinc-950/50 backdrop-blur-md">
|
||||
<div className="px-8 py-6">
|
||||
|
||||
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",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1860,3 +1860,265 @@ export async function isDropOverlayVisible(page: Page): Promise<boolean> {
|
||||
const overlay = page.locator('[data-testid="drop-overlay"]');
|
||||
return await overlay.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the settings view
|
||||
*/
|
||||
export async function navigateToSettings(page: Page): Promise<void> {
|
||||
await page.goto("/");
|
||||
|
||||
// Wait for the page to load
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Click on the Settings button in the sidebar
|
||||
const settingsButton = page.locator('[data-testid="settings-button"]');
|
||||
if (await settingsButton.isVisible().catch(() => false)) {
|
||||
await settingsButton.click();
|
||||
}
|
||||
|
||||
// Wait for the settings view to be visible
|
||||
await waitForElement(page, "settings-view", { timeout: 10000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the settings view scrollable content area
|
||||
*/
|
||||
export async function getSettingsContentArea(page: Page): Promise<Locator> {
|
||||
return page.locator('[data-testid="settings-view"] .overflow-y-auto');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an element is scrollable (has scrollable content)
|
||||
*/
|
||||
export async function isElementScrollable(locator: Locator): Promise<boolean> {
|
||||
const scrollInfo = await locator.evaluate((el) => {
|
||||
return {
|
||||
scrollHeight: el.scrollHeight,
|
||||
clientHeight: el.clientHeight,
|
||||
isScrollable: el.scrollHeight > el.clientHeight,
|
||||
};
|
||||
});
|
||||
return scrollInfo.isScrollable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll an element to the bottom
|
||||
*/
|
||||
export async function scrollToBottom(locator: Locator): Promise<void> {
|
||||
await locator.evaluate((el) => {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the scroll position of an element
|
||||
*/
|
||||
export async function getScrollPosition(locator: Locator): Promise<{ scrollTop: number; scrollHeight: number; clientHeight: number }> {
|
||||
return await locator.evaluate((el) => ({
|
||||
scrollTop: el.scrollTop,
|
||||
scrollHeight: el.scrollHeight,
|
||||
clientHeight: el.clientHeight,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an element is visible within a scrollable container
|
||||
*/
|
||||
export async function isElementVisibleInScrollContainer(
|
||||
element: Locator,
|
||||
container: Locator
|
||||
): Promise<boolean> {
|
||||
const elementBox = await element.boundingBox();
|
||||
const containerBox = await container.boundingBox();
|
||||
|
||||
if (!elementBox || !containerBox) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if element is within the visible area of the container
|
||||
return (
|
||||
elementBox.y >= containerBox.y &&
|
||||
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