diff --git a/.automaker/feature_list.json b/.automaker/feature_list.json index d3ef63ef..65bc0ad6 100644 --- a/.automaker/feature_list.json +++ b/.automaker/feature_list.json @@ -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" } ] \ No newline at end of file diff --git a/app/src/components/ui/log-viewer.tsx b/app/src/components/ui/log-viewer.tsx new file mode 100644 index 00000000..708f596b --- /dev/null +++ b/app/src/components/ui/log-viewer.tsx @@ -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 ; + case "tool_call": + return ; + case "tool_result": + return ; + case "phase": + return ; + case "error": + return ; + case "success": + return ; + case "warning": + return ; + case "debug": + return ; + default: + return ; + } +}; + +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 ( +
+ + + {(isExpanded || !hasContent) && ( +
+
+ {formattedContent.map((part, index) => ( +
+ {part.type === "json" ? ( +
+                    {part.content}
+                  
+ ) : ( +
+                    {part.content}
+                  
+ )} +
+ ))} +
+
+ )} +
+ ); +} + +export function LogViewer({ output, className }: LogViewerProps) { + const [expandedIds, setExpandedIds] = useState>(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); + + return ( +
+ {/* Header with controls */} +
+
+ {Object.entries(typeCounts).map(([type, count]) => { + const colors = getLogTypeColors(type as LogEntryType); + return ( + + {type}: {count} + + ); + })} +
+
+ + +
+
+ + {/* Log entries */} +
+ {entries.map((entry) => ( + toggleEntry(entry.id)} + /> + ))} +
+
+ ); +} diff --git a/app/src/components/views/agent-output-modal.tsx b/app/src/components/views/agent-output-modal.tsx index fa56da52..e38a8799 100644 --- a/app/src/components/views/agent-output-modal.tsx +++ b/app/src/components/views/agent-output-modal.tsx @@ -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(""); const [isLoading, setIsLoading] = useState(true); + const [viewMode, setViewMode] = useState("parsed"); const scrollRef = useRef(null); const autoScrollRef = useRef(true); const projectPathRef = useRef(""); @@ -202,10 +206,38 @@ export function AgentOutputModal({ data-testid="agent-output-modal" > - - - Agent Output - +
+ + + Agent Output + +
+ + +
+
{featureDescription} @@ -225,6 +257,8 @@ export function AgentOutputModal({
No output yet. The agent will stream output here as it works.
+ ) : viewMode === "parsed" ? ( + ) : (
{output} diff --git a/app/src/components/views/settings-view.tsx b/app/src/components/views/settings-view.tsx index 957e2005..295cbd42 100644 --- a/app/src/components/views/settings-view.tsx +++ b/app/src/components/views/settings-view.tsx @@ -89,7 +89,7 @@ export function SettingsView() { }; return ( -
+
{/* Header Section */}
diff --git a/app/src/lib/log-parser.ts b/app/src/lib/log-parser.ts new file mode 100644 index 00000000..76a6ce15 --- /dev/null +++ b/app/src/lib/log-parser.ts @@ -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", + }; + } +} diff --git a/app/tests/utils.ts b/app/tests/utils.ts index db0e9c08..5aa8b4a8 100644 --- a/app/tests/utils.ts +++ b/app/tests/utils.ts @@ -1860,3 +1860,265 @@ export async function isDropOverlayVisible(page: Page): Promise { 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return page.locator('[data-testid="log-viewer-header"]'); +} + +/** + * Check if the log viewer header is visible + */ +export async function isLogViewerHeaderVisible(page: Page): Promise { + 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 { + return page.locator('[data-testid="log-entries-container"]'); +} + +/** + * Get a log entry by its type + */ +export async function getLogEntryByType( + page: Page, + type: string +): Promise { + 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 { + return page.locator(`[data-testid="log-entry-${type}"]`); +} + +/** + * Count log entries of a specific type + */ +export async function countLogEntriesByType( + page: Page, + type: string +): Promise { + 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 { + 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 { + 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 { + await clickElement(page, "log-expand-all"); +} + +/** + * Click the collapse all button in the log viewer + */ +export async function clickLogCollapseAll(page: Page): Promise { + await clickElement(page, "log-collapse-all"); +} + +/** + * Get a log entry badge element + */ +export async function getLogEntryBadge(page: Page): Promise { + return page.locator('[data-testid="log-entry-badge"]').first(); +} + +/** + * Check if any log entry badge is visible + */ +export async function isLogEntryBadgeVisible(page: Page): Promise { + 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 { + return page.locator(`[data-testid="view-mode-${mode}"]`); +} + +/** + * Click a view mode toggle button + */ +export async function clickViewModeButton( + page: Page, + mode: "parsed" | "raw" +): Promise { + 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 { + 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 { + 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 } + ); +}