From 266e0c54b9357f2bbe3d943140fc3a06bc61310a Mon Sep 17 00:00:00 2001 From: Kacper Date: Wed, 17 Dec 2025 13:22:20 +0100 Subject: [PATCH 1/6] refactor: remove direct file saving from AgentOutputModal and implement debounced file writing in auto-mode service - Removed the saveOutput function from AgentOutputModal to streamline state management, ensuring local state updates without direct file writes. - Introduced a debounced file writing mechanism in the auto-mode service to handle incremental updates to agent output, improving performance and reliability. - Enhanced error handling during file writes to prevent execution interruptions and ensure all content is saved correctly. --- .../board-view/dialogs/agent-output-modal.tsx | 27 +------- apps/server/src/services/auto-mode-service.ts | 66 +++++++++++++++++-- 2 files changed, 63 insertions(+), 30 deletions(-) diff --git a/apps/app/src/components/views/board-view/dialogs/agent-output-modal.tsx b/apps/app/src/components/views/board-view/dialogs/agent-output-modal.tsx index 0dd6429c..d92cabcc 100644 --- a/apps/app/src/components/views/board-view/dialogs/agent-output-modal.tsx +++ b/apps/app/src/components/views/board-view/dialogs/agent-output-modal.tsx @@ -99,24 +99,6 @@ export function AgentOutputModal({ loadOutput(); }, [open, featureId]); - // Save output to file - const saveOutput = async (newContent: string) => { - if (!projectPathRef.current) return; - - const api = getElectronAPI(); - if (!api) return; - - try { - // Use features API - agent output is stored in features/{id}/agent-output.md - // We need to write it directly since there's no updateAgentOutput method - // The context-manager handles this on the backend, but for frontend edits we write directly - const outputPath = `${projectPathRef.current}/.automaker/features/${featureId}/agent-output.md`; - await api.writeFile(outputPath, newContent); - } catch (error) { - console.error("Failed to save output:", error); - } - }; - // Listen to auto mode events and update output useEffect(() => { if (!open) return; @@ -142,7 +124,7 @@ export function AgentOutputModal({ ? JSON.stringify(event.input, null, 2) : ""; newContent = `\nšŸ”§ Tool: ${toolName}\n${ - toolInput ? `Input: ${toolInput}` : "" + toolInput ? `Input: ${toolInput}\n` : "" }`; break; case "auto_mode_phase": @@ -202,11 +184,8 @@ export function AgentOutputModal({ } if (newContent) { - setOutput((prev) => { - const updated = prev + newContent; - saveOutput(updated); - return updated; - }); + // Only update local state - server is the single source of truth for file writes + setOutput((prev) => prev + newContent); } }); diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 7d779da8..c40c91ff 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -1264,10 +1264,49 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. const featureDirForOutput = getFeatureDir(configProjectPath, featureId); const outputPath = path.join(featureDirForOutput, "agent-output.md"); + // Incremental file writing state + let directoryCreated = false; + let writeTimeout: ReturnType | null = null; + const WRITE_DEBOUNCE_MS = 500; // Batch writes every 500ms + + // Helper to write current responseText to file + const writeToFile = async (): Promise => { + try { + if (!directoryCreated) { + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + directoryCreated = true; + } + await fs.writeFile(outputPath, responseText); + } catch (error) { + // Log but don't crash - file write errors shouldn't stop execution + console.error(`[AutoMode] Failed to write agent output for ${featureId}:`, error); + } + }; + + // Debounced write - schedules a write after WRITE_DEBOUNCE_MS + const scheduleWrite = (): void => { + if (writeTimeout) { + clearTimeout(writeTimeout); + } + writeTimeout = setTimeout(() => { + writeToFile().catch((err) => { + console.error(`[AutoMode] Debounced write error:`, err); + }); + }, WRITE_DEBOUNCE_MS); + }; + for await (const msg of stream) { if (msg.type === "assistant" && msg.message?.content) { for (const block of msg.message.content) { if (block.type === "text") { + // Add separator before new text if we already have content and it doesn't end with newlines + if (responseText.length > 0 && !responseText.endsWith('\n\n')) { + if (responseText.endsWith('\n')) { + responseText += '\n'; + } else { + responseText += '\n\n'; + } + } responseText += block.text || ""; // Check for authentication errors in the response @@ -1283,16 +1322,30 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. ); } + // Schedule incremental file write (debounced) + scheduleWrite(); + this.emitAutoModeEvent("auto_mode_progress", { featureId, content: block.text, }); } else if (block.type === "tool_use") { + // Emit event for real-time UI this.emitAutoModeEvent("auto_mode_tool", { featureId, tool: block.name, input: block.input, }); + + // Also add to file output for persistence + if (responseText.length > 0 && !responseText.endsWith('\n')) { + responseText += '\n'; + } + responseText += `\nšŸ”§ Tool: ${block.name}\n`; + if (block.input) { + responseText += `Input: ${JSON.stringify(block.input, null, 2)}\n`; + } + scheduleWrite(); } } } else if (msg.type === "error") { @@ -1300,16 +1353,17 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. throw new Error(msg.error || "Unknown error"); } else if (msg.type === "result" && msg.subtype === "success") { responseText = msg.result || responseText; + // Schedule write for final result + scheduleWrite(); } } - // Save agent output - try { - await fs.mkdir(path.dirname(outputPath), { recursive: true }); - await fs.writeFile(outputPath, responseText); - } catch { - // May fail if directory doesn't exist + // Clear any pending timeout and do a final write to ensure all content is saved + if (writeTimeout) { + clearTimeout(writeTimeout); } + // Final write - ensure all accumulated content is saved + await writeToFile(); } private async executeFeatureWithContext( From fe56ba133e91ce77d6a3fca74628ef6040b87915 Mon Sep 17 00:00:00 2001 From: Kacper Date: Wed, 17 Dec 2025 14:30:24 +0100 Subject: [PATCH 2/6] feat: enhance log viewer with tool category support and filtering - Added tool category icons and colors for log entries based on their metadata, improving visual differentiation. - Implemented search functionality and filters for log entry types and tool categories, allowing users to customize their view. - Enhanced log entry parsing to include tool-specific summaries and file paths, providing more context in the logs. - Introduced a clear filters button to reset search and category filters, improving user experience. - Updated the log viewer UI to accommodate new features, including a sticky header for better accessibility. --- apps/app/src/components/ui/log-viewer.tsx | 396 ++++++++++++++++-- apps/app/src/lib/log-parser.ts | 300 ++++++++++++- apps/server/src/services/auto-mode-service.ts | 5 +- 3 files changed, 658 insertions(+), 43 deletions(-) diff --git a/apps/app/src/components/ui/log-viewer.tsx b/apps/app/src/components/ui/log-viewer.tsx index 169c626f..059ebc78 100644 --- a/apps/app/src/components/ui/log-viewer.tsx +++ b/apps/app/src/components/ui/log-viewer.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useMemo } from "react"; +import { useState, useMemo, useEffect, useRef } from "react"; import { ChevronDown, ChevronRight, @@ -14,13 +14,23 @@ import { Info, FileOutput, Brain, + Eye, + Pencil, + Terminal, + Search, + ListTodo, + Layers, + X, + Filter, } from "lucide-react"; import { cn } from "@/lib/utils"; import { parseLogOutput, getLogTypeColors, + shouldCollapseByDefault, type LogEntry, type LogEntryType, + type ToolCategory, } from "@/lib/log-parser"; interface LogViewerProps { @@ -53,6 +63,54 @@ const getLogIcon = (type: LogEntryType) => { } }; +/** + * Returns a tool-specific icon based on the tool category + */ +const getToolCategoryIcon = (category: ToolCategory | undefined) => { + switch (category) { + case "read": + return ; + case "edit": + return ; + case "write": + return ; + case "bash": + return ; + case "search": + return ; + case "todo": + return ; + case "task": + return ; + default: + return ; + } +}; + +/** + * Returns color classes for a tool category + */ +const getToolCategoryColor = (category: ToolCategory | undefined): string => { + switch (category) { + case "read": + return "text-blue-400 bg-blue-500/10 border-blue-500/30"; + case "edit": + return "text-amber-400 bg-amber-500/10 border-amber-500/30"; + case "write": + return "text-emerald-400 bg-emerald-500/10 border-emerald-500/30"; + case "bash": + return "text-purple-400 bg-purple-500/10 border-purple-500/30"; + case "search": + return "text-cyan-400 bg-cyan-500/10 border-cyan-500/30"; + case "todo": + return "text-green-400 bg-green-500/10 border-green-500/30"; + case "task": + return "text-indigo-400 bg-indigo-500/10 border-indigo-500/30"; + default: + return "text-zinc-400 bg-zinc-500/10 border-zinc-500/30"; + } +}; + interface LogEntryItemProps { entry: LogEntry; isExpanded: boolean; @@ -63,9 +121,40 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) { const colors = getLogTypeColors(entry.type); const hasContent = entry.content.length > 100; + // For tool_call entries, use tool-specific styling + const isToolCall = entry.type === "tool_call"; + const toolCategory = entry.metadata?.toolCategory; + const toolCategoryColors = isToolCall ? getToolCategoryColor(toolCategory) : ""; + + // Get the appropriate icon based on entry type and tool category + const icon = isToolCall ? getToolCategoryIcon(toolCategory) : getLogIcon(entry.type); + + // Get collapsed preview text - prefer smart summary for tool calls + const collapsedPreview = useMemo(() => { + if (isExpanded) return ""; + + // Use smart summary if available + if (entry.metadata?.summary) { + return entry.metadata.summary; + } + + // Fallback to truncated content + return entry.content.slice(0, 80) + (entry.content.length > 80 ? "..." : ""); + }, [isExpanded, entry.metadata?.summary, entry.content]); + // Format content - detect and highlight JSON const formattedContent = useMemo(() => { - const content = entry.content; + let content = entry.content; + + // For tool_call entries, remove redundant "Tool: X" and "Input:" prefixes + // since we already show the tool name in the header badge + if (isToolCall) { + // Remove "šŸ”§ Tool: ToolName\n" or "Tool: ToolName\n" prefix + content = content.replace(/^(?:šŸ”§\s*)?Tool:\s*\w+\s*\n?/i, ""); + // Remove standalone "Input:" label (keep the JSON that follows) + content = content.replace(/^Input:\s*\n?/i, ""); + content = content.trim(); + } // Try to find and format JSON blocks const jsonRegex = /(\{[\s\S]*?\}|\[[\s\S]*?\])/g; @@ -103,14 +192,20 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) { } return parts.length > 0 ? parts : [{ type: "text" as const, content }]; - }, [entry.content]); + }, [entry.content, isToolCall]); + + // Get colors - use tool category colors for tool_call entries + const colorParts = toolCategoryColors.split(" "); + const textColor = isToolCall ? (colorParts[0] || "text-zinc-400") : colors.text; + const bgColor = isToolCall ? (colorParts[1] || "bg-zinc-500/10") : colors.bg; + const borderColor = isToolCall ? (colorParts[2] || "border-zinc-500/30") : colors.border; return (
)} - - {getLogIcon(entry.type)} + + {icon} @@ -145,9 +240,7 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) { - {!isExpanded && - entry.content.slice(0, 80) + - (entry.content.length > 80 ? "..." : "")} + {collapsedPreview} @@ -167,7 +260,7 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
                     {part.content}
@@ -182,10 +275,109 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
   );
 }
 
