From 000cae6737c57c8c1b1e6b74c19f1bfa051f5a3b Mon Sep 17 00:00:00 2001 From: Kacper Date: Tue, 9 Dec 2025 19:36:18 +0100 Subject: [PATCH 1/2] Fix settings view scrolling by adding overflow-hidden to container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settings view was not allowing scrolling because the outer container lacked overflow-hidden. This prevented the inner overflow-y-auto content area from properly constraining its height and enabling scroll behavior. Added overflow-hidden to the settings view container to match the pattern used in other views like board-view.tsx. Also added utility functions for settings navigation and scroll testing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .automaker/feature_list.json | 11 +++ app/src/components/views/settings-view.tsx | 2 +- app/tests/utils.ts | 81 ++++++++++++++++++++++ 3 files changed, 93 insertions(+), 1 deletion(-) diff --git a/.automaker/feature_list.json b/.automaker/feature_list.json index d3ef63ef..95f7cb04 100644 --- a/.automaker/feature_list.json +++ b/.automaker/feature_list.json @@ -33,5 +33,16 @@ "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" } ] \ No newline at end of file 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/tests/utils.ts b/app/tests/utils.ts index db0e9c08..09381da5 100644 --- a/app/tests/utils.ts +++ b/app/tests/utils.ts @@ -1860,3 +1860,84 @@ 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 + ); +} From 2c015871809e4da54cad001366be538393ca2ffa Mon Sep 17 00:00:00 2001 From: Kacper Date: Tue, 9 Dec 2025 21:01:55 +0100 Subject: [PATCH 2/2] 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 --- .automaker/feature_list.json | 7 + app/src/components/ui/log-viewer.tsx | 269 ++++++++++++++ .../components/views/agent-output-modal.tsx | 44 ++- app/src/lib/log-parser.ts | 341 ++++++++++++++++++ app/tests/utils.ts | 181 ++++++++++ 5 files changed, 837 insertions(+), 5 deletions(-) create mode 100644 app/src/components/ui/log-viewer.tsx create mode 100644 app/src/lib/log-parser.ts diff --git a/.automaker/feature_list.json b/.automaker/feature_list.json index 95f7cb04..65bc0ad6 100644 --- a/.automaker/feature_list.json +++ b/.automaker/feature_list.json @@ -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" } ] \ 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/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 09381da5..5aa8b4a8 100644 --- a/app/tests/utils.ts +++ b/app/tests/utils.ts @@ -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 { + 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 } + ); +}