diff --git a/src/renderer/components/report/SessionReportTab.tsx b/src/renderer/components/report/SessionReportTab.tsx index 58958caa..b72088cc 100644 --- a/src/renderer/components/report/SessionReportTab.tsx +++ b/src/renderer/components/report/SessionReportTab.tsx @@ -7,6 +7,7 @@ import { CostSection } from './sections/CostSection'; import { ErrorSection } from './sections/ErrorSection'; import { FrictionSection } from './sections/FrictionSection'; import { GitSection } from './sections/GitSection'; +import { InsightsSection } from './sections/InsightsSection'; import { OverviewSection } from './sections/OverviewSection'; import { QualitySection } from './sections/QualitySection'; import { SubagentSection } from './sections/SubagentSection'; @@ -63,6 +64,15 @@ export const SessionReportTab = ({ tab }: SessionReportTabProps) => { startup={report.startupOverhead} testProgression={report.testProgression} /> + ); diff --git a/src/renderer/components/report/sections/InsightsSection.tsx b/src/renderer/components/report/sections/InsightsSection.tsx new file mode 100644 index 00000000..00d9c55a --- /dev/null +++ b/src/renderer/components/report/sections/InsightsSection.tsx @@ -0,0 +1,202 @@ +import { Lightbulb } from 'lucide-react'; + +import { ReportSection } from '../ReportSection'; + +import type { + OutOfScopeFindings, + ReportAgentTree, + ReportBashCommands, + SkillInvocation, + SubagentBasicEntry, + UserQuestion, +} from '@renderer/types/sessionReport'; + +interface InsightsSectionProps { + skills: SkillInvocation[]; + bash: ReportBashCommands; + lifecycleTasks: string[]; + userQuestions: UserQuestion[]; + outOfScope: OutOfScopeFindings[]; + agentTree: ReportAgentTree; + subagentsList: SubagentBasicEntry[]; +} + +export const InsightsSection = ({ + skills, + bash, + lifecycleTasks, + userQuestions, + outOfScope, + agentTree, + subagentsList, +}: InsightsSectionProps) => { + return ( + + {/* Skills invoked */} + {skills.length > 0 && ( +
+
+ Skills Invoked ({skills.length}) +
+
+ {skills.map((s, idx) => ( +
+ {s.skill} + {s.argsPreview && {s.argsPreview}} +
+ ))} +
+
+ )} + + {/* Bash commands */} +
+
Bash Commands
+
+
+
Total
+
{bash.total}
+
+
+
Unique
+
{bash.unique}
+
+
+
Repeated
+
{Object.keys(bash.repeated).length}
+
+
+ {Object.keys(bash.repeated).length > 0 && ( +
+ {Object.entries(bash.repeated) + .slice(0, 10) + .map(([cmd, count], idx) => ( +
+ {count}x + {cmd} +
+ ))} +
+ )} +
+ + {/* Task tool subagent list */} + {subagentsList.length > 0 && ( +
+
+ Task Dispatches ({subagentsList.length}) +
+
+ {subagentsList.map((s, idx) => ( +
+ + {s.subagentType} + + {s.description} + {s.runInBackground && (background)} +
+ ))} +
+
+ )} + + {/* Lifecycle tasks */} + {lifecycleTasks.length > 0 && ( +
+
+ Tasks Created ({lifecycleTasks.length}) +
+
+ {lifecycleTasks.map((task, idx) => ( +
+ {task} +
+ ))} +
+
+ )} + + {/* User questions */} + {userQuestions.length > 0 && ( +
+
+ Questions Asked ({userQuestions.length}) +
+
+ {userQuestions.map((q, idx) => ( +
+
{q.question}
+ {q.options.length > 0 && ( +
+ {q.options.map((opt, optIdx) => ( + + {opt} + + ))} +
+ )} +
+ ))} +
+
+ )} + + {/* Agent tree */} + {agentTree.agentCount > 0 && ( +
+
+ Agent Tree ({agentTree.agentCount} agent{agentTree.agentCount !== 1 ? 's' : ''}) + {agentTree.hasTeamMode && ( + + Team Mode + + )} +
+ {agentTree.teamNames.length > 0 && ( +
+ Teams: {agentTree.teamNames.join(', ')} +
+ )} +
+ {agentTree.agents.map((agent, idx) => ( +
+ + {agent.agentType} + + + {agent.agentId.slice(0, 12)}... + +
+ ))} +
+
+ )} + + {/* Out-of-scope findings */} + {outOfScope.length > 0 && ( +
+
+ Out-of-Scope Findings ({outOfScope.length}) +
+
+ {outOfScope.map((f, idx) => ( +
+ + {f.keyword} + + {f.snippet} +
+ ))} +
+
+ )} +
+ ); +}; diff --git a/src/renderer/types/sessionReport.ts b/src/renderer/types/sessionReport.ts index b43b3894..c899e98c 100644 --- a/src/renderer/types/sessionReport.ts +++ b/src/renderer/types/sessionReport.ts @@ -273,6 +273,60 @@ export interface ReportFileReadRedundancy { redundantFiles: Record; } +// ============================================================================= +// Missing Sections (ported from Python analyzer) +// ============================================================================= + +export interface SkillInvocation { + skill: string; + argsPreview: string; +} + +export interface ReportBashCommands { + total: number; + unique: number; + repeated: Record; +} + +export interface UserQuestion { + question: string; + options: string[]; +} + +export interface OutOfScopeFindings { + keyword: string; + messageIndex: number; + snippet: string; +} + +export interface AgentTreeNode { + agentId: string; + agentType: string; + teamName: string; + parentToolUseId: string; + messageIndex: number; +} + +export interface ReportAgentTree { + agentCount: number; + agents: AgentTreeNode[]; + hasTeamMode: boolean; + teamNames: string[]; +} + +export interface ReportCompaction { + count: number; + compactSummaryCount: number; + note: string; +} + +export interface SubagentBasicEntry { + description: string; + subagentType: string; + model: string; + runInBackground: boolean; +} + // ============================================================================= // Combined Report // ============================================================================= @@ -284,6 +338,7 @@ export interface SessionReport { cacheEconomics: ReportCacheEconomics; toolUsage: ReportToolUsage; subagentMetrics: ReportSubagentMetrics; + subagentsList: SubagentBasicEntry[]; errors: ReportErrors; gitActivity: ReportGitActivity; frictionSignals: ReportFrictionSignals; @@ -299,8 +354,13 @@ export interface SessionReport { thinkingBlocks: ReportThinkingBlocks; keyEvents: KeyEvent[]; messageTypes: Record; - serviceTiers: Record; fileReadRedundancy: ReportFileReadRedundancy; - compactionCount: number; + compaction: ReportCompaction; gitBranches: string[]; + skillsInvoked: SkillInvocation[]; + bashCommands: ReportBashCommands; + lifecycleTasks: string[]; + userQuestions: UserQuestion[]; + outOfScopeFindings: OutOfScopeFindings[]; + agentTree: ReportAgentTree; } diff --git a/src/renderer/utils/sessionAnalyzer.ts b/src/renderer/utils/sessionAnalyzer.ts index 97478e9e..70c00cc0 100644 --- a/src/renderer/utils/sessionAnalyzer.ts +++ b/src/renderer/utils/sessionAnalyzer.ts @@ -9,6 +9,7 @@ */ import type { + AgentTreeNode, FrictionCorrection, GitCommit, IdleGap, @@ -16,11 +17,15 @@ import type { ModelPricing, ModelSwitch, ModelTokenStats, + OutOfScopeFindings, SessionReport, + SkillInvocation, + SubagentBasicEntry, SubagentEntry, TestSnapshot, ThinkingBlockAnalysis, ToolError, + UserQuestion, } from '@renderer/types/sessionReport'; import type { ContentBlock, @@ -367,6 +372,38 @@ export function analyzeSession(detail: SessionDetail): SessionReport { let firstUserMessageLength = 0; let firstUserSeen = false; + // Skills invoked + const skillsInvoked: SkillInvocation[] = []; + + // Bash commands + const bashCmds: string[] = []; + + // Subagents list (backward compat) + const subagentsList: SubagentBasicEntry[] = []; + + // Lifecycle tasks + const lifecycleTasks: string[] = []; + + // User questions + const userQuestions: UserQuestion[] = []; + + // Out-of-scope findings + const OUT_OF_SCOPE_KEYWORDS = [ + 'pre-existing', + 'out of scope', + 'tech debt', + 'anti-pattern', + 'existed before', + ]; + const outOfScopeFindings: OutOfScopeFindings[] = []; + + // Agent tree metadata + const agentTreeNodes: AgentTreeNode[] = []; + + // Compact summary count + + let compactSummaryCount = 0; + // =================================================================== // SINGLE PASS // =================================================================== @@ -435,7 +472,21 @@ export function analyzeSession(detail: SessionDetail): SessionReport { // --- Git branches --- if (m.gitBranch) branches.add(m.gitBranch); - // --- Compact summaries (counted but not accumulated separately) --- + // --- Compact summaries --- + if (m.isCompactSummary) compactSummaryCount++; + + // --- Agent tree metadata --- + if (m.agentId) { + // agentType/teamName/parentToolUseId may exist on raw data but not typed in ParsedMessage + const raw = m as unknown as Record; + agentTreeNodes.push({ + agentId: m.agentId, + agentType: (raw.agentType as string) ?? 'unknown', + teamName: (raw.teamName as string) ?? '', + parentToolUseId: (raw.parentToolUseId as string) ?? '', + messageIndex: i, + }); + } // --- Thinking blocks (with content analysis) --- if (Array.isArray(m.content)) { @@ -507,6 +558,8 @@ export function analyzeSession(detail: SessionDetail): SessionReport { // Bash commands if (toolName === 'Bash') { const cmd = typeof inp.command === 'string' ? inp.command : ''; + const cmdTrunc = cmd.slice(0, 200); + bashCmds.push(cmdTrunc); // Thrashing: bash prefix groups const prefix = cmd.slice(0, 40); bashPrefixGroups.set(prefix, (bashPrefixGroups.get(prefix) ?? 0) + 1); @@ -530,6 +583,42 @@ export function analyzeSession(detail: SessionDetail): SessionReport { } } + // Skills + if (toolName === 'Skill') { + skillsInvoked.push({ + skill: (inp.skill as string) ?? 'unknown', + argsPreview: String(inp.args ?? '').slice(0, 120), + }); + } + + // Task (subagent list) + if (toolName === 'Task') { + subagentsList.push({ + description: (inp.description as string) ?? 'unknown', + subagentType: (inp.subagent_type as string) ?? 'unknown', + model: (inp.model as string) ?? 'default (inherits parent)', + runInBackground: (inp.run_in_background as boolean) ?? false, + }); + } + + // TaskCreate + if (toolName === 'TaskCreate') { + lifecycleTasks.push((inp.subject as string) ?? 'unknown'); + } + + // AskUserQuestion + if (toolName === 'AskUserQuestion') { + const questions = inp.questions as { question?: string; options?: { label?: string }[] }[]; + if (Array.isArray(questions)) { + for (const q of questions) { + userQuestions.push({ + question: q.question ?? '', + options: Array.isArray(q.options) ? q.options.map((o) => o.label ?? '') : [], + }); + } + } + } + // File reads if (toolName === 'Read') { const filePath = (inp.file_path as string) ?? ''; @@ -619,6 +708,25 @@ export function analyzeSession(detail: SessionDetail): SessionReport { } } + // --- Out-of-scope findings (assistant messages) --- + if (msgType === 'assistant') { + const contentText = extractTextContent(m); + const contentLower = contentText.toLowerCase(); + for (const kw of OUT_OF_SCOPE_KEYWORDS) { + const kwIdx = contentLower.indexOf(kw.toLowerCase()); + if (kwIdx >= 0) { + const start = Math.max(0, kwIdx - 80); + const end = Math.min(contentText.length, kwIdx + 300); + outOfScopeFindings.push({ + keyword: kw, + messageIndex: i, + snippet: contentText.slice(start, end).replace(/\n/g, ' ').trim(), + }); + break; + } + } + } + // --- Tool results --- for (const tr of m.toolResults) { const toolUseId = tr.toolUseId ?? ''; @@ -1040,6 +1148,8 @@ export function analyzeSession(detail: SessionDetail): SessionReport { subagentMetrics: saFromProcesses, + subagentsList, + errors: { errors, permissionDenials: { @@ -1140,8 +1250,6 @@ export function analyzeSession(detail: SessionDetail): SessionReport { messageTypes, - serviceTiers: {}, - fileReadRedundancy: { totalReads, uniqueFiles, @@ -1149,9 +1257,51 @@ export function analyzeSession(detail: SessionDetail): SessionReport { redundantFiles, }, - compactionCount: session.compactionCount ?? 0, + compaction: { + count: session.compactionCount ?? 0, + compactSummaryCount, + note: + (session.compactionCount ?? 0) > 0 + ? 'Session underwent compaction, which may have caused loss of earlier context. Check for repeated work after compaction events.' + : 'No compaction occurred — session stayed within context limits.', + }, gitBranches: [...branches], + + skillsInvoked, + + bashCommands: { + total: bashCmds.length, + unique: new Set(bashCmds).size, + repeated: Object.fromEntries( + [ + ...bashCmds + .reduce((acc, cmd) => acc.set(cmd, (acc.get(cmd) ?? 0) + 1), new Map()) + .entries(), + ] + .filter(([, count]) => count > 1) + .sort((a, b) => b[1] - a[1]) + ), + }, + + lifecycleTasks, + + userQuestions, + + outOfScopeFindings, + + agentTree: (() => { + const uniqueAgents = new Map(); + for (const node of agentTreeNodes) { + if (!uniqueAgents.has(node.agentId)) uniqueAgents.set(node.agentId, node); + } + return { + agentCount: uniqueAgents.size, + agents: [...uniqueAgents.values()], + hasTeamMode: agentTreeNodes.some((n) => n.teamName), + teamNames: [...new Set(agentTreeNodes.filter((n) => n.teamName).map((n) => n.teamName))], + }; + })(), }; return report; diff --git a/test/renderer/utils/sessionAnalyzer.test.ts b/test/renderer/utils/sessionAnalyzer.test.ts index 542ab5c6..a75cf256 100644 --- a/test/renderer/utils/sessionAnalyzer.test.ts +++ b/test/renderer/utils/sessionAnalyzer.test.ts @@ -110,8 +110,18 @@ describe('analyzeSession', () => { expect(report.tokenDensityTimeline.quartiles).toHaveLength(4); expect(report.tokenDensityTimeline.quartiles.every((q) => q.avgTokens === 0)).toBe(true); - expect(report.compactionCount).toBe(0); + expect(report.compaction.count).toBe(0); + expect(report.compaction.compactSummaryCount).toBe(0); expect(report.gitBranches).toEqual([]); + + // New sections + expect(report.skillsInvoked).toEqual([]); + expect(report.bashCommands.total).toBe(0); + expect(report.lifecycleTasks).toEqual([]); + expect(report.userQuestions).toEqual([]); + expect(report.outOfScopeFindings).toEqual([]); + expect(report.agentTree.agentCount).toBe(0); + expect(report.subagentsList).toEqual([]); }); }); @@ -1001,4 +1011,174 @@ describe('analyzeSession', () => { expect(report.thrashingSignals.editReworkFiles[0].editIndices).toHaveLength(3); }); }); + + describe('skills invoked', () => { + it('tracks Skill tool calls', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + toolCalls: [ + { + id: 'tc-1', + name: 'Skill', + input: { skill: 'brainstorming', args: '--verbose' }, + isTask: false, + }, + { id: 'tc-2', name: 'Skill', input: { skill: 'writing-plans' }, isTask: false }, + ], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.skillsInvoked).toHaveLength(2); + expect(report.skillsInvoked[0].skill).toBe('brainstorming'); + expect(report.skillsInvoked[0].argsPreview).toBe('--verbose'); + expect(report.skillsInvoked[1].skill).toBe('writing-plans'); + }); + }); + + describe('bash commands', () => { + it('tracks total, unique, and repeated commands', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + toolCalls: [ + { id: 'tc-1', name: 'Bash', input: { command: 'pnpm test' }, isTask: false }, + { id: 'tc-2', name: 'Bash', input: { command: 'pnpm test' }, isTask: false }, + { id: 'tc-3', name: 'Bash', input: { command: 'git status' }, isTask: false }, + ], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.bashCommands.total).toBe(3); + expect(report.bashCommands.unique).toBe(2); + expect(report.bashCommands.repeated['pnpm test']).toBe(2); + }); + }); + + describe('subagents list', () => { + it('tracks Task tool dispatches', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + toolCalls: [ + { + id: 'tc-1', + name: 'Task', + input: { + description: 'explore auth', + subagent_type: 'Explore', + run_in_background: true, + }, + isTask: true, + }, + ], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.subagentsList).toHaveLength(1); + expect(report.subagentsList[0].description).toBe('explore auth'); + expect(report.subagentsList[0].subagentType).toBe('Explore'); + expect(report.subagentsList[0].runInBackground).toBe(true); + }); + }); + + describe('lifecycle tasks', () => { + it('tracks TaskCreate subjects', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + toolCalls: [ + { id: 'tc-1', name: 'TaskCreate', input: { subject: 'Add login page' }, isTask: false }, + { id: 'tc-2', name: 'TaskCreate', input: { subject: 'Write tests' }, isTask: false }, + ], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.lifecycleTasks).toEqual(['Add login page', 'Write tests']); + }); + }); + + describe('user questions', () => { + it('tracks AskUserQuestion calls', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + toolCalls: [ + { + id: 'tc-1', + name: 'AskUserQuestion', + input: { + questions: [ + { + question: 'Which auth method?', + options: [{ label: 'JWT' }, { label: 'OAuth' }], + }, + ], + }, + isTask: false, + }, + ], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.userQuestions).toHaveLength(1); + expect(report.userQuestions[0].question).toBe('Which auth method?'); + expect(report.userQuestions[0].options).toEqual(['JWT', 'OAuth']); + }); + }); + + describe('out-of-scope findings', () => { + it('detects pre-existing and tech debt mentions', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + content: 'This is a pre-existing issue that was there before our changes.', + }), + createMockMessage({ + type: 'assistant', + content: 'I noticed some tech debt in the authentication module.', + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.outOfScopeFindings).toHaveLength(2); + expect(report.outOfScopeFindings[0].keyword).toBe('pre-existing'); + expect(report.outOfScopeFindings[1].keyword).toBe('tech debt'); + }); + }); + + describe('compaction', () => { + it('tracks compaction count and summary messages', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ type: 'assistant', isCompactSummary: true }), + createMockMessage({ type: 'assistant', isCompactSummary: true }), + ]; + + const session = createMockSession(); + session.compactionCount = 2; + const report = analyzeSession(createMockDetail({ messages, session })); + + expect(report.compaction.count).toBe(2); + expect(report.compaction.compactSummaryCount).toBe(2); + expect(report.compaction.note).toContain('underwent compaction'); + }); + + it('reports no compaction', () => { + const report = analyzeSession(createMockDetail({})); + + expect(report.compaction.count).toBe(0); + expect(report.compaction.note).toContain('No compaction'); + }); + }); });