diff --git a/src/__tests__/renderer/components/FileExplorerPanel.test.tsx b/src/__tests__/renderer/components/FileExplorerPanel.test.tsx index d7ba4bc8..6dbc3de3 100644 --- a/src/__tests__/renderer/components/FileExplorerPanel.test.tsx +++ b/src/__tests__/renderer/components/FileExplorerPanel.test.tsx @@ -1391,6 +1391,19 @@ describe('FileExplorerPanel', () => { expect(screen.getByText('Reveal in Finder')).toBeInTheDocument(); }); + it('updates selection to right-clicked item when opening context menu', () => { + const { container } = render(); + // package.json is at index 1 (after src at index 0) + const fileItem = Array.from(container.querySelectorAll('[data-file-index]')) + .find(el => el.textContent?.includes('package.json')); + const fileIndex = parseInt(fileItem!.getAttribute('data-file-index')!, 10); + + fireEvent.contextMenu(fileItem!, { clientX: 100, clientY: 200 }); + + // Should update selection to the right-clicked item + expect(defaultProps.setSelectedFileIndex).toHaveBeenCalledWith(fileIndex); + }); + it('shows Document Graph option only for markdown files', () => { const onFocusFileInGraph = vi.fn(); const { container } = render( diff --git a/src/__tests__/renderer/components/LogViewer.test.tsx b/src/__tests__/renderer/components/LogViewer.test.tsx index 791bf9b3..d40c91a3 100644 --- a/src/__tests__/renderer/components/LogViewer.test.tsx +++ b/src/__tests__/renderer/components/LogViewer.test.tsx @@ -41,7 +41,7 @@ const mockTheme: Theme = { // Mock log entries const createMockLog = (overrides: Partial<{ timestamp: number; - level: 'debug' | 'info' | 'warn' | 'error' | 'toast'; + level: 'debug' | 'info' | 'warn' | 'error' | 'toast' | 'autorun'; message: string; context?: string; data?: unknown; @@ -1014,6 +1014,83 @@ describe('LogViewer', () => { expect(screen.queryByText('TestModule')).not.toBeInTheDocument(); }); }); + + it('should display agent pill for toast entries with project in data', async () => { + getMockGetLogs().mockResolvedValue([ + createMockLog({ + level: 'toast', + message: 'Toast message', + data: { project: 'Test Agent', type: 'success' } + }), + ]); + + render(); + + await waitFor(() => { + expect(screen.getByText('Test Agent')).toBeInTheDocument(); + }); + }); + + it('should display agent pill for autorun entries with context', async () => { + getMockGetLogs().mockResolvedValue([ + createMockLog({ + level: 'autorun', + message: 'Auto run started', + context: 'My Session' + }), + ]); + + render(); + + await waitFor(() => { + expect(screen.getByText('My Session')).toBeInTheDocument(); + }); + }); + + it('should not show agent pill for toast entries without project', async () => { + getMockGetLogs().mockResolvedValue([ + createMockLog({ + level: 'toast', + message: 'Toast message', + data: { type: 'info' } // No project field + }), + ]); + + render(); + + await waitFor(() => { + expect(screen.getByText('Toast message')).toBeInTheDocument(); + // Should not have any agent pill text + expect(screen.queryByText('Test Agent')).not.toBeInTheDocument(); + }); + }); + + it('should not show context badge for toast entries', async () => { + getMockGetLogs().mockResolvedValue([ + createMockLog({ + level: 'toast', + message: 'Test notification', + context: 'Toast', + data: { project: 'Agent Name' } + }), + ]); + + render(); + + await waitFor(() => { + // Should show the agent name from data.project + expect(screen.getByText('Agent Name')).toBeInTheDocument(); + // The context "Toast" should not be shown as a separate badge for toast entries + // The level pill shows "toast" (lowercase), and context "Toast" (capitalized) + // should not appear as a separate context badge + const toastLevelPill = screen.getByText('toast'); + expect(toastLevelPill).toBeInTheDocument(); + // Make sure there's no separate "Toast" context badge (distinct from the level pill) + const allTextElements = screen.queryAllByText('Toast'); + // Should be 0 - the context "Toast" is not displayed for toast level entries + expect(allTextElements.length).toBe(0); + }); + }); }); describe('Footer', () => { diff --git a/src/__tests__/shared/synopsis.test.ts b/src/__tests__/shared/synopsis.test.ts index e1655444..685eda1b 100644 --- a/src/__tests__/shared/synopsis.test.ts +++ b/src/__tests__/shared/synopsis.test.ts @@ -3,11 +3,13 @@ * * Coverage: * - parseSynopsis: Parse synopsis response into summary and full text + * - isNothingToReport: Check if response indicates nothing to report + * - NOTHING_TO_REPORT: Sentinel token constant * - ParsedSynopsis: Interface for parsed result */ import { describe, it, expect } from 'vitest'; -import { parseSynopsis, ParsedSynopsis } from '../../shared/synopsis'; +import { parseSynopsis, ParsedSynopsis, isNothingToReport, NOTHING_TO_REPORT } from '../../shared/synopsis'; describe('synopsis', () => { describe('parseSynopsis', () => { @@ -18,6 +20,7 @@ describe('synopsis', () => { expect(result.shortSummary).toBe('Fixed the authentication bug'); expect(result.fullSynopsis).toBe('Fixed the authentication bug\n\nUpdated the login handler to properly validate tokens and handle edge cases.'); + expect(result.nothingToReport).toBe(false); }); it('should parse response with Summary only', () => { @@ -26,6 +29,7 @@ describe('synopsis', () => { expect(result.shortSummary).toBe('No changes made.'); expect(result.fullSynopsis).toBe('No changes made.'); + expect(result.nothingToReport).toBe(false); }); it('should handle case-insensitive section headers', () => { @@ -213,13 +217,15 @@ detail line two`; }); describe('return type validation', () => { - it('should always return object with shortSummary and fullSynopsis', () => { + it('should always return object with shortSummary, fullSynopsis, and nothingToReport', () => { const result = parseSynopsis('test'); expect(result).toHaveProperty('shortSummary'); expect(result).toHaveProperty('fullSynopsis'); + expect(result).toHaveProperty('nothingToReport'); expect(typeof result.shortSummary).toBe('string'); expect(typeof result.fullSynopsis).toBe('string'); + expect(typeof result.nothingToReport).toBe('boolean'); }); it('should satisfy ParsedSynopsis interface', () => { @@ -229,7 +235,86 @@ detail line two`; // Runtime check that properties exist expect(result.shortSummary).toBeDefined(); expect(result.fullSynopsis).toBeDefined(); + expect(result.nothingToReport).toBe(false); + }); + }); + + describe('NOTHING_TO_REPORT detection', () => { + it('should detect NOTHING_TO_REPORT token and return nothingToReport: true', () => { + const result = parseSynopsis('NOTHING_TO_REPORT'); + + expect(result.nothingToReport).toBe(true); + expect(result.shortSummary).toBe(''); + expect(result.fullSynopsis).toBe(''); + }); + + it('should detect NOTHING_TO_REPORT with surrounding whitespace', () => { + const result = parseSynopsis(' \n NOTHING_TO_REPORT \n '); + + expect(result.nothingToReport).toBe(true); + expect(result.shortSummary).toBe(''); + expect(result.fullSynopsis).toBe(''); + }); + + it('should detect NOTHING_TO_REPORT with ANSI codes', () => { + const result = parseSynopsis('\x1b[32mNOTHING_TO_REPORT\x1b[0m'); + + expect(result.nothingToReport).toBe(true); + }); + + it('should detect NOTHING_TO_REPORT with box drawing characters', () => { + const result = parseSynopsis('───────\n│NOTHING_TO_REPORT│\n───────'); + + expect(result.nothingToReport).toBe(true); + }); + + it('should return nothingToReport: false for normal synopsis', () => { + const result = parseSynopsis('**Summary:** Fixed the bug\n\n**Details:** Updated code.'); + + expect(result.nothingToReport).toBe(false); + expect(result.shortSummary).toBe('Fixed the bug'); + }); + + it('should return nothingToReport: false for empty responses', () => { + // Empty responses should fall back to "Task completed", not NOTHING_TO_REPORT + const result = parseSynopsis(''); + + expect(result.nothingToReport).toBe(false); + expect(result.shortSummary).toBe('Task completed'); }); }); }); + + describe('isNothingToReport', () => { + it('should return true for exact NOTHING_TO_REPORT', () => { + expect(isNothingToReport('NOTHING_TO_REPORT')).toBe(true); + }); + + it('should return true when NOTHING_TO_REPORT is embedded in response', () => { + expect(isNothingToReport('Some preamble\nNOTHING_TO_REPORT\nSome postamble')).toBe(true); + }); + + it('should return true with ANSI codes around token', () => { + expect(isNothingToReport('\x1b[32mNOTHING_TO_REPORT\x1b[0m')).toBe(true); + }); + + it('should return false for normal responses', () => { + expect(isNothingToReport('**Summary:** Fixed the bug')).toBe(false); + }); + + it('should return false for empty string', () => { + expect(isNothingToReport('')).toBe(false); + }); + + it('should return false for partial matches', () => { + expect(isNothingToReport('NOTHING_TO')).toBe(false); + expect(isNothingToReport('TO_REPORT')).toBe(false); + }); + }); + + describe('NOTHING_TO_REPORT constant', () => { + it('should be the expected string value', () => { + expect(NOTHING_TO_REPORT).toBe('NOTHING_TO_REPORT'); + }); + }); }); diff --git a/src/prompts/autorun-synopsis.md b/src/prompts/autorun-synopsis.md index 13c65bdc..84cf0340 100644 --- a/src/prompts/autorun-synopsis.md +++ b/src/prompts/autorun-synopsis.md @@ -9,4 +9,5 @@ Rules: - Focus only on meaningful work that was done. Omit filler phrases like "the task is complete", "no further action needed", "everything is working", etc. - NEVER include preamble about session context, interaction history, or caveats like "This is our first interaction", "there's no prior work to summarize", "you asked me to", etc. Jump straight to the accomplishment. - Start directly with the action taken (e.g., "Fixed button visibility..." not "You asked me to fix..."). -- If nothing meaningful was accomplished, respond with only: **Summary:** No changes made. +- If nothing meaningful was accomplished (no code changes, no files modified, no research completed, just greetings or introductions), respond with ONLY the text: NOTHING_TO_REPORT +- Use NOTHING_TO_REPORT when the conversation was just a greeting, introduction, or there genuinely was no work to summarize. diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 40257a32..780d0178 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -4888,6 +4888,29 @@ You are taking over this conversation. Based on the context above, provide a bri // Parse the synopsis response const parsed = parseSynopsis(result.response); + // Check if AI indicated nothing meaningful to report + if (parsed.nothingToReport) { + // Update the pending log to indicate nothing to report + setSessions(prev => prev.map(s => { + if (s.id !== activeSession.id) return s; + return { + ...s, + aiTabs: s.aiTabs.map(tab => { + if (tab.id !== activeTab.id) return tab; + return { + ...tab, + logs: tab.logs.map(log => + log.id === pendingLog.id + ? { ...log, text: 'Nothing to report - no history entry created.' } + : log + ), + }; + }), + }; + })); + return; + } + // Get group info for the history entry const group = groups.find(g => g.id === activeSession.groupId); const groupName = group?.name || 'Ungrouped'; diff --git a/src/renderer/components/DeleteAgentConfirmModal.tsx b/src/renderer/components/DeleteAgentConfirmModal.tsx index 536405af..f0c08a52 100644 --- a/src/renderer/components/DeleteAgentConfirmModal.tsx +++ b/src/renderer/components/DeleteAgentConfirmModal.tsx @@ -72,7 +72,7 @@ export function DeleteAgentConfirmModal({ onKeyDown={(e) => handleKeyDown(e, handleConfirm)} className="px-4 py-2 rounded transition-colors outline-none focus:ring-2 focus:ring-offset-1" style={{ - backgroundColor: theme.colors.error, + backgroundColor: `${theme.colors.error}99`, color: '#ffffff', }} > diff --git a/src/renderer/components/FileExplorerPanel.tsx b/src/renderer/components/FileExplorerPanel.tsx index aa456f17..2ffa8eee 100644 --- a/src/renderer/components/FileExplorerPanel.tsx +++ b/src/renderer/components/FileExplorerPanel.tsx @@ -462,16 +462,18 @@ function FileExplorerPanelInner(props: FileExplorerPanelProps) { }, [onAutoRefreshChange]); // Context menu handlers - const handleContextMenu = useCallback((e: React.MouseEvent, node: FileNode, path: string) => { + const handleContextMenu = useCallback((e: React.MouseEvent, node: FileNode, path: string, globalIndex: number) => { e.preventDefault(); e.stopPropagation(); + // Update selection to the right-clicked item so user sees which item the menu affects + setSelectedFileIndex(globalIndex); setContextMenu({ x: e.clientX, y: e.clientY, node, path }); - }, []); + }, [setSelectedFileIndex]); const handleFocusInGraph = useCallback(() => { if (contextMenu && onFocusFileInGraph) { @@ -819,7 +821,7 @@ function FileExplorerPanelInner(props: FileExplorerPanelProps) { handleFileClick(node, fullPath, session); } }} - onContextMenu={(e) => handleContextMenu(e, node, fullPath)} + onContextMenu={(e) => handleContextMenu(e, node, fullPath, globalIndex)} > {indentGuides} {isFolder && ( diff --git a/src/renderer/components/LogViewer.tsx b/src/renderer/components/LogViewer.tsx index 765a4335..c47fc58a 100644 --- a/src/renderer/components/LogViewer.tsx +++ b/src/renderer/components/LogViewer.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; -import { Search, X, Trash2, Download, ChevronRight, ChevronDown, ChevronsDownUp, ChevronsUpDown } from 'lucide-react'; +import { Search, X, Trash2, Download, ChevronRight, ChevronDown, ChevronsDownUp, ChevronsUpDown, Pencil } from 'lucide-react'; import type { Theme } from '../types'; import { useLayerStack } from '../contexts/LayerStackContext'; import { MODAL_PRIORITIES } from '../constants/modalPriorities'; @@ -567,7 +567,8 @@ export function LogViewer({ theme, onClose, logLevel = 'info', savedSelectedLeve {new Date(log.timestamp).toLocaleTimeString()} - {log.context && ( + {/* Context pill - show for non-toast/autorun entries */} + {log.level !== 'toast' && log.level !== 'autorun' && log.context && ( )} + {/* Agent name pill for toast entries (from data.project) */} + {(() => { + if (log.level !== 'toast') return null; + const data = log.data as { project?: string } | undefined; + const project = data?.project; + if (!project) return null; + return ( + + + {project} + + ); + })()} + {/* Agent name pill for autorun entries (from context) */} + {log.level === 'autorun' && log.context && ( + + + {log.context} + + )}
{log.message} diff --git a/src/renderer/hooks/git/useFileTreeManagement.ts b/src/renderer/hooks/git/useFileTreeManagement.ts index 2dc22856..1de99933 100644 --- a/src/renderer/hooks/git/useFileTreeManagement.ts +++ b/src/renderer/hooks/git/useFileTreeManagement.ts @@ -233,8 +233,10 @@ export function useFileTreeManagement( const session = sessions.find(s => s.id === activeSessionId); if (!session) return; - // Only load if file tree is empty and not already loading - if ((!session.fileTree || session.fileTree.length === 0) && !session.fileTreeLoading) { + // Only load if file tree is empty, not already loading, and hasn't been loaded yet + // fileTreeStats is set after successful load, so we use it to detect "loaded but empty" + const hasLoadedOnce = session.fileTreeStats !== undefined || session.fileTreeError !== undefined; + if ((!session.fileTree || session.fileTree.length === 0) && !session.fileTreeLoading && !hasLoadedOnce) { // Check if we're in a retry backoff period if (session.fileTreeRetryAt && Date.now() < session.fileTreeRetryAt) { // Schedule retry when backoff expires (if not already scheduled) diff --git a/src/shared/synopsis.ts b/src/shared/synopsis.ts index 1b90bfcd..7d5860fe 100644 --- a/src/shared/synopsis.ts +++ b/src/shared/synopsis.ts @@ -4,13 +4,22 @@ * * Functions: * - parseSynopsis: Parse AI-generated synopsis responses into structured format + * - isNothingToReport: Check if response indicates no meaningful work was done */ import { stripAnsiCodes } from './stringUtils'; +/** + * Sentinel token that AI agents should return when there's nothing meaningful to report. + * When detected, callers should skip creating a history entry. + */ +export const NOTHING_TO_REPORT = 'NOTHING_TO_REPORT'; + export interface ParsedSynopsis { shortSummary: string; fullSynopsis: string; + /** True if the AI indicated there was nothing meaningful to report */ + nothingToReport: boolean; } /** @@ -29,6 +38,22 @@ function isTemplatePlaceholder(text: string): boolean { return placeholderPatterns.some(pattern => pattern.test(text.trim())); } +/** + * Check if a response indicates nothing meaningful to report. + * Looks for the NOTHING_TO_REPORT sentinel token anywhere in the response. + * + * @param response - Raw AI response string + * @returns True if the response contains NOTHING_TO_REPORT + */ +export function isNothingToReport(response: string): boolean { + const clean = stripAnsiCodes(response) + .replace(/─+/g, '') + .replace(/[│┌┐└┘├┤┬┴┼]/g, '') + .trim(); + + return clean.includes(NOTHING_TO_REPORT); +} + /** * Parse a synopsis response into short summary and full synopsis. * @@ -40,8 +65,11 @@ function isTemplatePlaceholder(text: string): boolean { * Filters out template placeholders that models sometimes output literally * (especially common with thinking/reasoning models). * + * If the response contains NOTHING_TO_REPORT, returns nothingToReport: true + * and callers should skip creating a history entry. + * * @param response - Raw AI response string (may contain ANSI codes, box drawing chars) - * @returns Parsed synopsis with shortSummary and fullSynopsis + * @returns Parsed synopsis with shortSummary, fullSynopsis, and nothingToReport flag */ export function parseSynopsis(response: string): ParsedSynopsis { // Clean up ANSI codes and box drawing characters @@ -50,6 +78,15 @@ export function parseSynopsis(response: string): ParsedSynopsis { .replace(/[│┌┐└┘├┤┬┴┼]/g, '') .trim(); + // Check for the sentinel token first + if (clean.includes(NOTHING_TO_REPORT)) { + return { + shortSummary: '', + fullSynopsis: '', + nothingToReport: true, + }; + } + // Try to extract Summary and Details sections const summaryMatch = clean.match(/\*\*Summary:\*\*\s*(.+?)(?=\*\*Details:\*\*|$)/is); const detailsMatch = clean.match(/\*\*Details:\*\*\s*(.+?)$/is); @@ -82,5 +119,5 @@ export function parseSynopsis(response: string): ParsedSynopsis { // Full synopsis includes both parts const fullSynopsis = details ? `${shortSummary}\n\n${details}` : shortSummary; - return { shortSummary, fullSynopsis }; + return { shortSummary, fullSynopsis, nothingToReport: false }; }