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 }
+ );
+}