feat: add missing analysis sections to session report

Add 7 missing sections and fix 4 incomplete ones that the Python
analyzer generates but the TypeScript port was missing:

New sections: skillsInvoked, bashCommands, lifecycleTasks,
userQuestions, outOfScopeFindings, agentTree, subagentsList

Fixed: compaction (was just count, now has summaryCount + note),
serviceTiers removed (not available in parsed types),
compactionCount replaced with compaction object

Adds InsightsSection UI component and 8 new tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Holstein 2026-02-21 17:10:20 -05:00
parent 3f479dcf07
commit ab1ad071fe
5 changed files with 609 additions and 7 deletions

View file

@ -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}
/>
<InsightsSection
skills={report.skillsInvoked}
bash={report.bashCommands}
lifecycleTasks={report.lifecycleTasks}
userQuestions={report.userQuestions}
outOfScope={report.outOfScopeFindings}
agentTree={report.agentTree}
subagentsList={report.subagentsList}
/>
</div>
</div>
);

View file

@ -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 (
<ReportSection title="Session Insights" icon={Lightbulb}>
{/* Skills invoked */}
{skills.length > 0 && (
<div className="mb-4">
<div className="mb-2 text-xs font-medium text-text-muted">
Skills Invoked ({skills.length})
</div>
<div className="flex flex-col gap-1">
{skills.map((s, idx) => (
<div key={idx} className="flex items-center gap-2 px-2 py-0.5 text-xs">
<span className="font-mono text-text">{s.skill}</span>
{s.argsPreview && <span className="truncate text-text-muted">{s.argsPreview}</span>}
</div>
))}
</div>
</div>
)}
{/* Bash commands */}
<div className="mb-4">
<div className="mb-2 text-xs font-medium text-text-muted">Bash Commands</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<div>
<div className="text-xs text-text-muted">Total</div>
<div className="text-sm font-medium text-text">{bash.total}</div>
</div>
<div>
<div className="text-xs text-text-muted">Unique</div>
<div className="text-sm font-medium text-text">{bash.unique}</div>
</div>
<div>
<div className="text-xs text-text-muted">Repeated</div>
<div className="text-sm font-medium text-text">{Object.keys(bash.repeated).length}</div>
</div>
</div>
{Object.keys(bash.repeated).length > 0 && (
<div className="mt-2 flex flex-col gap-1">
{Object.entries(bash.repeated)
.slice(0, 10)
.map(([cmd, count], idx) => (
<div key={idx} className="flex items-center gap-2 px-2 py-0.5 text-xs">
<span className="font-mono text-text-muted">{count}x</span>
<span className="truncate font-mono text-text-secondary">{cmd}</span>
</div>
))}
</div>
)}
</div>
{/* Task tool subagent list */}
{subagentsList.length > 0 && (
<div className="mb-4">
<div className="mb-2 text-xs font-medium text-text-muted">
Task Dispatches ({subagentsList.length})
</div>
<div className="flex flex-col gap-1">
{subagentsList.map((s, idx) => (
<div key={idx} className="flex items-center gap-2 px-2 py-0.5 text-xs">
<span className="rounded bg-surface-raised px-1.5 py-0.5 text-text-muted">
{s.subagentType}
</span>
<span className="truncate text-text">{s.description}</span>
{s.runInBackground && <span className="text-text-muted">(background)</span>}
</div>
))}
</div>
</div>
)}
{/* Lifecycle tasks */}
{lifecycleTasks.length > 0 && (
<div className="mb-4">
<div className="mb-2 text-xs font-medium text-text-muted">
Tasks Created ({lifecycleTasks.length})
</div>
<div className="flex flex-col gap-1">
{lifecycleTasks.map((task, idx) => (
<div key={idx} className="px-2 py-0.5 text-xs text-text-secondary">
{task}
</div>
))}
</div>
</div>
)}
{/* User questions */}
{userQuestions.length > 0 && (
<div className="mb-4">
<div className="mb-2 text-xs font-medium text-text-muted">
Questions Asked ({userQuestions.length})
</div>
<div className="flex flex-col gap-2">
{userQuestions.map((q, idx) => (
<div key={idx} className="rounded-md bg-surface-raised px-3 py-2">
<div className="text-xs text-text">{q.question}</div>
{q.options.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{q.options.map((opt, optIdx) => (
<span
key={optIdx}
className="rounded px-1.5 py-0.5 text-xs text-text-muted"
style={{ backgroundColor: 'var(--color-surface-overlay)' }}
>
{opt}
</span>
))}
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Agent tree */}
{agentTree.agentCount > 0 && (
<div className="mb-4">
<div className="mb-2 text-xs font-medium text-text-muted">
Agent Tree ({agentTree.agentCount} agent{agentTree.agentCount !== 1 ? 's' : ''})
{agentTree.hasTeamMode && (
<span className="ml-2 rounded px-1.5 py-0.5 text-xs" style={{ color: '#60a5fa' }}>
Team Mode
</span>
)}
</div>
{agentTree.teamNames.length > 0 && (
<div className="mb-2 text-xs text-text-muted">
Teams: {agentTree.teamNames.join(', ')}
</div>
)}
<div className="flex flex-col gap-1">
{agentTree.agents.map((agent, idx) => (
<div key={idx} className="flex items-center gap-2 px-2 py-0.5 text-xs">
<span className="rounded bg-surface-raised px-1.5 py-0.5 text-text-muted">
{agent.agentType}
</span>
<span className="truncate font-mono text-text-secondary">
{agent.agentId.slice(0, 12)}...
</span>
</div>
))}
</div>
</div>
)}
{/* Out-of-scope findings */}
{outOfScope.length > 0 && (
<div>
<div className="mb-2 text-xs font-medium text-text-muted">
Out-of-Scope Findings ({outOfScope.length})
</div>
<div className="flex flex-col gap-2">
{outOfScope.map((f, idx) => (
<div key={idx} className="rounded-md bg-surface-raised px-3 py-2">
<span
className="mr-2 rounded px-1.5 py-0.5 text-xs"
style={{ backgroundColor: '#fbbf2420', color: '#fbbf24' }}
>
{f.keyword}
</span>
<span className="text-xs text-text-secondary">{f.snippet}</span>
</div>
))}
</div>
</div>
)}
</ReportSection>
);
};

View file

@ -273,6 +273,60 @@ export interface ReportFileReadRedundancy {
redundantFiles: Record<string, number>;
}
// =============================================================================
// Missing Sections (ported from Python analyzer)
// =============================================================================
export interface SkillInvocation {
skill: string;
argsPreview: string;
}
export interface ReportBashCommands {
total: number;
unique: number;
repeated: Record<string, number>;
}
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<string, number>;
serviceTiers: Record<string, number>;
fileReadRedundancy: ReportFileReadRedundancy;
compactionCount: number;
compaction: ReportCompaction;
gitBranches: string[];
skillsInvoked: SkillInvocation[];
bashCommands: ReportBashCommands;
lifecycleTasks: string[];
userQuestions: UserQuestion[];
outOfScopeFindings: OutOfScopeFindings[];
agentTree: ReportAgentTree;
}

View file

@ -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<string, unknown>;
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<string, number>())
.entries(),
]
.filter(([, count]) => count > 1)
.sort((a, b) => b[1] - a[1])
),
},
lifecycleTasks,
userQuestions,
outOfScopeFindings,
agentTree: (() => {
const uniqueAgents = new Map<string, AgentTreeNode>();
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;

View file

@ -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');
});
});
});