+interface ToolCategoryStats {
+  read: number;
+  edit: number;
+  write: number;
+  bash: number;
+  search: number;
+  todo: number;
+  task: number;
+  other: number;
+}
+
 export function LogViewer({ output, className }: LogViewerProps) {
   const [expandedIds, setExpandedIds] = useState>(new Set());
+  const [searchQuery, setSearchQuery] = useState("");
+  const [hiddenTypes, setHiddenTypes] = useState>(new Set());
+  const [hiddenCategories, setHiddenCategories] = useState>(new Set());
 
-  const entries = useMemo(() => parseLogOutput(output), [output]);
+  // Parse entries and compute initial expanded state together
+  const { entries, initialExpandedIds } = useMemo(() => {
+    const parsedEntries = parseLogOutput(output);
+    const toExpand: string[] = [];
+
+    parsedEntries.forEach((entry) => {
+      // If entry should NOT collapse by default, mark it for expansion
+      if (!shouldCollapseByDefault(entry)) {
+        toExpand.push(entry.id);
+      }
+    });
+
+    return {
+      entries: parsedEntries,
+      initialExpandedIds: new Set(toExpand),
+    };
+  }, [output]);
+
+  // Merge initial expanded IDs with user-toggled ones
+  // Use a ref to track if we've applied initial state
+  const appliedInitialRef = useRef>(new Set());
+
+  // Apply initial expanded state for new entries
+  const effectiveExpandedIds = useMemo(() => {
+    const result = new Set(expandedIds);
+    initialExpandedIds.forEach((id) => {
+      if (!appliedInitialRef.current.has(id)) {
+        appliedInitialRef.current.add(id);
+        result.add(id);
+      }
+    });
+    return result;
+  }, [expandedIds, initialExpandedIds]);
+
+  // Calculate stats for tool categories
+  const stats = useMemo(() => {
+    const toolCalls = entries.filter((e) => e.type === "tool_call");
+    const byCategory: ToolCategoryStats = {
+      read: 0,
+      edit: 0,
+      write: 0,
+      bash: 0,
+      search: 0,
+      todo: 0,
+      task: 0,
+      other: 0,
+    };
+
+    toolCalls.forEach((tc) => {
+      const cat = tc.metadata?.toolCategory || "other";
+      byCategory[cat]++;
+    });
+
+    return {
+      total: toolCalls.length,
+      byCategory,
+      errors: entries.filter((e) => e.type === "error").length,
+    };
+  }, [entries]);
+
+  // Filter entries based on search and hidden types/categories
+  const filteredEntries = useMemo(() => {
+    return entries.filter((entry) => {
+      // Filter by hidden types
+      if (hiddenTypes.has(entry.type)) return false;
+
+      // Filter by hidden tool categories (for tool_call entries)
+      if (entry.type === "tool_call" && entry.metadata?.toolCategory) {
+        if (hiddenCategories.has(entry.metadata.toolCategory)) return false;
+      }
+
+      // Filter by search query
+      if (searchQuery) {
+        const query = searchQuery.toLowerCase();
+        return (
+          entry.content.toLowerCase().includes(query) ||
+          entry.title.toLowerCase().includes(query) ||
+          entry.metadata?.toolName?.toLowerCase().includes(query) ||
+          entry.metadata?.summary?.toLowerCase().includes(query) ||
+          entry.metadata?.filePath?.toLowerCase().includes(query)
+        );
+      }
+
+      return true;
+    });
+  }, [entries, hiddenTypes, hiddenCategories, searchQuery]);
 
   const toggleEntry = (id: string) => {
     setExpandedIds((prev) => {
@@ -200,13 +392,45 @@ export function LogViewer({ output, className }: LogViewerProps) {
   };
 
   const expandAll = () => {
-    setExpandedIds(new Set(entries.map((e) => e.id)));
+    setExpandedIds(new Set(filteredEntries.map((e) => e.id)));
   };
 
   const collapseAll = () => {
     setExpandedIds(new Set());
   };
 
+  const toggleTypeFilter = (type: LogEntryType) => {
+    setHiddenTypes((prev) => {
+      const next = new Set(prev);
+      if (next.has(type)) {
+        next.delete(type);
+      } else {
+        next.add(type);
+      }
+      return next;
+    });
+  };
+
+  const toggleCategoryFilter = (category: ToolCategory) => {
+    setHiddenCategories((prev) => {
+      const next = new Set(prev);
+      if (next.has(category)) {
+        next.delete(category);
+      } else {
+        next.add(category);
+      }
+      return next;
+    });
+  };
+
+  const clearFilters = () => {
+    setSearchQuery("");
+    setHiddenTypes(new Set());
+    setHiddenCategories(new Set());
+  };
+
+  const hasActiveFilters = searchQuery || hiddenTypes.size > 0 || hiddenCategories.size > 0;
+
   if (entries.length === 0) {
     return (
       
@@ -229,28 +453,123 @@ export function LogViewer({ output, className }: LogViewerProps) { return acc; }, {} as Record); + // Tool categories to display in stats bar + const toolCategoryLabels: { key: ToolCategory; label: string }[] = [ + { key: "read", label: "Read" }, + { key: "edit", label: "Edit" }, + { key: "write", label: "Write" }, + { key: "bash", label: "Bash" }, + { key: "search", label: "Search" }, + { key: "todo", label: "Todo" }, + { key: "task", label: "Task" }, + { key: "other", label: "Other" }, + ]; + return ( -
- {/* Header with controls */} +
+ {/* Sticky header with search, stats, and filters */} + {/* Use -top-4 to compensate for parent's p-4 padding, pt-4 to restore visual spacing */} +
+ {/* Search bar */} +
+
+ + setSearchQuery(e.target.value)} + placeholder="Search logs..." + className="w-full pl-8 pr-8 py-1.5 text-xs bg-zinc-900/50 border border-zinc-700/50 rounded-md text-zinc-200 placeholder:text-zinc-500 focus:outline-none focus:border-zinc-600" + data-testid="log-search-input" + /> + {searchQuery && ( + + )} +
+ {hasActiveFilters && ( + + )} +
+ + {/* Tool category stats bar */} + {stats.total > 0 && ( +
+ + + {stats.total} tools: + + {toolCategoryLabels.map(({ key, label }) => { + const count = stats.byCategory[key]; + if (count === 0) return null; + const isHidden = hiddenCategories.has(key); + const colorClasses = getToolCategoryColor(key); + return ( + + ); + })} + {stats.errors > 0 && ( + + + {stats.errors} + + )} +
+ )} + + {/* Header with type filters and controls */}
-
+
+ {Object.entries(typeCounts).map(([type, count]) => { const colors = getLogTypeColors(type as LogEntryType); + const isHidden = hiddenTypes.has(type as LogEntryType); return ( - toggleTypeFilter(type as LogEntryType)} className={cn( - "text-xs px-2 py-0.5 rounded-full", - colors.badge + "text-xs px-2 py-0.5 rounded-full transition-all", + colors.badge, + isHidden && "opacity-40 line-through" )} - data-testid={`log-type-count-${type}`} + title={isHidden ? `Show ${type}` : `Hide ${type}`} + data-testid={`log-type-filter-${type}`} > {type}: {count} - + ); })}
+ + {filteredEntries.length}/{entries.length} +
+
{/* Log entries */} -
- {entries.map((entry) => ( - toggleEntry(entry.id)} - /> - ))} +
+ {filteredEntries.length === 0 ? ( +
+ No entries match your filters. + {hasActiveFilters && ( + + )} +
+ ) : ( + filteredEntries.map((entry) => ( + toggleEntry(entry.id)} + /> + )) + )}
); diff --git a/apps/app/src/lib/log-parser.ts b/apps/app/src/lib/log-parser.ts index 872b814d..725b9b24 100644 --- a/apps/app/src/lib/log-parser.ts +++ b/apps/app/src/lib/log-parser.ts @@ -15,6 +15,38 @@ export type LogEntryType = | "warning" | "thinking"; +export type ToolCategory = 'read' | 'edit' | 'write' | 'bash' | 'search' | 'todo' | 'task' | 'other'; + +const TOOL_CATEGORIES: Record = { + 'Read': 'read', + 'Edit': 'edit', + 'Write': 'write', + 'Bash': 'bash', + 'Grep': 'search', + 'Glob': 'search', + 'WebSearch': 'search', + 'WebFetch': 'read', + 'TodoWrite': 'todo', + 'Task': 'task', + 'NotebookEdit': 'edit', + 'KillShell': 'bash', +}; + +/** + * Categorizes a tool name into a predefined category + */ +export function categorizeToolName(toolName: string): ToolCategory { + return TOOL_CATEGORIES[toolName] || 'other'; +} + +export interface LogEntryMetadata { + toolName?: string; + toolCategory?: ToolCategory; + filePath?: string; + summary?: string; + phase?: string; +} + export interface LogEntry { id: string; type: LogEntryType; @@ -22,11 +54,7 @@ export interface LogEntry { content: string; timestamp?: string; collapsed?: boolean; - metadata?: { - toolName?: string; - phase?: string; - [key: string]: string | undefined; - }; + metadata?: LogEntryMetadata; } /** @@ -93,11 +121,14 @@ function detectEntryType(content: string): LogEntryType { return "error"; } - // Success messages + // Success messages and summary sections if ( trimmed.startsWith("āœ…") || trimmed.toLowerCase().includes("success") || - trimmed.toLowerCase().includes("completed") + trimmed.toLowerCase().includes("completed") || + // Markdown summary headers + trimmed.match(/^##\s+(Summary|Feature|Changes|Implementation)/i) || + trimmed.match(/^(I've|I have) (successfully |now )?(completed|finished|implemented)/i) ) { return "success"; } @@ -135,9 +166,11 @@ function detectEntryType(content: string): LogEntryType { /** * Extracts tool name from a tool call entry + * Matches both "šŸ”§ Tool: Name" and "Tool: Name" formats */ function extractToolName(content: string): string | undefined { - const match = content.match(/šŸ”§\s*Tool:\s*(\S+)/); + // Try emoji format first, then plain format + const match = content.match(/(?:šŸ”§\s*)?Tool:\s*(\S+)/); return match?.[1]; } @@ -159,6 +192,134 @@ function extractPhase(content: string): string | undefined { return match?.[1]?.toLowerCase(); } +/** + * Extracts file path from tool input JSON + */ +function extractFilePath(content: string): string | undefined { + try { + const inputMatch = content.match(/Input:\s*([\s\S]*)/); + if (!inputMatch) return undefined; + + const jsonStr = inputMatch[1].trim(); + const parsed = JSON.parse(jsonStr) as Record; + + if (typeof parsed.file_path === 'string') return parsed.file_path; + if (typeof parsed.path === 'string') return parsed.path; + if (typeof parsed.notebook_path === 'string') return parsed.notebook_path; + + return undefined; + } catch { + return undefined; + } +} + +/** + * Generates a smart summary for tool calls based on the tool name and input + */ +export function generateToolSummary(toolName: string, content: string): string | undefined { + try { + // Try to parse JSON input + const inputMatch = content.match(/Input:\s*([\s\S]*)/); + if (!inputMatch) return undefined; + + const jsonStr = inputMatch[1].trim(); + const parsed = JSON.parse(jsonStr) as Record; + + switch (toolName) { + case 'Read': { + const filePath = parsed.file_path as string | undefined; + return `Reading ${filePath?.split('/').pop() || 'file'}`; + } + case 'Edit': { + const filePath = parsed.file_path as string | undefined; + const fileName = filePath?.split('/').pop() || 'file'; + return `Editing ${fileName}`; + } + case 'Write': { + const filePath = parsed.file_path as string | undefined; + return `Writing ${filePath?.split('/').pop() || 'file'}`; + } + case 'Bash': { + const command = parsed.command as string | undefined; + const cmd = command?.slice(0, 50) || ''; + return `Running: ${cmd}${(command?.length || 0) > 50 ? '...' : ''}`; + } + case 'Grep': { + const pattern = parsed.pattern as string | undefined; + return `Searching for "${pattern?.slice(0, 30) || ''}"`; + } + case 'Glob': { + const pattern = parsed.pattern as string | undefined; + return `Finding files: ${pattern || ''}`; + } + case 'TodoWrite': { + const todos = parsed.todos as unknown[] | undefined; + const todoCount = todos?.length || 0; + return `${todoCount} todo item${todoCount !== 1 ? 's' : ''}`; + } + case 'Task': { + const subagentType = parsed.subagent_type as string | undefined; + const description = parsed.description as string | undefined; + return `${subagentType || 'Agent'}: ${description || ''}`; + } + case 'WebSearch': { + const query = parsed.query as string | undefined; + return `Searching: "${query?.slice(0, 40) || ''}"`; + } + case 'WebFetch': { + const url = parsed.url as string | undefined; + return `Fetching: ${url?.slice(0, 40) || ''}`; + } + case 'NotebookEdit': { + const notebookPath = parsed.notebook_path as string | undefined; + return `Editing notebook: ${notebookPath?.split('/').pop() || 'notebook'}`; + } + case 'KillShell': { + return 'Terminating shell session'; + } + default: + return undefined; + } + } catch { + return undefined; + } +} + +/** + * Determines if an entry should be collapsed by default + */ +export function shouldCollapseByDefault(entry: LogEntry): boolean { + // Collapse if content is long + if (entry.content.length > 200) return true; + + // Collapse if contains multi-line JSON (> 5 lines) + const lineCount = entry.content.split('\n').length; + if (lineCount > 5 && (entry.content.includes('{') || entry.content.includes('['))) { + return true; + } + + // Collapse TodoWrite with multiple items + if (entry.metadata?.toolName === 'TodoWrite') { + try { + const inputMatch = entry.content.match(/Input:\s*([\s\S]*)/); + if (inputMatch) { + const parsed = JSON.parse(inputMatch[1].trim()) as Record; + const todos = parsed.todos as unknown[] | undefined; + if (todos && todos.length > 1) return true; + } + } catch { + // Ignore parse errors + } + } + + // Collapse Edit with code blocks + if (entry.metadata?.toolName === 'Edit' && entry.content.includes('old_string')) { + return true; + } + + return false; +} + /** * Generates a title for a log entry */ @@ -183,8 +344,16 @@ function generateTitle(type: LogEntryType, content: string): string { } case "error": return "Error"; - case "success": + case "success": { + // Check if it's a summary section + if (content.match(/^##\s+(Summary|Feature|Changes|Implementation)/i)) { + return "Summary"; + } + if (content.match(/^All tasks completed/i) || content.match(/^(I've|I have) (successfully |now )?(completed|finished|implemented)/i)) { + return "Summary"; + } return "Success"; + } case "warning": return "Warning"; case "thinking": @@ -198,6 +367,39 @@ function generateTitle(type: LogEntryType, content: string): string { } } +/** + * Tracks bracket depth for JSON accumulation + */ +function calculateBracketDepth(line: string): { braceChange: number; bracketChange: number } { + let braceChange = 0; + let bracketChange = 0; + let inString = false; + let escapeNext = false; + + for (const char of line) { + if (escapeNext) { + escapeNext = false; + continue; + } + if (char === '\\') { + escapeNext = true; + continue; + } + if (char === '"') { + inString = !inString; + continue; + } + if (inString) continue; + + if (char === '{') braceChange++; + else if (char === '}') braceChange--; + else if (char === '[') bracketChange++; + else if (char === ']') bracketChange--; + } + + return { braceChange, bracketChange }; +} + /** * Parses raw log output into structured entries */ @@ -213,10 +415,30 @@ export function parseLogOutput(rawOutput: string): LogEntry[] { let currentContent: string[] = []; let entryStartLine = 0; // Track the starting line for deterministic ID generation + // JSON accumulation state + let inJsonAccumulation = false; + let jsonBraceDepth = 0; + let jsonBracketDepth = 0; + const finalizeEntry = () => { if (currentEntry && currentContent.length > 0) { currentEntry.content = currentContent.join("\n").trim(); if (currentEntry.content) { + // Populate enhanced metadata for tool calls + const toolName = currentEntry.metadata?.toolName; + if (toolName && currentEntry.type === 'tool_call') { + const toolCategory = categorizeToolName(toolName); + const filePath = extractFilePath(currentEntry.content); + const summary = generateToolSummary(toolName, currentEntry.content); + + currentEntry.metadata = { + ...currentEntry.metadata, + toolCategory, + filePath, + summary, + }; + } + // Generate deterministic ID based on content and position const entryWithId: LogEntry = { ...currentEntry as Omit, @@ -226,6 +448,9 @@ export function parseLogOutput(rawOutput: string): LogEntry[] { } } currentContent = []; + inJsonAccumulation = false; + jsonBraceDepth = 0; + jsonBracketDepth = 0; }; let lineIndex = 0; @@ -238,6 +463,23 @@ export function parseLogOutput(rawOutput: string): LogEntry[] { continue; } + // If we're in JSON accumulation mode, keep accumulating until depth returns to 0 + if (inJsonAccumulation) { + currentContent.push(line); + const { braceChange, bracketChange } = calculateBracketDepth(trimmedLine); + jsonBraceDepth += braceChange; + jsonBracketDepth += bracketChange; + + // JSON is complete when depth returns to 0 + if (jsonBraceDepth <= 0 && jsonBracketDepth <= 0) { + inJsonAccumulation = false; + jsonBraceDepth = 0; + jsonBracketDepth = 0; + } + lineIndex++; + continue; + } + // Detect if this line starts a new entry const lineType = detectEntryType(trimmedLine); const isNewEntry = @@ -257,7 +499,14 @@ export function parseLogOutput(rawOutput: string): LogEntry[] { trimmedLine.match(/\[Status\]/i) || trimmedLine.toLowerCase().includes("ultrathink preparation") || trimmedLine.toLowerCase().includes("thinking level") || - (trimmedLine.startsWith("Input:") && currentEntry?.type === "tool_call"); + // Agent summary sections (markdown headers after tool calls) + trimmedLine.match(/^##\s+(Summary|Feature|Changes|Implementation)/i) || + // Summary introduction lines + trimmedLine.match(/^All tasks completed/i) || + trimmedLine.match(/^(I've|I have) (successfully |now )?(completed|finished|implemented)/i); + + // Check if this is an Input: line that should trigger JSON accumulation + const isInputLine = trimmedLine.startsWith("Input:") && currentEntry?.type === "tool_call"; if (isNewEntry) { // Finalize previous entry @@ -277,9 +526,40 @@ export function parseLogOutput(rawOutput: string): LogEntry[] { }, }; currentContent.push(trimmedLine); + } else if (isInputLine && currentEntry) { + // Start JSON accumulation mode + currentContent.push(trimmedLine); + + // Check if there's JSON on the same line after "Input:" + const inputContent = trimmedLine.replace(/^Input:\s*/, ''); + if (inputContent) { + const { braceChange, bracketChange } = calculateBracketDepth(inputContent); + jsonBraceDepth = braceChange; + jsonBracketDepth = bracketChange; + + // Only enter accumulation mode if JSON is incomplete + if (jsonBraceDepth > 0 || jsonBracketDepth > 0) { + inJsonAccumulation = true; + } + } else { + // Input: line with JSON starting on next line + inJsonAccumulation = true; + } } else if (currentEntry) { // Continue current entry currentContent.push(line); + + // Check if this line starts a JSON block + if (trimmedLine.startsWith('{') || trimmedLine.startsWith('[')) { + const { braceChange, bracketChange } = calculateBracketDepth(trimmedLine); + if (braceChange > 0 || bracketChange > 0) { + jsonBraceDepth = braceChange; + jsonBracketDepth = bracketChange; + if (jsonBraceDepth > 0 || jsonBracketDepth > 0) { + inJsonAccumulation = true; + } + } + } } else { // Track starting line for deterministic ID entryStartLine = lineIndex; diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index c40c91ff..1f81ff0c 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -1352,8 +1352,9 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. // Handle error messages throw new Error(msg.error || "Unknown error"); } else if (msg.type === "result" && msg.subtype === "success") { - responseText = msg.result || responseText; - // Schedule write for final result + // Don't replace responseText - the accumulated content is the full history + // The msg.result is just a summary which would lose all tool use details + // Just ensure final write happens scheduleWrite(); } } From 2a782392bcb665e89ee61ca7f44aa5433e38fc35 Mon Sep 17 00:00:00 2001 From: Kacper Date: Wed, 17 Dec 2025 14:33:13 +0100 Subject: [PATCH 3/6] feat: enhance log parsing to support tags for structured output - Introduced support for tags in log entries, allowing for better organization and parsing of summary content. - Updated the detectEntryType function to recognize tags as a preferred format for summaries. - Implemented summary accumulation logic to handle content between and tags. - Modified the prompt in auto-mode service to instruct users to wrap their summaries in tags for consistency in log output. --- apps/app/src/components/ui/log-viewer.tsx | 9 ++++- apps/app/src/lib/log-parser.ts | 39 ++++++++++++++++--- apps/server/src/services/auto-mode-service.ts | 17 +++++++- 3 files changed, 58 insertions(+), 7 deletions(-) diff --git a/apps/app/src/components/ui/log-viewer.tsx b/apps/app/src/components/ui/log-viewer.tsx index 059ebc78..d962a4fc 100644 --- a/apps/app/src/components/ui/log-viewer.tsx +++ b/apps/app/src/components/ui/log-viewer.tsx @@ -156,6 +156,13 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) { content = content.trim(); } + // For summary entries, remove the and tags + if (entry.title === "Summary") { + content = content.replace(/^\s*/i, ""); + content = content.replace(/\s*<\/summary>\s*$/i, ""); + content = content.trim(); + } + // Try to find and format JSON blocks const jsonRegex = /(\{[\s\S]*?\}|\[[\s\S]*?\])/g; let lastIndex = 0; @@ -192,7 +199,7 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) { } return parts.length > 0 ? parts : [{ type: "text" as const, content }]; - }, [entry.content, isToolCall]); + }, [entry.content, entry.title, isToolCall]); // Get colors - use tool category colors for tool_call entries const colorParts = toolCategoryColors.split(" "); diff --git a/apps/app/src/lib/log-parser.ts b/apps/app/src/lib/log-parser.ts index 725b9b24..85fa96c6 100644 --- a/apps/app/src/lib/log-parser.ts +++ b/apps/app/src/lib/log-parser.ts @@ -126,7 +126,9 @@ function detectEntryType(content: string): LogEntryType { trimmed.startsWith("āœ…") || trimmed.toLowerCase().includes("success") || trimmed.toLowerCase().includes("completed") || - // Markdown summary headers + // Summary tags (preferred format from agent) + trimmed.startsWith("") || + // Markdown summary headers (fallback) trimmed.match(/^##\s+(Summary|Feature|Changes|Implementation)/i) || trimmed.match(/^(I've|I have) (successfully |now )?(completed|finished|implemented)/i) ) { @@ -138,10 +140,11 @@ function detectEntryType(content: string): LogEntryType { return "warning"; } - // Thinking/Preparation info + // Thinking/Preparation info (be specific to avoid matching summary content) if ( trimmed.toLowerCase().includes("ultrathink") || - trimmed.toLowerCase().includes("thinking level") || + trimmed.match(/thinking level[:\s]*(low|medium|high|none|\d)/i) || + trimmed.match(/^thinking level\s*$/i) || trimmed.toLowerCase().includes("estimated cost") || trimmed.toLowerCase().includes("estimated time") || trimmed.toLowerCase().includes("budget tokens") || @@ -346,6 +349,9 @@ function generateTitle(type: LogEntryType, content: string): string { return "Error"; case "success": { // Check if it's a summary section + if (content.startsWith("") || content.includes("")) { + return "Summary"; + } if (content.match(/^##\s+(Summary|Feature|Changes|Implementation)/i)) { return "Summary"; } @@ -420,6 +426,9 @@ export function parseLogOutput(rawOutput: string): LogEntry[] { let jsonBraceDepth = 0; let jsonBracketDepth = 0; + // Summary tag accumulation state + let inSummaryAccumulation = false; + const finalizeEntry = () => { if (currentEntry && currentContent.length > 0) { currentEntry.content = currentContent.join("\n").trim(); @@ -451,6 +460,7 @@ export function parseLogOutput(rawOutput: string): LogEntry[] { inJsonAccumulation = false; jsonBraceDepth = 0; jsonBracketDepth = 0; + inSummaryAccumulation = false; }; let lineIndex = 0; @@ -480,6 +490,18 @@ export function parseLogOutput(rawOutput: string): LogEntry[] { continue; } + // If we're in summary accumulation mode, keep accumulating until + if (inSummaryAccumulation) { + currentContent.push(line); + // Summary is complete when we see closing tag + if (trimmedLine.includes("")) { + inSummaryAccumulation = false; + // Don't finalize here - let normal flow handle it + } + lineIndex++; + continue; + } + // Detect if this line starts a new entry const lineType = detectEntryType(trimmedLine); const isNewEntry = @@ -498,8 +520,10 @@ export function parseLogOutput(rawOutput: string): LogEntry[] { trimmedLine.match(/\[ERROR\]/i) || trimmedLine.match(/\[Status\]/i) || trimmedLine.toLowerCase().includes("ultrathink preparation") || - trimmedLine.toLowerCase().includes("thinking level") || - // Agent summary sections (markdown headers after tool calls) + trimmedLine.match(/thinking level[:\s]*(low|medium|high|none|\d)/i) || + // Summary tags (preferred format from agent) + trimmedLine.startsWith("") || + // Agent summary sections (markdown headers - fallback) trimmedLine.match(/^##\s+(Summary|Feature|Changes|Implementation)/i) || // Summary introduction lines trimmedLine.match(/^All tasks completed/i) || @@ -526,6 +550,11 @@ export function parseLogOutput(rawOutput: string): LogEntry[] { }, }; currentContent.push(trimmedLine); + + // If this is a tag, start summary accumulation mode + if (trimmedLine.startsWith("") && !trimmedLine.includes("")) { + inSummaryAccumulation = true; + } } else if (isInputLine && currentEntry) { // Start JSON accumulation mode currentContent.push(trimmedLine); diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 1f81ff0c..40c434c0 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -1143,7 +1143,22 @@ Implement this feature by: 4. Add or update tests as needed 5. Ensure the code follows existing patterns and conventions -When done, summarize what you implemented and any notes for the developer.`; +When done, wrap your final summary in tags like this: + + +## Summary: [Feature Title] + +### Changes Implemented +- [List of changes made] + +### Files Modified +- [List of files] + +### Notes for Developer +- [Any important notes] + + +This helps parse your summary correctly in the output logs.`; return prompt; } From b59bbd93bab8d3197aa73e18ef465531b766ee3c Mon Sep 17 00:00:00 2001 From: Kacper Date: Wed, 17 Dec 2025 14:54:32 +0100 Subject: [PATCH 4/6] feat: add TodoWrite support in log viewer for enhanced task management - Introduced a new TodoListRenderer component to display parsed todo items with status indicators and colors. - Implemented a parseTodoContent function to extract todo items from TodoWrite JSON content. - Enhanced LogEntryItem to conditionally render todo items when a TodoWrite entry is detected, improving log entry clarity and usability. - Updated UI to visually differentiate between todo item statuses, enhancing user experience in task tracking. --- apps/app/src/components/ui/log-viewer.tsx | 161 +++++++++++++++--- apps/server/src/services/auto-mode-service.ts | 12 +- 2 files changed, 150 insertions(+), 23 deletions(-) diff --git a/apps/app/src/components/ui/log-viewer.tsx b/apps/app/src/components/ui/log-viewer.tsx index d962a4fc..a926e2d9 100644 --- a/apps/app/src/components/ui/log-viewer.tsx +++ b/apps/app/src/components/ui/log-viewer.tsx @@ -22,6 +22,9 @@ import { Layers, X, Filter, + Circle, + Play, + Loader2, } from "lucide-react"; import { cn } from "@/lib/utils"; import { @@ -111,6 +114,112 @@ const getToolCategoryColor = (category: ToolCategory | undefined): string => { } }; +/** + * Interface for parsed todo items from TodoWrite tool + */ +interface TodoItem { + content: string; + status: "pending" | "in_progress" | "completed"; + activeForm?: string; +} + +/** + * Parses TodoWrite JSON content and extracts todo items + */ +function parseTodoContent(content: string): TodoItem[] | null { + try { + // Find the JSON object in the content + const jsonMatch = content.match(/\{[\s\S]*"todos"[\s\S]*\}/); + if (!jsonMatch) return null; + + const parsed = JSON.parse(jsonMatch[0]) as { todos?: TodoItem[] }; + if (!parsed.todos || !Array.isArray(parsed.todos)) return null; + + return parsed.todos; + } catch { + return null; + } +} + +/** + * Renders a list of todo items with status icons and colors + */ +function TodoListRenderer({ todos }: { todos: TodoItem[] }) { + const getStatusIcon = (status: TodoItem["status"]) => { + switch (status) { + case "completed": + return ; + case "in_progress": + return ; + case "pending": + return ; + default: + return ; + } + }; + + const getStatusColor = (status: TodoItem["status"]) => { + switch (status) { + case "completed": + return "text-emerald-300 line-through opacity-70"; + case "in_progress": + return "text-amber-300"; + case "pending": + return "text-zinc-400"; + default: + return "text-zinc-400"; + } + }; + + const getStatusBadge = (status: TodoItem["status"]) => { + switch (status) { + case "completed": + return ( + + Done + + ); + case "in_progress": + return ( + + In Progress + + ); + default: + return null; + } + }; + + return ( +
+ {todos.map((todo, index) => ( +
+
{getStatusIcon(todo.status)}
+
+

+ {todo.content} +

+ {todo.status === "in_progress" && todo.activeForm && ( +

+ {todo.activeForm} +

+ )} +
+ {getStatusBadge(todo.status)} +
+ ))} +
+ ); +} + interface LogEntryItemProps { entry: LogEntry; isExpanded: boolean; @@ -126,6 +235,13 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) { const toolCategory = entry.metadata?.toolCategory; const toolCategoryColors = isToolCall ? getToolCategoryColor(toolCategory) : ""; + // Check if this is a TodoWrite entry and parse the todos + const isTodoWrite = entry.metadata?.toolName === "TodoWrite"; + const parsedTodos = useMemo(() => { + if (!isTodoWrite) return null; + return parseTodoContent(entry.content); + }, [isTodoWrite, entry.content]); + // Get the appropriate icon based on entry type and tool category const icon = isToolCall ? getToolCategoryIcon(toolCategory) : getLogIcon(entry.type); @@ -256,26 +372,31 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) { className="px-4 pb-3 pt-1" data-testid={`log-entry-content-${entry.id}`} > -
- {formattedContent.map((part, index) => ( -
- {part.type === "json" ? ( -
-                    {part.content}
-                  
- ) : ( -
-                    {part.content}
-                  
- )} -
- ))} -
+ {/* Render TodoWrite entries with special formatting */} + {parsedTodos ? ( + + ) : ( +
+ {formattedContent.map((part, index) => ( +
+ {part.type === "json" ? ( +
+                      {part.content}
+                    
+ ) : ( +
+                      {part.content}
+                    
+ )} +
+ ))} +
+ )}
)}
diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 40c434c0..1ce206c9 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -549,13 +549,15 @@ Address the follow-up instructions above. Review the previous work and make the } // Use fullPrompt (already built above) with model and all images + // Pass previousContext so the history is preserved in the output file await this.runAgent( workDir, featureId, fullPrompt, abortController, allImagePaths.length > 0 ? allImagePaths : imagePaths, - model + model, + previousContext || undefined ); // Mark as waiting_approval for user review @@ -1169,7 +1171,8 @@ This helps parse your summary correctly in the output logs.`; prompt: string, abortController: AbortController, imagePaths?: string[], - model?: string + model?: string, + previousContent?: string ): Promise { // CI/CD Mock Mode: Return early with mock response when AUTOMAKER_MOCK_AGENT is set // This prevents actual API calls during automated testing @@ -1271,7 +1274,10 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. // Execute via provider const stream = provider.executeQuery(options); - let responseText = ""; + // Initialize with previous content if this is a follow-up, with a separator + let responseText = previousContent + ? `${previousContent}\n\n---\n\n## Follow-up Session\n\n` + : ""; // Agent output goes to .automaker directory // Note: We use the original projectPath here (from config), not workDir // because workDir might be a worktree path From 043edde63b5ffcde7fadeca8658501cce575023c Mon Sep 17 00:00:00 2001 From: Kacper Date: Wed, 17 Dec 2025 16:19:31 +0100 Subject: [PATCH 5/6] refactor: implement gemini suggestions --- apps/server/src/services/auto-mode-service.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 1ce206c9..d64b50a8 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -1286,17 +1286,13 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. const outputPath = path.join(featureDirForOutput, "agent-output.md"); // Incremental file writing state - let directoryCreated = false; let writeTimeout: ReturnType | null = null; const WRITE_DEBOUNCE_MS = 500; // Batch writes every 500ms // Helper to write current responseText to file const writeToFile = async (): Promise => { try { - if (!directoryCreated) { - await fs.mkdir(path.dirname(outputPath), { recursive: true }); - directoryCreated = true; - } + await fs.mkdir(path.dirname(outputPath), { recursive: true }); await fs.writeFile(outputPath, responseText); } catch (error) { // Log but don't crash - file write errors shouldn't stop execution @@ -1310,9 +1306,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. clearTimeout(writeTimeout); } writeTimeout = setTimeout(() => { - writeToFile().catch((err) => { - console.error(`[AutoMode] Debounced write error:`, err); - }); + writeToFile(); }, WRITE_DEBOUNCE_MS); }; From e0471fef0906314f647ceadbcaa3d822771a80d2 Mon Sep 17 00:00:00 2001 From: Kacper Date: Wed, 17 Dec 2025 16:36:56 +0100 Subject: [PATCH 6/6] feat: enhance summary extraction to support tags - Updated the extractSummary function to capture content between and tags for improved log parsing. - Retained fallback logic to extract summaries from traditional ## Summary sections, ensuring backward compatibility. --- apps/app/src/lib/agent-context-parser.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/app/src/lib/agent-context-parser.ts b/apps/app/src/lib/agent-context-parser.ts index 925c56fd..feb33678 100644 --- a/apps/app/src/lib/agent-context-parser.ts +++ b/apps/app/src/lib/agent-context-parser.ts @@ -130,9 +130,16 @@ function getCurrentPhase(content: string): "planning" | "action" | "verification /** * Extracts a summary from completed feature context + * Looks for content between and tags */ function extractSummary(content: string): string | undefined { - // Look for summary sections - capture everything including subsections (###) + // Look for tags - capture everything between opening and closing tags + const summaryTagMatch = content.match(/([\s\S]*?)<\/summary>/i); + if (summaryTagMatch) { + return summaryTagMatch[1].trim(); + } + + // Fallback: Look for summary sections - capture everything including subsections (###) // Stop at same-level ## sections (but not ###), or tool markers, or end const summaryMatch = content.match(/## Summary[^\n]*\n([\s\S]*?)(?=\n## [^#]|\nšŸ”§|$)/i); if (summaryMatch) {