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
{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();
}
}