diff --git a/.claude/commands/thorough.md b/.claude/commands/thorough.md
new file mode 100644
index 00000000..c69ada0f
--- /dev/null
+++ b/.claude/commands/thorough.md
@@ -0,0 +1,45 @@
+When you think you are done, you are NOT done.
+
+You must run a mandatory 3-pass verification before concluding:
+
+## Pass 1: Correctness & Functionality
+
+- [ ] Verify logic matches requirements and specifications
+- [ ] Check type safety (TypeScript types are correct and complete)
+- [ ] Ensure imports are correct and follow project conventions
+- [ ] Verify all functions/classes work as intended
+- [ ] Check that return values and side effects are correct
+- [ ] Run relevant tests if they exist, or verify testability
+- [ ] Confirm integration with existing code works properly
+
+## Pass 2: Edge Cases & Safety
+
+- [ ] Handle null/undefined inputs gracefully
+- [ ] Validate all user inputs and external data
+- [ ] Check error handling (try/catch, error boundaries, etc.)
+- [ ] Verify security considerations (no sensitive data exposure, proper auth checks)
+- [ ] Test boundary conditions (empty arrays, zero values, max lengths, etc.)
+- [ ] Ensure resource cleanup (file handles, connections, timers)
+- [ ] Check for potential race conditions or async issues
+- [ ] Verify file path security (no directory traversal vulnerabilities)
+
+## Pass 3: Maintainability & Code Quality
+
+- [ ] Code follows project style guide and conventions
+- [ ] Functions/classes are single-purpose and well-named
+- [ ] Remove dead code, unused imports, and console.logs
+- [ ] Extract magic numbers/strings into named constants
+- [ ] Check for code duplication (DRY principle)
+- [ ] Verify appropriate abstraction levels (not over/under-engineered)
+- [ ] Add necessary comments for complex logic
+- [ ] Ensure consistent error messages and logging
+- [ ] Check that code is readable and self-documenting
+- [ ] Verify proper separation of concerns
+
+**For each pass, explicitly report:**
+
+- What you checked
+- Any issues found and how they were fixed
+- Any remaining concerns or trade-offs
+
+Only after completing all three passes with explicit findings may you conclude the work is done.
diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx
index b36dea20..1fbe6ee1 100644
--- a/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx
+++ b/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx
@@ -255,6 +255,45 @@ export function AgentInfoPanel({
);
}
+ // Show just the todo list for non-backlog features when showAgentInfo is false
+ // This ensures users always see what the agent is working on
+ if (!showAgentInfo && feature.status !== 'backlog' && agentInfo && agentInfo.todos.length > 0) {
+ return (
+
+
+
+
+ {agentInfo.todos.filter((t) => t.status === 'completed').length}/
+ {agentInfo.todos.length} tasks
+
+
+
+ {agentInfo.todos.map((todo, idx) => (
+
+ {todo.status === 'completed' ? (
+
+ ) : todo.status === 'in_progress' ? (
+
+ ) : (
+
+ )}
+
+ {todo.content}
+
+
+ ))}
+
+
+ );
+ }
+
// Always render SummaryDialog if showAgentInfo is true (even if no agentInfo yet)
// This ensures the dialog can be opened from the expand button
return (
diff --git a/apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx b/apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx
index 58fe3ad6..5124f7af 100644
--- a/apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx
+++ b/apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState } from 'react';
+import { useEffect, useRef, useState, useMemo } from 'react';
import {
Dialog,
DialogContent,
@@ -6,12 +6,14 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
-import { Loader2, List, FileText, GitBranch } from 'lucide-react';
+import { Loader2, List, FileText, GitBranch, ClipboardList } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { LogViewer } from '@/components/ui/log-viewer';
import { GitDiffPanel } from '@/components/ui/git-diff-panel';
import { TaskProgressPanel } from '@/components/ui/task-progress-panel';
+import { Markdown } from '@/components/ui/markdown';
import { useAppStore } from '@/store/app-store';
+import { extractSummary } from '@/lib/log-parser';
import type { AutoModeEvent } from '@/types/electron';
interface AgentOutputModalProps {
@@ -27,7 +29,7 @@ interface AgentOutputModalProps {
projectPath?: string;
}
-type ViewMode = 'parsed' | 'raw' | 'changes';
+type ViewMode = 'summary' | 'parsed' | 'raw' | 'changes';
export function AgentOutputModal({
open,
@@ -40,8 +42,14 @@ export function AgentOutputModal({
}: AgentOutputModalProps) {
const [output, setOutput] = useState('');
const [isLoading, setIsLoading] = useState(true);
- const [viewMode, setViewMode] = useState('parsed');
+ const [viewMode, setViewMode] = useState(null);
const [projectPath, setProjectPath] = useState('');
+
+ // Extract summary from output
+ const summary = useMemo(() => extractSummary(output), [output]);
+
+ // Determine the effective view mode - default to summary if available, otherwise parsed
+ const effectiveViewMode = viewMode ?? (summary ? 'summary' : 'parsed');
const scrollRef = useRef(null);
const autoScrollRef = useRef(true);
const projectPathRef = useRef('');
@@ -299,8 +307,8 @@ export function AgentOutputModal({
className="w-[60vw] max-w-[60vw] max-h-[80vh] flex flex-col"
data-testid="agent-output-modal"
>
-
-
+
+
{featureStatus !== 'verified' && featureStatus !== 'waiting_approval' && (
@@ -308,10 +316,24 @@ export function AgentOutputModal({
Agent Output
+ {summary && (
+
setViewMode('summary')}
+ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
+ effectiveViewMode === 'summary'
+ ? 'bg-primary/20 text-primary shadow-sm'
+ : 'text-muted-foreground hover:text-foreground hover:bg-accent'
+ }`}
+ data-testid="view-mode-summary"
+ >
+
+ Summary
+
+ )}
setViewMode('parsed')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
- viewMode === 'parsed'
+ effectiveViewMode === 'parsed'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
@@ -323,7 +345,7 @@ export function AgentOutputModal({
setViewMode('changes')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
- viewMode === 'changes'
+ effectiveViewMode === 'changes'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
@@ -335,7 +357,7 @@ export function AgentOutputModal({
setViewMode('raw')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
- viewMode === 'raw'
+ effectiveViewMode === 'raw'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
@@ -361,7 +383,7 @@ export function AgentOutputModal({
className="flex-shrink-0 mx-1"
/>
- {viewMode === 'changes' ? (
+ {effectiveViewMode === 'changes' ? (
{projectPath ? (
)}
+ ) : effectiveViewMode === 'summary' && summary ? (
+
+ {summary}
+
) : (
<>
No output yet. The agent will stream output here as it works.
- ) : viewMode === 'parsed' ? (
+ ) : effectiveViewMode === 'parsed' ? (
) : (
{output}
diff --git a/apps/ui/src/lib/agent-context-parser.ts b/apps/ui/src/lib/agent-context-parser.ts
index be82aa94..9bdb141b 100644
--- a/apps/ui/src/lib/agent-context-parser.ts
+++ b/apps/ui/src/lib/agent-context-parser.ts
@@ -39,55 +39,118 @@ export function formatModelName(model: string): string {
return model.split('-').slice(1, 3).join(' ');
}
+/**
+ * Helper to extract a balanced JSON object from a string starting at a given position
+ */
+function extractJsonObject(str: string, startIdx: number): string | null {
+ if (str[startIdx] !== '{') return null;
+
+ let depth = 0;
+ let inString = false;
+ let escapeNext = false;
+
+ for (let i = startIdx; i < str.length; i++) {
+ const char = str[i];
+
+ if (escapeNext) {
+ escapeNext = false;
+ continue;
+ }
+
+ if (char === '\\' && inString) {
+ escapeNext = true;
+ continue;
+ }
+
+ if (char === '"' && !escapeNext) {
+ inString = !inString;
+ continue;
+ }
+
+ if (inString) continue;
+
+ if (char === '{') depth++;
+ else if (char === '}') {
+ depth--;
+ if (depth === 0) {
+ return str.slice(startIdx, i + 1);
+ }
+ }
+ }
+
+ return null;
+}
+
/**
* Extracts todos from the context content
* Looks for TodoWrite tool calls in the format:
- * TodoWrite: [{"content": "...", "status": "..."}]
+ * š§ Tool: TodoWrite
+ * Input: {"todos": [{"content": "...", "status": "..."}]}
*/
function extractTodos(content: string): AgentTaskInfo['todos'] {
const todos: AgentTaskInfo['todos'] = [];
- // Look for TodoWrite tool inputs
- const todoMatches = content.matchAll(
- /TodoWrite.*?(?:"todos"\s*:\s*)?(\[[\s\S]*?\](?=\s*(?:\}|$|š§|š|ā”|ā
|ā)))/g
- );
+ // Find all occurrences of TodoWrite tool calls
+ const todoWriteMarker = 'š§ Tool: TodoWrite';
+ let searchStart = 0;
- for (const match of todoMatches) {
- try {
- // Try to find JSON array in the match
- const jsonStr = match[1] || match[0];
- const arrayMatch = jsonStr.match(/\[[\s\S]*?\]/);
- if (arrayMatch) {
- const parsed = JSON.parse(arrayMatch[0]);
- if (Array.isArray(parsed)) {
- for (const item of parsed) {
+ while (true) {
+ const markerIdx = content.indexOf(todoWriteMarker, searchStart);
+ if (markerIdx === -1) break;
+
+ // Look for "Input:" after the marker
+ const inputIdx = content.indexOf('Input:', markerIdx);
+ if (inputIdx === -1 || inputIdx > markerIdx + 100) {
+ searchStart = markerIdx + 1;
+ continue;
+ }
+
+ // Find the start of the JSON object
+ const jsonStart = content.indexOf('{', inputIdx);
+ if (jsonStart === -1) {
+ searchStart = markerIdx + 1;
+ continue;
+ }
+
+ // Extract the complete JSON object
+ const jsonStr = extractJsonObject(content, jsonStart);
+ if (jsonStr) {
+ try {
+ const parsed = JSON.parse(jsonStr) as {
+ todos?: Array<{ content: string; status: string }>;
+ };
+ if (parsed.todos && Array.isArray(parsed.todos)) {
+ // Clear previous todos - we want the latest state
+ todos.length = 0;
+ for (const item of parsed.todos) {
if (item.content && item.status) {
- // Check if this todo already exists (avoid duplicates)
- if (!todos.some((t) => t.content === item.content)) {
- todos.push({
- content: item.content,
- status: item.status,
- });
- }
+ todos.push({
+ content: item.content,
+ status: item.status as 'pending' | 'in_progress' | 'completed',
+ });
}
}
}
+ } catch {
+ // Ignore parse errors
}
- } catch {
- // Ignore parse errors
}
+
+ searchStart = markerIdx + 1;
}
- // Also try to extract from markdown task lists
- const markdownTodos = content.matchAll(/- \[([ xX])\] (.+)/g);
- for (const match of markdownTodos) {
- const isCompleted = match[1].toLowerCase() === 'x';
- const content = match[2].trim();
- if (!todos.some((t) => t.content === content)) {
- todos.push({
- content,
- status: isCompleted ? 'completed' : 'pending',
- });
+ // Also try to extract from markdown task lists as fallback
+ if (todos.length === 0) {
+ const markdownTodos = content.matchAll(/- \[([ xX])\] (.+)/g);
+ for (const match of markdownTodos) {
+ const isCompleted = match[1].toLowerCase() === 'x';
+ const todoContent = match[2].trim();
+ if (!todos.some((t) => t.content === todoContent)) {
+ todos.push({
+ content: todoContent,
+ status: isCompleted ? 'completed' : 'pending',
+ });
+ }
}
}
diff --git a/apps/ui/src/lib/log-parser.ts b/apps/ui/src/lib/log-parser.ts
index b7a86abe..c1d70eef 100644
--- a/apps/ui/src/lib/log-parser.ts
+++ b/apps/ui/src/lib/log-parser.ts
@@ -664,6 +664,53 @@ function mergeConsecutiveEntries(entries: LogEntry[]): LogEntry[] {
return merged;
}
+/**
+ * Extracts summary content from raw log output
+ * Returns the summary text if found, or null if no summary exists
+ */
+export function extractSummary(rawOutput: string): string | null {
+ if (!rawOutput || !rawOutput.trim()) {
+ return null;
+ }
+
+ // Try to find tags first (preferred format)
+ const summaryTagMatch = rawOutput.match(/([\s\S]*?)<\/summary>/);
+ if (summaryTagMatch) {
+ return summaryTagMatch[1].trim();
+ }
+
+ // Try to find markdown ## Summary section
+ const summaryHeaderMatch = rawOutput.match(/^##\s+Summary\s*\n([\s\S]*?)(?=\n##\s+|$)/m);
+ if (summaryHeaderMatch) {
+ return summaryHeaderMatch[1].trim();
+ }
+
+ // Try other summary formats (Feature, Changes, Implementation)
+ const otherHeaderMatch = rawOutput.match(
+ /^##\s+(Feature|Changes|Implementation)\s*\n([\s\S]*?)(?=\n##\s+|$)/m
+ );
+ if (otherHeaderMatch) {
+ return `## ${otherHeaderMatch[1]}\n${otherHeaderMatch[2].trim()}`;
+ }
+
+ // Try to find summary introduction lines
+ const introMatch = rawOutput.match(
+ /(^|\n)(All tasks completed[\s\S]*?)(?=\nš§|\nš|\nā”|\nā|$)/
+ );
+ if (introMatch) {
+ return introMatch[2].trim();
+ }
+
+ const completionMatch = rawOutput.match(
+ /(^|\n)((I've|I have) (successfully |now )?(completed|finished|implemented)[\s\S]*?)(?=\nš§|\nš|\nā”|\nā|$)/
+ );
+ if (completionMatch) {
+ return completionMatch[2].trim();
+ }
+
+ return null;
+}
+
/**
* Gets the color classes for a log entry type
*/