Merge pull request #7 from 777genius/sync/upstream-main

merge: sync with upstream/main — session reports, cost calculation, Linux title bar
This commit is contained in:
Илия 2026-02-24 21:17:19 +02:00 committed by GitHub
commit ebc61844e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
68 changed files with 11945 additions and 91 deletions

View file

@ -7,6 +7,9 @@ The format is based on Keep a Changelog and this project follows Semantic Versio
## [Unreleased]
### Added
- `general.autoExpandAIGroups` setting: automatically expands all AI response groups when opening a transcript or when new AI responses arrive in a live session. Defaults to off. Stored in the on-disk config so it persists across restarts.
- Strict IPC input validation guards for project/session/subagent/search limits.
- `get-waterfall-data` IPC endpoint implementation.
- Cross-platform path normalization in renderer path resolvers.

View file

@ -23,10 +23,27 @@ pnpm build
```
## Pull Request Guidelines
- Keep changes focused and small.
- Keep changes focused and small — one purpose per PR.
- Add/adjust tests for behavior changes.
- Update docs when changing public behavior or setup.
- Use clear PR titles and include a short validation checklist.
- **Large changes (new features, new dependencies, large data additions) must have a discussion in an Issue first.** Do not open a large PR without prior agreement on the approach.
- Avoid committing large hardcoded data blobs. If data can be fetched at runtime or generated at build time, prefer that approach.
## AI-Assisted Contributions
AI coding tools are welcome, but **you are responsible for what you submit**:
- **Review before submitting.** Read every line of AI-generated code and understand what it does. Do not submit raw, unreviewed AI output.
- **Do not commit AI workflow artifacts.** Planning documents, session logs, step-by-step plans, or other outputs from AI tools (e.g. `docs/plans/`, `.speckit/`, etc.) do not belong in the repository.
- **Test it yourself.** AI-generated code must be manually verified — run the app, confirm the feature works, check edge cases.
- **Keep it intentional.** Every line in your PR should exist for a reason you can explain. If you can't explain why a piece of code is there, remove it.
## What Does NOT Belong in the Repo
- Personal planning/workflow artifacts (AI session plans, task lists, etc.)
- Large static data that could be fetched at runtime
- Generated files that aren't part of the build output
- Experimental features without prior discussion
## Commit Style
- Prefer conventional commits (`feat:`, `fix:`, `chore:`, `docs:`).

View file

@ -20,6 +20,7 @@
"scripts": {
"dev": "electron-vite dev",
"dev:kill": "node bin/kill-dev.js",
"prebuild": "tsx scripts/fetch-pricing-data.ts",
"build": "electron-vite build",
"dist": "electron-builder --mac --win --linux",
"dist:mac": "electron-builder --mac --publish always",
@ -156,6 +157,12 @@
"asarUnpack": [
"out/renderer/**"
],
"extraResources": [
{
"from": "resources/pricing.json",
"to": "pricing.json"
}
],
"npmRebuild": false,
"extraMetadata": {
"main": "dist-electron/main/index.cjs"

4195
resources/pricing.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,113 @@
#!/usr/bin/env tsx
/**
* Fetch latest model pricing from LiteLLM and save to renderer assets.
* Filters to Claude models only to reduce bundle size.
* Runs automatically during prebuild.
*/
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const LITELLM_PRICING_URL =
'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json';
const OUTPUT_PATH = path.join(__dirname, '..', 'resources', 'pricing.json');
const FETCH_TIMEOUT = 10000; // 10 seconds
interface ModelPricing {
input_cost_per_token: number;
output_cost_per_token: number;
cache_creation_input_token_cost?: number;
cache_read_input_token_cost?: number;
input_cost_per_token_above_200k_tokens?: number;
output_cost_per_token_above_200k_tokens?: number;
cache_creation_input_token_cost_above_200k_tokens?: number;
cache_read_input_token_cost_above_200k_tokens?: number;
[key: string]: unknown;
}
function isValidModelPricing(entry: unknown): entry is ModelPricing {
return (
typeof entry === 'object' &&
entry !== null &&
'input_cost_per_token' in entry &&
'output_cost_per_token' in entry &&
typeof (entry as ModelPricing).input_cost_per_token === 'number' &&
typeof (entry as ModelPricing).output_cost_per_token === 'number'
);
}
function isClaudeModel(modelName: string): boolean {
const lower = modelName.toLowerCase();
return lower.includes('claude');
}
async function fetchPricingData(): Promise<Record<string, ModelPricing>> {
console.log('Fetching pricing data from LiteLLM...');
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
try {
const response = await fetch(LITELLM_PRICING_URL, { signal: controller.signal });
clearTimeout(timeout);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = (await response.json()) as Record<string, unknown>;
console.log(`Fetched pricing for ${Object.keys(data).length} models`);
// Filter to Claude models only and validate entries
const claudeModels: Record<string, ModelPricing> = {};
for (const [modelName, entry] of Object.entries(data)) {
if (isClaudeModel(modelName) && isValidModelPricing(entry)) {
claudeModels[modelName] = entry;
}
}
console.log(`Filtered to ${Object.keys(claudeModels).length} Claude models`);
return claudeModels;
} catch (error) {
clearTimeout(timeout);
if (error instanceof Error && error.name === 'AbortError') {
throw new Error('Fetch timeout after 10 seconds');
}
throw error;
}
}
async function main(): Promise<void> {
try {
console.log('Fetching pricing data for models...');
const pricing = await fetchPricingData();
// Ensure output directory exists
const outputDir = path.dirname(OUTPUT_PATH);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
// Write formatted JSON
fs.writeFileSync(OUTPUT_PATH, JSON.stringify(pricing, null, 2), 'utf-8');
// Calculate file size
const stats = fs.statSync(OUTPUT_PATH);
const sizeKB = (stats.size / 1024).toFixed(2);
console.log(`✓ Wrote pricing data to ${OUTPUT_PATH}`);
console.log(` Bundle size: ${sizeKB} KB`);
} catch (error) {
console.error('Failed to fetch pricing data:', error);
console.error('Build will continue with existing pricing.json if available');
// Don't fail the build - allow using existing pricing.json
process.exit(0);
}
}
main();

View file

@ -454,8 +454,8 @@ function syncTrafficLightPosition(win: BrowserWindow): void {
*/
function createWindow(): void {
const isMac = process.platform === 'darwin';
const isLinux = process.platform === 'linux';
const iconPath = isMac ? undefined : getWindowIconPath();
const useNativeTitleBar = !isMac && configManager.getConfig().general.useNativeTitleBar;
mainWindow = new BrowserWindow({
width: DEFAULT_WINDOW_WIDTH,
height: DEFAULT_WINDOW_HEIGHT,
@ -466,7 +466,7 @@ function createWindow(): void {
contextIsolation: true,
},
backgroundColor: '#1a1a1a',
...(isLinux ? {} : { titleBarStyle: 'hidden' as const }),
...(useNativeTitleBar ? {} : { titleBarStyle: 'hidden' as const }),
...(isMac && { trafficLightPosition: getTrafficLightPositionForZoom(1) }),
title: 'Claude Agent Teams UI',
});
@ -527,7 +527,17 @@ function createWindow(): void {
const ZOOM_OUT_KEYS = new Set(['-', '_']);
mainWindow.webContents.on('before-input-event', (event, input) => {
if (!mainWindow || mainWindow.isDestroyed()) return;
if (!input.meta || input.type !== 'keyDown') return;
if (input.type !== 'keyDown') return;
// Prevent Electron's default Ctrl+R / Cmd+R page reload so the renderer
// keyboard handler can use it as "Refresh Session" (fixes #58).
// Also prevent Ctrl+Shift+R / Cmd+Shift+R (hard reload).
if ((input.control || input.meta) && input.key.toLowerCase() === 'r') {
event.preventDefault();
return;
}
if (!input.meta) return;
const currentLevel = mainWindow.webContents.getZoomLevel();

View file

@ -204,6 +204,8 @@ function validateGeneralSection(data: unknown): ValidationSuccess<'general'> | V
'defaultTab',
'claudeRootPath',
'agentLanguage',
'autoExpandAIGroups',
'useNativeTitleBar',
];
const result: Partial<GeneralConfig> = {};
@ -274,6 +276,18 @@ function validateGeneralSection(data: unknown): ValidationSuccess<'general'> | V
}
result.agentLanguage = value.trim();
break;
case 'autoExpandAIGroups':
if (typeof value !== 'boolean') {
return { valid: false, error: `general.${key} must be a boolean` };
}
result.autoExpandAIGroups = value;
break;
case 'useNativeTitleBar':
if (typeof value !== 'boolean') {
return { valid: false, error: `general.${key} must be a boolean` };
}
result.useNativeTitleBar = value;
break;
default:
return { valid: false, error: `Unsupported general key: ${key}` };
}

View file

@ -5,9 +5,9 @@
*/
import { createLogger } from '@shared/utils/logger';
import { app, BrowserWindow, type IpcMain } from 'electron';
const WINDOW_IS_FULLSCREEN = 'window:isFullScreen';
import { BrowserWindow, type IpcMain } from 'electron';
const logger = createLogger('IPC:window');
@ -47,6 +47,11 @@ export function registerWindowHandlers(ipcMain: IpcMain): void {
return win != null && !win.isDestroyed() && win.isFullScreen();
});
ipcMain.handle('app:relaunch', () => {
app.relaunch();
app.exit(0);
});
logger.info('Window handlers registered');
}
@ -56,5 +61,6 @@ export function removeWindowHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler('window:close');
ipcMain.removeHandler('window:isMaximized');
ipcMain.removeHandler(WINDOW_IS_FULLSCREEN);
ipcMain.removeHandler('app:relaunch');
logger.info('Window handlers removed');
}

View file

@ -182,6 +182,8 @@ export interface GeneralConfig {
defaultTab: 'dashboard' | 'last-session';
claudeRootPath: string | null;
agentLanguage: string;
autoExpandAIGroups: boolean;
useNativeTitleBar: boolean;
}
export interface DisplayConfig {
@ -250,6 +252,8 @@ const DEFAULT_CONFIG: AppConfig = {
defaultTab: 'dashboard',
claudeRootPath: null,
agentLanguage: 'system',
autoExpandAIGroups: false,
useNativeTitleBar: false,
},
display: {
showTimestamps: true,

View file

@ -373,8 +373,12 @@ export class NotificationManager extends EventEmitter {
* Shows a native macOS notification for an error.
*/
private showNativeNotification(error: DetectedError): void {
// Check if Notification is supported
if (!Notification.isSupported()) {
// Guard against standalone/Docker mode where Electron's Notification API is unavailable
if (
typeof Notification === 'undefined' ||
typeof Notification.isSupported !== 'function' ||
!Notification.isSupported()
) {
logger.warn('Native notifications not supported');
return;
}

View file

@ -461,6 +461,7 @@ export const EMPTY_METRICS: SessionMetrics = {
cacheReadTokens: 0,
cacheCreationTokens: 0,
messageCount: 0,
costUsd: 0,
};
// =============================================================================

View file

@ -9,6 +9,7 @@
import { isCommandOutputContent, sanitizeDisplayContent } from '@shared/utils/contentSanitizer';
import { createLogger } from '@shared/utils/logger';
import { calculateMessageCost } from '@shared/utils/pricing';
import * as readline from 'readline';
import { LocalFileSystemProvider } from '../services/infrastructure/LocalFileSystemProvider';
@ -228,7 +229,6 @@ export function calculateMetrics(messages: ParsedMessage[]): SessionMetrics {
let outputTokens = 0;
let cacheReadTokens = 0;
let cacheCreationTokens = 0;
const costUsd = 0;
// Get timestamps for duration (loop instead of Math.min/max spread to avoid stack overflow on large sessions)
const timestamps = messages.map((m) => m.timestamp.getTime()).filter((t) => !isNaN(t));
@ -244,12 +244,30 @@ export function calculateMetrics(messages: ParsedMessage[]): SessionMetrics {
}
}
// Calculate cost per-message, then sum (tiered pricing applies per-API-call, not to aggregated totals)
let costUsd = 0;
for (const msg of messages) {
if (msg.usage) {
inputTokens += msg.usage.input_tokens ?? 0;
outputTokens += msg.usage.output_tokens ?? 0;
cacheReadTokens += msg.usage.cache_read_input_tokens ?? 0;
cacheCreationTokens += msg.usage.cache_creation_input_tokens ?? 0;
const msgInputTokens = msg.usage.input_tokens ?? 0;
const msgOutputTokens = msg.usage.output_tokens ?? 0;
const msgCacheReadTokens = msg.usage.cache_read_input_tokens ?? 0;
const msgCacheCreationTokens = msg.usage.cache_creation_input_tokens ?? 0;
inputTokens += msgInputTokens;
outputTokens += msgOutputTokens;
cacheReadTokens += msgCacheReadTokens;
cacheCreationTokens += msgCacheCreationTokens;
if (msg.model) {
costUsd += calculateMessageCost(
msg.model,
msgInputTokens,
msgOutputTokens,
msgCacheReadTokens,
msgCacheCreationTokens
);
}
}
}
@ -261,7 +279,7 @@ export function calculateMetrics(messages: ParsedMessage[]): SessionMetrics {
cacheReadTokens,
cacheCreationTokens,
messageCount: messages.length,
costUsd: costUsd > 0 ? costUsd : undefined,
costUsd,
};
}

View file

@ -178,6 +178,9 @@ export const WINDOW_IS_FULLSCREEN = 'window:isFullScreen';
/** Event: (isFullScreen: boolean) when window enters or leaves fullscreen */
export const WINDOW_FULLSCREEN_CHANGED = 'window:fullscreen-changed';
/** Relaunch the application */
export const APP_RELAUNCH = 'app:relaunch';
// =============================================================================
// Team API Channels
// =============================================================================

View file

@ -2,6 +2,7 @@ import { WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL } from '@shared/constants';
import { contextBridge, ipcRenderer } from 'electron';
import {
APP_RELAUNCH,
CONTEXT_CHANGED,
CONTEXT_GET_ACTIVE,
CONTEXT_LIST,
@ -409,6 +410,7 @@ const electronAPI: ElectronAPI = {
close: () => ipcRenderer.invoke(WINDOW_CLOSE),
isMaximized: () => ipcRenderer.invoke(WINDOW_IS_MAXIMIZED) as Promise<boolean>,
isFullScreen: () => ipcRenderer.invoke(WINDOW_IS_FULLSCREEN) as Promise<boolean>,
relaunch: () => ipcRenderer.invoke(APP_RELAUNCH),
},
onFullScreenChange: (callback: (isFullScreen: boolean) => void): (() => void) => {

View file

@ -532,6 +532,7 @@ export class HttpAPIClient implements ElectronAPI {
close: async (): Promise<void> => {},
isMaximized: async (): Promise<boolean> => false,
isFullScreen: async (): Promise<boolean> => false,
relaunch: async (): Promise<void> => {},
};
onFullScreenChange =

View file

@ -245,6 +245,9 @@ const AIChatGroupInner = ({
return null;
}, [aiGroup.responses]);
// Get the total cost
const costUSD = aiGroup.metrics.costUsd;
// Calculate thinking and text output tokens from assistant message content blocks
// These are estimated from the actual content, providing breakdown of output token usage
const { thinkingTokens, textOutputTokens } = useMemo(() => {
@ -470,6 +473,7 @@ const AIChatGroupInner = ({
contextStats={contextStats}
phaseNumber={phaseNumber}
totalPhases={totalPhases}
costUsd={costUSD}
/>
)}

View file

@ -1,15 +1,21 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useAutoScrollBottom } from '@renderer/hooks/useAutoScrollBottom';
import { isNearBottom, useAutoScrollBottom } from '@renderer/hooks/useAutoScrollBottom';
import { useTabNavigationController } from '@renderer/hooks/useTabNavigationController';
import { useTabUI } from '@renderer/hooks/useTabUI';
import { useVisibleAIGroup } from '@renderer/hooks/useVisibleAIGroup';
import { useStore } from '@renderer/store';
import { useVirtualizer } from '@tanstack/react-virtual';
import { ChevronRight, Users } from 'lucide-react';
import { ChevronRight, ChevronsDown, Users } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { SessionContextPanel } from './SessionContextPanel/index';
/** Pixels from bottom considered "near bottom" for scroll-button visibility and auto-scroll. */
const SCROLL_THRESHOLD = 300;
/** Must match the `w-80` (320px) context panel width used in the layout below. */
const CONTEXT_PANEL_WIDTH_PX = 320;
import { ChatHistoryEmptyState } from './ChatHistoryEmptyState';
import { ChatHistoryItem } from './ChatHistoryItem';
import { ChatHistoryLoadingState } from './ChatHistoryLoadingState';
@ -60,6 +66,7 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
setTabVisibleAIGroup,
teams,
openTeamTab,
openSessionReport,
} = useStore(
useShallow((s) => ({
searchQuery: s.searchQuery,
@ -74,6 +81,7 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
setTabVisibleAIGroup: s.setTabVisibleAIGroup,
teams: s.teams,
openTeamTab: s.openTeamTab,
openSessionReport: s.openSessionReport,
}))
);
@ -98,6 +106,14 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
sessionDetail,
} = tabData;
// Compute combined subagent cost from process metrics
const subagentCostUsd = useMemo(() => {
const processes = sessionDetail?.processes;
if (!processes || processes.length === 0) return undefined;
const total = processes.reduce((sum, p) => sum + (p.metrics.costUsd ?? 0), 0);
return total > 0 ? total : undefined;
}, [sessionDetail?.processes]);
// State for Context button hover (local state OK - doesn't need per-tab isolation)
const [isContextButtonHovered, setIsContextButtonHovered] = useState(false);
@ -355,11 +371,21 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
rootRef: scrollContainerRef,
});
// Scroll-to-bottom button visibility
const [showScrollButton, setShowScrollButton] = useState(false);
const checkScrollButton = useCallback(() => {
const container = scrollContainerRef.current;
if (!container) return;
const { scrollTop, scrollHeight, clientHeight } = container;
setShowScrollButton(!isNearBottom(scrollTop, scrollHeight, clientHeight, SCROLL_THRESHOLD));
}, []);
// Auto-follow when conversation updates, but only if the user was already near bottom.
// This preserves manual reading position when the user scrolls up.
// Disabled during navigation to prevent conflicts with deep-link/search scrolling.
useAutoScrollBottom([conversation], {
threshold: 150,
const { scrollToBottom } = useAutoScrollBottom([conversation], {
threshold: SCROLL_THRESHOLD,
smoothDuration: 300,
autoBehavior: 'auto',
disabled: shouldDisableAutoScroll,
@ -367,6 +393,11 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
resetKey: effectiveTabId,
});
// Re-check button visibility whenever conversation updates
useEffect(() => {
checkScrollButton();
}, [conversation, checkScrollButton]);
// Callback to register AI group refs (combines with visibility hook)
const registerAIGroupRefCombined = useCallback(
(groupId: string) => {
@ -730,12 +761,13 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
className="flex flex-1 flex-col overflow-hidden"
style={{ backgroundColor: 'var(--color-surface)' }}
>
<div className="flex flex-1 overflow-hidden">
<div className="relative flex flex-1 overflow-hidden">
{/* Chat content */}
<div
ref={scrollContainerRef}
className="flex-1 overflow-y-auto"
style={{ backgroundColor: 'var(--color-surface)' }}
onScroll={checkScrollButton}
>
{/* Sticky Context button */}
{allContextInjections.length > 0 && (
@ -845,6 +877,30 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
</div>
</div>
{/* Scroll to bottom button */}
{showScrollButton && (
<button
onClick={() => {
scrollToBottom('smooth');
setShowScrollButton(false);
}}
className="absolute bottom-5 z-20 flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs shadow-lg backdrop-blur-md transition-all"
style={{
right:
isContextPanelVisible && allContextInjections.length > 0
? `calc(${CONTEXT_PANEL_WIDTH_PX}px + 1rem)`
: '1rem',
backgroundColor: 'var(--context-btn-bg)',
color: 'var(--color-text-secondary)',
border: '1px solid var(--color-border-emphasis)',
}}
title="Scroll to bottom"
>
<ChevronsDown className="size-3.5" />
<span>Bottom</span>
</button>
)}
{/* Context panel sidebar */}
{isContextPanelVisible && allContextInjections.length > 0 && (
<div className="w-80 shrink-0">
@ -856,6 +912,9 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
onNavigateToTool={handleNavigateToTool}
onNavigateToUserGroup={handleNavigateToUserGroup}
totalSessionTokens={lastAiGroupTotalTokens}
sessionMetrics={sessionDetail?.metrics}
subagentCostUsd={subagentCostUsd}
onViewReport={effectiveTabId ? () => openSessionReport(effectiveTabId) : undefined}
phaseInfo={sessionPhaseInfo ?? undefined}
selectedPhase={selectedContextPhase}
onPhaseChange={setSelectedContextPhase}

View file

@ -12,6 +12,7 @@ import {
COLOR_TEXT_MUTED,
COLOR_TEXT_SECONDARY,
} from '@renderer/constants/cssVariables';
import { formatCostUsd } from '@shared/utils/costFormatting';
import { ArrowDownWideNarrow, FileText, LayoutList, X } from 'lucide-react';
import { formatTokens } from '../utils/formatting';
@ -20,12 +21,16 @@ import { SessionContextHelpTooltip } from './SessionContextHelpTooltip';
import type { ContextViewMode } from '../types';
import type { ContextPhaseInfo } from '@renderer/types/contextInjection';
import type { SessionMetrics } from '@shared/types';
interface SessionContextHeaderProps {
injectionCount: number;
totalTokens: number;
totalSessionTokens?: number;
sessionMetrics?: SessionMetrics;
subagentCostUsd?: number;
onClose?: () => void;
onViewReport?: () => void;
phaseInfo?: ContextPhaseInfo;
selectedPhase: number | null;
onPhaseChange: (phase: number | null) => void;
@ -37,7 +42,10 @@ export const SessionContextHeader = ({
injectionCount,
totalTokens,
totalSessionTokens,
sessionMetrics,
subagentCostUsd,
onClose,
onViewReport,
phaseInfo,
selectedPhase,
onPhaseChange,
@ -115,6 +123,46 @@ export const SessionContextHeader = ({
)}
</div>
{/* Session Metrics Breakdown */}
{sessionMetrics && (
<div
className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 pt-2 text-[10px]"
style={{ borderTop: `1px solid ${COLOR_BORDER_SUBTLE}` }}
>
{/* Cost */}
{sessionMetrics.costUsd !== undefined && sessionMetrics.costUsd > 0 && (
<div className="col-span-2">
<span style={{ color: COLOR_TEXT_MUTED }}>Session Cost: </span>
<span className="font-medium tabular-nums" style={{ color: COLOR_TEXT_SECONDARY }}>
{formatCostUsd(sessionMetrics.costUsd + (subagentCostUsd ?? 0))}
</span>
{subagentCostUsd !== undefined && subagentCostUsd > 0 && (
<span style={{ color: COLOR_TEXT_MUTED }}>
{' ('}
{formatCostUsd(sessionMetrics.costUsd)}
{' parent + '}
{formatCostUsd(subagentCostUsd)}
{' subagents'}
{onViewReport && (
<>
{' · '}
<button
onClick={onViewReport}
className="underline"
style={{ color: COLOR_TEXT_SECONDARY }}
>
details
</button>
</>
)}
{')'}
</span>
)}
</div>
)}
</div>
)}
{/* Phase selector - only shown when compactions exist */}
{phaseInfo && phaseInfo.phases.length > 1 && (
<div

View file

@ -48,6 +48,9 @@ export const SessionContextPanel = ({
onNavigateToTool,
onNavigateToUserGroup,
totalSessionTokens,
sessionMetrics,
subagentCostUsd,
onViewReport,
phaseInfo,
selectedPhase,
onPhaseChange,
@ -190,7 +193,10 @@ export const SessionContextPanel = ({
injectionCount={injections.length}
totalTokens={totalTokens}
totalSessionTokens={totalSessionTokens}
sessionMetrics={sessionMetrics}
subagentCostUsd={subagentCostUsd}
onClose={onClose}
onViewReport={onViewReport}
phaseInfo={phaseInfo}
selectedPhase={selectedPhase}
onPhaseChange={onPhaseChange}

View file

@ -4,6 +4,7 @@
import type { ClaudeMdSource } from '@renderer/types/claudeMd';
import type { ContextInjection, ContextPhaseInfo } from '@renderer/types/contextInjection';
import type { SessionMetrics } from '@shared/types';
// =============================================================================
// Props Interface
@ -24,6 +25,12 @@ export interface SessionContextPanelProps {
onNavigateToUserGroup?: (turnIndex: number) => void;
/** Total session tokens (input + output + cache) for comparison */
totalSessionTokens?: number;
/** Full session metrics (input, output, cache tokens, cost) */
sessionMetrics?: SessionMetrics;
/** Combined cost of all subagent processes */
subagentCostUsd?: number;
/** Open the Session Report to see full cost breakdown */
onViewReport?: () => void;
/** Phase information for phase selector */
phaseInfo?: ContextPhaseInfo;
/** Currently selected phase (null = current/latest) */

View file

@ -11,6 +11,7 @@ import React, { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY } from '@renderer/constants/cssVariables';
import { formatCostUsd } from '@shared/utils/costFormatting';
import { getModelColorClass } from '@shared/utils/modelParser';
import {
formatTokensCompact as formatTokens,
@ -49,6 +50,8 @@ interface TokenUsageDisplayProps {
phaseNumber?: number;
/** Total number of phases in the session */
totalPhases?: number;
/** Optional USD cost for this usage */
costUsd?: number;
}
/**
@ -255,6 +258,7 @@ export const TokenUsageDisplay = ({
contextStats,
phaseNumber,
totalPhases,
costUsd,
}: Readonly<TokenUsageDisplayProps>): React.JSX.Element => {
const totalTokens = inputTokens + cacheReadTokens + cacheCreationTokens + outputTokens;
const formattedTotal = formatTokens(totalTokens);
@ -513,6 +517,19 @@ export const TokenUsageDisplay = ({
</span>
</div>
{/* Cost (USD) - if available */}
{costUsd !== undefined && costUsd > 0 && (
<div className="mt-1 flex items-center justify-between text-[10px]">
<span style={{ color: COLOR_TEXT_SECONDARY }}>Cost (USD)</span>
<span
className="tabular-nums"
style={{ color: 'var(--color-text-primary, var(--color-text))' }}
>
{formatCostUsd(costUsd)}
</span>
</div>
)}
{/* Visible Context Breakdown - expandable section */}
{contextStats &&
(contextStats.totalEstimatedTokens > 0 ||

View file

@ -18,6 +18,7 @@ import {
normalizePath,
type TaskStatusCounts,
} from '@renderer/utils/pathNormalize';
import { formatShortcut } from '@renderer/utils/stringUtils';
import { createLogger } from '@shared/utils/logger';
import { useShallow } from 'zustand/react/shallow';
@ -82,7 +83,11 @@ const CommandSearch = ({ value, onChange }: Readonly<CommandSearchProps>): React
<button
onClick={() => openCommandPalette()}
className="flex shrink-0 items-center gap-1 transition-opacity hover:opacity-80"
title={selectedProjectId ? 'Search in sessions (⌘K)' : 'Search projects (⌘K)'}
title={
selectedProjectId
? `Search in sessions (${formatShortcut('K')})`
: `Search projects (${formatShortcut('K')})`
}
>
<kbd className="flex h-5 items-center justify-center rounded border border-border bg-surface-overlay px-1.5 text-[10px] font-medium text-text-muted">
<Command className="size-2.5" />

View file

@ -1,33 +1,36 @@
/**
* WindowsTitleBar - Conventional title bar for Windows when the native frame is hidden.
* CustomTitleBar - Conventional title bar for Windows and Linux when the native frame is hidden.
*
* Renders a draggable top strip with window controls (minimize, maximize/restore, close)
* on the right, matching Windows conventions. Only shown in Electron on Windows (win32).
* on the right. Only shown in Electron on Windows or Linux (macOS uses native traffic lights).
*/
import { useEffect, useState } from 'react';
import { isElectronMode } from '@renderer/api';
import { AppLogo } from '@renderer/components/common/AppLogo';
import faviconUrl from '@renderer/favicon.png';
import { useStore } from '@renderer/store';
import { Minus, Square, X } from 'lucide-react';
const TITLE_BAR_HEIGHT = 32;
function isWindowsDesktop(): boolean {
function needsCustomTitleBar(): boolean {
if (!isElectronMode()) return false;
return window.navigator.userAgent.includes('Windows');
const ua = window.navigator.userAgent;
return ua.includes('Windows') || ua.includes('Linux');
}
export const WindowsTitleBar = (): React.JSX.Element | null => {
export const CustomTitleBar = (): React.JSX.Element | null => {
const [isMaximized, setIsMaximized] = useState(false);
const isWin = isWindowsDesktop();
const useNativeTitleBar = useStore((s) => s.appConfig?.general?.useNativeTitleBar ?? false);
const showTitleBar = needsCustomTitleBar() && !useNativeTitleBar;
const api = typeof window !== 'undefined' ? window.electronAPI?.windowControls : null;
useEffect(() => {
if (api) void api.isMaximized().then(setIsMaximized);
}, [api]);
if (!isWin || !api) return null;
if (!showTitleBar || !api) return null;
const { minimize, maximize, close, isMaximized: getIsMaximized } = api;
@ -50,15 +53,9 @@ export const WindowsTitleBar = (): React.JSX.Element | null => {
return (
<div className="flex shrink-0 select-none items-stretch" style={titleBarStyle}>
{/* Draggable area — app title optional */}
<div className="flex flex-1 items-center gap-2 pl-3" style={{ minWidth: 0 }}>
<AppLogo size={18} className="shrink-0" />
<span
className="truncate text-sm font-semibold"
style={{ color: 'var(--color-text-muted)' }}
>
Claude Agent Teams UI
</span>
{/* Draggable area — app icon */}
<div className="flex flex-1 items-center pl-3" style={{ minWidth: 0 }}>
<img src={faviconUrl} alt="" className="size-5 shrink-0 rounded-sm" draggable={false} />
</div>
{/* Window controls — no-drag so they receive clicks */}

View file

@ -0,0 +1,216 @@
/**
* MoreMenu - Dropdown menu behind a "..." icon for less-frequent toolbar actions.
*
* Groups: Search, Export (session-only), Analyze (session-only), Settings.
* Closes on outside click or Escape.
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useStore } from '@renderer/store';
import { triggerDownload } from '@renderer/utils/sessionExporter';
import { formatShortcut } from '@renderer/utils/stringUtils';
import { Activity, Braces, FileText, MoreHorizontal, Search, Settings, Type } from 'lucide-react';
import type { SessionDetail } from '@renderer/types/data';
import type { Tab } from '@renderer/types/tabs';
import type { ExportFormat } from '@renderer/utils/sessionExporter';
interface MoreMenuProps {
activeTab: Tab | undefined;
activeTabSessionDetail: SessionDetail | null;
activeTabId: string | null;
}
interface MenuItem {
id: string;
label: string;
icon: React.ComponentType<{ className?: string }>;
shortcut?: string;
onClick: () => void;
}
export const MoreMenu = ({
activeTab,
activeTabSessionDetail,
activeTabId,
}: Readonly<MoreMenuProps>): React.JSX.Element => {
const [isOpen, setIsOpen] = useState(false);
const [buttonHover, setButtonHover] = useState(false);
const [hoveredId, setHoveredId] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const openCommandPalette = useStore((s) => s.openCommandPalette);
const openSettingsTab = useStore((s) => s.openSettingsTab);
const openSessionReport = useStore((s) => s.openSessionReport);
// Close on outside click
useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (event: MouseEvent): void => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen]);
// Close on Escape
useEffect(() => {
if (!isOpen) return;
const handleEscape = (event: KeyboardEvent): void => {
if (event.key === 'Escape') {
setIsOpen(false);
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen]);
const handleExport = useCallback(
(format: ExportFormat) => {
if (activeTabSessionDetail) {
triggerDownload(activeTabSessionDetail, format);
}
setIsOpen(false);
},
[activeTabSessionDetail]
);
const isSessionWithData = activeTab?.type === 'session' && activeTabSessionDetail != null;
// Build menu sections
const topItems: MenuItem[] = [
{
id: 'search',
label: 'Search',
icon: Search,
shortcut: formatShortcut('K'),
onClick: () => {
openCommandPalette();
setIsOpen(false);
},
},
];
const sessionItems: MenuItem[] = isSessionWithData
? [
{
id: 'export-md',
label: 'Export as Markdown',
icon: FileText,
shortcut: '.md',
onClick: () => handleExport('markdown'),
},
{
id: 'export-json',
label: 'Export as JSON',
icon: Braces,
shortcut: '.json',
onClick: () => handleExport('json'),
},
{
id: 'export-txt',
label: 'Export as Plain Text',
icon: Type,
shortcut: '.txt',
onClick: () => handleExport('plaintext'),
},
{
id: 'analyze',
label: 'Analyze Session',
icon: Activity,
onClick: () => {
if (activeTabId) openSessionReport(activeTabId);
setIsOpen(false);
},
},
]
: [];
const bottomItems: MenuItem[] = [
{
id: 'settings',
label: 'Settings',
icon: Settings,
shortcut: formatShortcut(','),
onClick: () => {
openSettingsTab();
setIsOpen(false);
},
},
];
const renderItem = (item: MenuItem): React.JSX.Element => (
<button
key={item.id}
onClick={item.onClick}
onMouseEnter={() => setHoveredId(item.id)}
onMouseLeave={() => setHoveredId(null)}
className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-xs transition-colors"
style={{
color: hoveredId === item.id ? 'var(--color-text)' : 'var(--color-text-secondary)',
backgroundColor: hoveredId === item.id ? 'var(--color-surface-raised)' : 'transparent',
}}
>
<item.icon className="size-3.5" />
<span className="flex-1">{item.label}</span>
{item.shortcut && (
<span className="text-[10px]" style={{ color: 'var(--color-text-muted)' }}>
{item.shortcut}
</span>
)}
</button>
);
const separator = (
<div className="my-0.5" style={{ borderBottom: '1px solid var(--color-border)' }} />
);
return (
<div ref={containerRef} className="relative">
{/* Trigger button */}
<button
onClick={() => setIsOpen(!isOpen)}
onMouseEnter={() => setButtonHover(true)}
onMouseLeave={() => setButtonHover(false)}
className="rounded-md p-2 transition-colors"
style={{
color: buttonHover || isOpen ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: buttonHover || isOpen ? 'var(--color-surface-raised)' : 'transparent',
}}
title="More actions"
>
<MoreHorizontal className="size-4" />
</button>
{/* Dropdown menu */}
{isOpen && (
<div
className="absolute right-0 top-full z-50 mt-1 w-52 overflow-hidden rounded-md border shadow-lg"
style={{
backgroundColor: 'var(--color-surface-overlay)',
borderColor: 'var(--color-border)',
}}
>
{topItems.map(renderItem)}
{sessionItems.length > 0 && (
<>
{separator}
{sessionItems.map(renderItem)}
</>
)}
{separator}
{bottomItems.map(renderItem)}
</div>
)}
</div>
);
};

View file

@ -7,6 +7,7 @@ import { TabUIProvider } from '@renderer/contexts/TabUIContext';
import { DashboardView } from '../dashboard/DashboardView';
import { NotificationsView } from '../notifications/NotificationsView';
import { SessionReportTab } from '../report/SessionReportTab';
import { SettingsView } from '../settings/SettingsView';
import { TeamDetailView } from '../team/TeamDetailView';
import { TeamListView } from '../team/TeamListView';
@ -51,6 +52,7 @@ export const PaneContent = ({ pane }: PaneContentProps): React.JSX.Element => {
<SessionTabContent tab={tab} isActive={isActive} />
</TabUIProvider>
)}
{tab.type === 'report' && <SessionReportTab tab={tab} />}
</div>
);
})}

View file

@ -17,7 +17,7 @@ import { isElectronMode } from '@renderer/api';
import { HEADER_ROW1_HEIGHT, HEADER_ROW2_HEIGHT } from '@renderer/constants/layout';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { truncateMiddle } from '@renderer/utils/stringUtils';
import { formatShortcut, truncateMiddle } from '@renderer/utils/stringUtils';
import { Check, ChevronDown, GitBranch, PanelLeft } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
@ -329,7 +329,7 @@ export const SidebarHeader = (): React.JSX.Element => {
backgroundColor: isCollapseHovered ? 'var(--color-surface-raised)' : 'transparent',
} as React.CSSProperties
}
title="Collapse sidebar (⌘B)"
title={`Collapse sidebar (${formatShortcut('B')})`}
>
<PanelLeft className="size-4" />
</button>

View file

@ -8,7 +8,17 @@ import { useCallback, useState } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { useStore } from '@renderer/store';
import { Bell, FileText, LayoutDashboard, Pin, Search, Settings, Users, X } from 'lucide-react';
import {
Activity,
Bell,
FileText,
LayoutDashboard,
Pin,
Search,
Settings,
Users,
X,
} from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import type { Tab } from '@renderer/types/tabs';
@ -32,6 +42,7 @@ const TAB_ICONS = {
session: FileText,
teams: Users,
team: Users,
report: Activity,
} as const;
export const SortableTab = ({
@ -62,7 +73,8 @@ export const SortableTab = ({
},
});
const style: React.CSSProperties = {
const style = {
WebkitAppRegion: 'no-drag',
transform: CSS.Transform.toString(transform),
transition: isDragging ? 'none' : transition,
opacity: isDragging ? 0.3 : 1,

View file

@ -14,11 +14,11 @@ import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortabl
import { isElectronMode } from '@renderer/api';
import { HEADER_ROW1_HEIGHT } from '@renderer/constants/layout';
import { useStore } from '@renderer/store';
import { Bell, PanelLeft, Plus, RefreshCw, Search, Settings, Users } from 'lucide-react';
import { formatShortcut } from '@renderer/utils/stringUtils';
import { Bell, PanelLeft, Plus, RefreshCw, Settings, Users } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { ExportDropdown } from '../common/ExportDropdown';
import { MoreMenu } from './MoreMenu';
import { SortableTab } from './SortableTab';
import { TabContextMenu } from './TabContextMenu';
@ -41,7 +41,6 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
openDashboard,
fetchSessionDetail,
fetchSessions,
openCommandPalette,
unreadCount,
openNotificationsTab,
openTeamsTab,
@ -69,7 +68,6 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
openDashboard: s.openDashboard,
fetchSessionDetail: s.fetchSessionDetail,
fetchSessions: s.fetchSessions,
openCommandPalette: s.openCommandPalette,
unreadCount: s.unreadCount,
openNotificationsTab: s.openNotificationsTab,
openTeamsTab: s.openTeamsTab,
@ -104,7 +102,6 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
const [expandHover, setExpandHover] = useState(false);
const [refreshHover, setRefreshHover] = useState(false);
const [newTabHover, setNewTabHover] = useState(false);
const [searchHover, setSearchHover] = useState(false);
const [notificationsHover, setNotificationsHover] = useState(false);
const [teamsHover, setTeamsHover] = useState(false);
const [settingsHover, setSettingsHover] = useState(false);
@ -271,8 +268,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
sidebarCollapsed && isLeftmostPane
? 'var(--macos-traffic-light-padding-left, 72px)'
: '8px',
WebkitAppRegion:
isElectronMode() && sidebarCollapsed && isLeftmostPane ? 'drag' : undefined,
WebkitAppRegion: isElectronMode() && isLeftmostPane ? 'drag' : undefined,
backgroundColor: 'var(--color-surface)',
borderBottom: '1px solid var(--color-border)',
opacity: isFocused || paneCount === 1 ? 1 : 0.7,
@ -299,15 +295,17 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
</button>
)}
{/* Tab list with horizontal scroll, sortable DnD, and droppable area */}
{/* Tab list with horizontal scroll, sortable DnD, and droppable area.
Capped at 75% so the drag spacer always has room to the right. */}
<div
ref={(el) => {
scrollContainerRef.current = el;
setDroppableRef(el);
}}
className="scrollbar-none flex min-w-0 flex-1 items-center gap-1 overflow-x-auto"
className="scrollbar-none flex min-w-0 shrink items-center gap-1 overflow-x-auto"
style={
{
maxWidth: '75%',
WebkitAppRegion: 'no-drag',
outline: isDroppableOver ? '1px dashed var(--color-accent, #6366f1)' : 'none',
outlineOffset: '-1px',
@ -342,13 +340,25 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
onMouseEnter={() => setRefreshHover(true)}
onMouseLeave={() => setRefreshHover(false)}
onClick={handleRefresh}
title="Refresh Session (Cmd+R)"
title={`Refresh Session (${formatShortcut('R')})`}
>
<RefreshCw className="size-4" />
</button>
)}
</div>
{/* Drag spacer fills empty space between tab list and action buttons.
Gives users a reliable window-drag target regardless of how many tabs are open.
Only applied on the leftmost pane in Electron to match the TabBar drag region logic. */}
<div
className="flex-1 self-stretch"
style={
{
WebkitAppRegion: isElectronMode() && isLeftmostPane ? 'drag' : undefined,
} as React.CSSProperties
}
/>
{/* Right side actions */}
<div
className="ml-2 flex shrink-0 items-center gap-1"
@ -369,26 +379,6 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
<Plus className="size-4" />
</button>
{/* Search button (icon only) */}
<button
onClick={openCommandPalette}
onMouseEnter={() => setSearchHover(true)}
onMouseLeave={() => setSearchHover(false)}
className="rounded-md p-2 transition-colors"
style={{
color: searchHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: searchHover ? 'var(--color-surface-raised)' : 'transparent',
}}
title="Search (Cmd+K)"
>
<Search className="size-4" />
</button>
{/* Export dropdown - show only for session tabs with loaded data */}
{activeTab?.type === 'session' && activeTabSessionDetail && (
<ExportDropdown sessionDetail={activeTabSessionDetail} />
)}
{/* Notifications bell icon */}
<button
onClick={openNotificationsTab}
@ -438,6 +428,13 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
>
<Settings className="size-4" />
</button>
{/* More menu (Search, Export, Analyze, Settings) */}
<MoreMenu
activeTab={activeTab}
activeTabSessionDetail={activeTabSessionDetail}
activeTabId={activeTabId}
/>
</div>
{/* Context menu */}

View file

@ -7,6 +7,8 @@
import { useEffect, useRef } from 'react';
import { formatShortcut } from '@renderer/utils/stringUtils';
interface TabContextMenuProps {
x: number;
y: number;
@ -100,13 +102,17 @@ export const TabContextMenu = ({
onClick={handleClick(onCloseSelectedTabs)}
/>
) : (
<MenuItem label="Close Tab" shortcut="⌘W" onClick={handleClick(onCloseTab)} />
<MenuItem
label="Close Tab"
shortcut={formatShortcut('W')}
onClick={handleClick(onCloseTab)}
/>
)}
<MenuItem label="Close Other Tabs" onClick={handleClick(onCloseOtherTabs)} />
<div className="mx-2 my-1 border-t" style={{ borderColor: 'var(--color-border)' }} />
<MenuItem
label="Split Right"
shortcut="⌘\"
shortcut={formatShortcut('\\')}
onClick={handleClick(onSplitRight)}
disabled={disableSplit}
/>
@ -127,7 +133,11 @@ export const TabContextMenu = ({
/>
)}
<div className="mx-2 my-1 border-t" style={{ borderColor: 'var(--color-border)' }} />
<MenuItem label="Close All Tabs" shortcut="⇧⌘W" onClick={handleClick(onCloseAllTabs)} />
<MenuItem
label="Close All Tabs"
shortcut={formatShortcut('W', { shift: true })}
onClick={handleClick(onCloseAllTabs)}
/>
</div>
);
};

View file

@ -17,9 +17,9 @@ import { UpdateDialog } from '../common/UpdateDialog';
import { WorkspaceIndicator } from '../common/WorkspaceIndicator';
import { CommandPalette } from '../search/CommandPalette';
import { CustomTitleBar } from './CustomTitleBar';
import { PaneContainer } from './PaneContainer';
import { Sidebar } from './Sidebar';
import { WindowsTitleBar } from './WindowsTitleBar';
export const TabbedLayout = (): React.JSX.Element => {
useKeyboardShortcuts();
@ -38,7 +38,7 @@ export const TabbedLayout = (): React.JSX.Element => {
{ '--macos-traffic-light-padding-left': `${trafficLightPadding}px` } as React.CSSProperties
}
>
<WindowsTitleBar />
<CustomTitleBar />
<UpdateBanner />
<div className="flex flex-1 overflow-hidden">
{/* Command Palette (Cmd+K) */}

View file

@ -0,0 +1,78 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import {
assessmentColor,
assessmentExplanation,
assessmentLabel,
} from '@renderer/utils/reportAssessments';
import type { MetricKey } from '@renderer/utils/reportAssessments';
interface AssessmentBadgeProps {
assessment: string;
metricKey?: MetricKey;
}
export const AssessmentBadge = ({ assessment, metricKey }: AssessmentBadgeProps) => {
const color = assessmentColor(assessment);
const explanation = metricKey ? assessmentExplanation(metricKey, assessment) : '';
const [showTooltip, setShowTooltip] = useState(false);
const [tooltipPos, setTooltipPos] = useState({ top: 0, left: 0 });
const badgeRef = useRef<HTMLSpanElement>(null);
const enterTimer = useRef<ReturnType<typeof setTimeout>>();
const leaveTimer = useRef<ReturnType<typeof setTimeout>>();
const handleMouseEnter = useCallback(() => {
if (!explanation) return;
clearTimeout(leaveTimer.current);
enterTimer.current = setTimeout(() => {
if (badgeRef.current) {
const rect = badgeRef.current.getBoundingClientRect();
setTooltipPos({ top: rect.bottom + 4, left: rect.left + rect.width / 2 });
}
setShowTooltip(true);
}, 200);
}, [explanation]);
const handleMouseLeave = useCallback(() => {
clearTimeout(enterTimer.current);
leaveTimer.current = setTimeout(() => setShowTooltip(false), 150);
}, []);
useEffect(() => {
return () => {
clearTimeout(enterTimer.current);
clearTimeout(leaveTimer.current);
};
}, []);
return (
<>
<span
ref={badgeRef}
className="rounded px-2 py-0.5 text-xs font-medium"
style={{ backgroundColor: `${color}20`, color }}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{assessmentLabel(assessment)}
</span>
{showTooltip &&
explanation &&
createPortal(
<div
className="pointer-events-none fixed z-50 max-w-60 rounded border border-border bg-surface-raised px-2.5 py-1.5 text-xs text-text-secondary shadow-lg"
style={{
top: tooltipPos.top,
left: tooltipPos.left,
transform: 'translateX(-50%)',
}}
>
{explanation}
</div>,
document.body
)}
</>
);
};

View file

@ -0,0 +1,58 @@
import { useEffect, useRef, useState } from 'react';
import { ChevronDown, ChevronRight } from 'lucide-react';
const sectionId = (title: string) =>
`report-section-${title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
interface ReportSectionProps {
title: string;
icon: React.ComponentType<{ className?: string }>;
children: React.ReactNode;
defaultCollapsed?: boolean;
}
export const ReportSection = ({
title,
icon: Icon,
children,
defaultCollapsed = false,
}: ReportSectionProps) => {
const [collapsed, setCollapsed] = useState(defaultCollapsed);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const handler = () => {
setCollapsed(false);
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
el.addEventListener('report-section-expand', handler);
return () => el.removeEventListener('report-section-expand', handler);
}, []);
return (
<div
ref={ref}
id={sectionId(title)}
className="rounded-lg border border-border bg-surface-raised"
>
<button
onClick={() => setCollapsed(!collapsed)}
className="flex w-full items-center gap-2 p-4 text-left"
>
{collapsed ? (
<ChevronRight className="size-4 text-text-muted" />
) : (
<ChevronDown className="size-4 text-text-muted" />
)}
<Icon className="size-4 text-text-secondary" />
<span className="text-sm font-semibold text-text">{title}</span>
</button>
{!collapsed && <div className="border-t border-border px-4 pb-4 pt-3">{children}</div>}
</div>
);
};
export { sectionId };

View file

@ -0,0 +1,99 @@
import { useMemo } from 'react';
import { useStore } from '@renderer/store';
import { computeTakeaways } from '@renderer/utils/reportAssessments';
import { analyzeSession } from '@renderer/utils/sessionAnalyzer';
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 { KeyTakeawaysSection } from './sections/KeyTakeawaysSection';
import { OverviewSection } from './sections/OverviewSection';
import { QualitySection } from './sections/QualitySection';
import { SubagentSection } from './sections/SubagentSection';
import { TimelineSection } from './sections/TimelineSection';
import { TokenSection } from './sections/TokenSection';
import { ToolSection } from './sections/ToolSection';
import type { Tab } from '@renderer/types/tabs';
interface SessionReportTabProps {
tab: Tab;
}
export const SessionReportTab = ({ tab }: SessionReportTabProps) => {
// Find session data from any session tab with matching sessionId
const sessionDetail = useStore((s) => {
const allTabs = s.paneLayout.panes.flatMap((p) => p.tabs);
const sourceTab = allTabs.find((t) => t.type === 'session' && t.sessionId === tab.sessionId);
return sourceTab ? s.tabSessionData[sourceTab.id]?.sessionDetail : null;
});
const report = useMemo(
() => (sessionDetail ? analyzeSession(sessionDetail) : null),
[sessionDetail]
);
const takeaways = useMemo(() => (report ? computeTakeaways(report) : []), [report]);
if (!report) {
return (
<div className="flex h-full items-center justify-center text-text-muted">
No session data available. Open the session tab first.
</div>
);
}
return (
<div className="h-full overflow-y-auto p-6" style={{ backgroundColor: 'var(--color-surface)' }}>
<h1 className="mb-6 text-lg font-semibold text-text">Session Analysis Report</h1>
<div className="flex flex-col gap-4">
{takeaways.length > 0 && <KeyTakeawaysSection takeaways={takeaways} />}
<OverviewSection data={report.overview} />
<CostSection
data={report.costAnalysis}
tokensByModel={report.tokenUsage.byModel}
commitCount={report.gitActivity.commitCount}
linesChanged={report.gitActivity.linesChanged}
/>
<TokenSection data={report.tokenUsage} cacheEconomics={report.cacheEconomics} />
<ToolSection data={report.toolUsage} />
{report.subagentMetrics.count > 0 && (
<SubagentSection data={report.subagentMetrics} defaultCollapsed />
)}
{report.errors.errors.length > 0 && <ErrorSection data={report.errors} defaultCollapsed />}
<GitSection data={report.gitActivity} defaultCollapsed />
<FrictionSection
data={report.frictionSignals}
thrashing={report.thrashingSignals}
defaultCollapsed
/>
<TimelineSection
idle={report.idleAnalysis}
modelSwitches={report.modelSwitches}
keyEvents={report.keyEvents}
defaultCollapsed
/>
<QualitySection
prompt={report.promptQuality}
startup={report.startupOverhead}
testProgression={report.testProgression}
fileReadRedundancy={report.fileReadRedundancy}
defaultCollapsed
/>
<InsightsSection
skills={report.skillsInvoked}
bash={report.bashCommands}
lifecycleTasks={report.lifecycleTasks}
userQuestions={report.userQuestions}
outOfScope={report.outOfScopeFindings}
agentTree={report.agentTree}
subagentsList={report.subagentsList}
defaultCollapsed
/>
</div>
</div>
);
};

View file

@ -0,0 +1,260 @@
import { Fragment, useState } from 'react';
import { getPricing } from '@renderer/utils/sessionAnalyzer';
import { DollarSign } from 'lucide-react';
import { AssessmentBadge } from '../AssessmentBadge';
import { ReportSection, sectionId } from '../ReportSection';
import type {
ModelPricing,
ModelTokenStats,
ReportCostAnalysis,
} from '@renderer/types/sessionReport';
const fmt = (v: number) => `$${v.toFixed(4)}`;
const fmtK = (v: number) => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : String(v));
const fmtRate = (v: number) => `$${v}`;
const lineCost = (tokens: number, ratePerM: number) => (tokens * ratePerM) / 1_000_000;
interface CostSectionProps {
data: ReportCostAnalysis;
tokensByModel: Record<string, ModelTokenStats>;
commitCount: number;
linesChanged: number;
defaultCollapsed?: boolean;
}
interface BreakdownLine {
label: string;
tokens: number;
ratePerM: number;
}
const CostBreakdownCard = ({
stats,
pricing,
}: {
stats: ModelTokenStats;
pricing: ModelPricing;
}) => {
const lines: BreakdownLine[] = [
{ label: 'Input', tokens: stats.inputTokens, ratePerM: pricing.input },
{ label: 'Output', tokens: stats.outputTokens, ratePerM: pricing.output },
{ label: 'Cache Read', tokens: stats.cacheRead, ratePerM: pricing.cache_read },
{ label: 'Cache Write', tokens: stats.cacheCreation, ratePerM: pricing.cache_creation },
];
const total = lines.reduce((sum, l) => sum + lineCost(l.tokens, l.ratePerM), 0);
return (
<div className="rounded-md border border-border bg-surface-raised px-4 py-3">
<div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-text-muted">
Cost Breakdown (per 1M tokens)
</div>
<div className="flex flex-col gap-1.5 font-mono text-xs">
{lines.map((l) => {
const cost = lineCost(l.tokens, l.ratePerM);
return (
<div key={l.label} className="flex items-baseline justify-between gap-4">
<span className="text-text-muted">{l.label}</span>
<span className="text-text-secondary">
{l.tokens.toLocaleString()} {'\u00D7'} {fmtRate(l.ratePerM)}/M = {fmt(cost)}
</span>
</div>
);
})}
<div className="mt-1 flex items-baseline justify-between gap-4 border-t border-border pt-1.5">
<span className="font-medium text-text">Total</span>
<span className="font-medium text-text">{fmt(total)}</span>
</div>
</div>
</div>
);
};
export const CostSection = ({
data,
tokensByModel,
commitCount,
linesChanged,
defaultCollapsed,
}: CostSectionProps) => {
const [expandedModel, setExpandedModel] = useState<string | null>(null);
const modelEntries = Object.entries(data.costByModel).sort((a, b) => b[1] - a[1]);
const showStackedBar = data.subagentCostUsd > 0;
const parentPct =
showStackedBar && data.totalSessionCostUsd > 0
? (data.parentCostUsd / data.totalSessionCostUsd) * 100
: 100;
return (
<ReportSection title="Cost Analysis" icon={DollarSign} defaultCollapsed={defaultCollapsed}>
<div className="mb-4 text-2xl font-bold text-text">{fmt(data.totalSessionCostUsd)}</div>
{/* Parent/Subagent stacked bar */}
{showStackedBar && (
<div className="mb-4">
<div className="mb-1.5 flex h-3 w-full overflow-hidden rounded-full">
<div
className="h-full"
style={{ width: `${parentPct}%`, backgroundColor: '#60a5fa' }}
/>
<div
className="h-full"
style={{ width: `${100 - parentPct}%`, backgroundColor: '#c084fc' }}
/>
</div>
<div className="flex gap-4 text-xs">
<div className="flex items-center gap-1.5">
<span
className="inline-block size-2 rounded-full"
style={{ backgroundColor: '#60a5fa' }}
/>
<span className="text-text-secondary">Parent: {fmt(data.parentCostUsd)}</span>
</div>
<div className="flex items-center gap-1.5">
<span
className="inline-block size-2 rounded-full"
style={{ backgroundColor: '#c084fc' }}
/>
<span className="text-text-secondary">Subagent: {fmt(data.subagentCostUsd)}</span>
</div>
</div>
</div>
)}
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
{!showStackedBar && (
<>
<div>
<div className="text-xs text-text-muted">Parent Cost</div>
<div className="text-sm font-medium text-text">{fmt(data.parentCostUsd)}</div>
</div>
<div>
<div className="text-xs text-text-muted">Subagent Cost</div>
<div className="text-sm font-medium text-text">{fmt(data.subagentCostUsd)}</div>
</div>
</>
)}
<div>
<div className="text-xs text-text-muted">Per Commit</div>
<div className="text-[10px] text-text-muted">
{commitCount > 0 ? (
<>
total cost {'\u00F7'} {commitCount} commit{commitCount !== 1 ? 's' : ''}
</>
) : (
'no commits'
)}
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-text">
{data.costPerCommit != null ? fmt(data.costPerCommit) : 'N/A'}
</span>
{data.costPerCommitAssessment && (
<AssessmentBadge
assessment={data.costPerCommitAssessment}
metricKey="costPerCommit"
/>
)}
</div>
</div>
<div>
<div className="text-xs text-text-muted">Per Line Changed</div>
<div className="text-[10px] text-text-muted">
{linesChanged > 0 ? (
<>
total cost {'\u00F7'} {linesChanged.toLocaleString()} line
{linesChanged !== 1 ? 's' : ''}
</>
) : (
'no lines changed'
)}
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-text">
{data.costPerLineChanged != null ? `$${data.costPerLineChanged.toFixed(6)}` : 'N/A'}
</span>
{data.costPerLineAssessment && (
<AssessmentBadge assessment={data.costPerLineAssessment} metricKey="costPerLine" />
)}
</div>
</div>
</div>
{modelEntries.length > 0 && (
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border text-left text-text-muted">
<th className="pb-2 pr-4">Model</th>
<th className="pb-2 pr-4 text-right">Input</th>
<th className="pb-2 pr-4 text-right">Output</th>
<th className="pb-2 pr-4 text-right">Cache Read</th>
<th className="pb-2 pr-4 text-right">Cache Write</th>
<th className="pb-2 pr-4 text-right">Cost</th>
</tr>
</thead>
<tbody>
{modelEntries.map(([model, cost]) => {
const stats = tokensByModel[model];
// Don't allow expansion for the synthetic aggregated row — getPricing
// would return wrong default rates for a non-model label.
const isAggregateRow = model === 'Subagents (combined)';
const isExpanded = expandedModel === model && !!stats && !isAggregateRow;
const pricing = isAggregateRow ? null : getPricing(model);
return (
<Fragment key={model}>
<tr
className={`border-border/50 border-b ${stats ? 'hover:bg-surface-raised/50 cursor-pointer' : ''}`}
onClick={() => {
if (isAggregateRow) {
const el = document.getElementById(sectionId('Subagents'));
if (el) {
el.scrollIntoView({ behavior: 'smooth' });
el.dispatchEvent(new CustomEvent('report-section-expand'));
}
} else if (stats) {
setExpandedModel(isExpanded ? null : model);
}
}}
>
<td className="py-1.5 pr-4 text-text">
{isAggregateRow ? (
<span className="mr-1.5 inline-block w-3 text-text-muted">{'\u2192'}</span>
) : (
<span className="mr-1.5 inline-block w-3 text-text-muted">
{stats ? (isExpanded ? '\u25BC' : '\u25B6') : ''}
</span>
)}
{model}
</td>
<td className="py-1.5 pr-4 text-right text-text-secondary">
{stats ? fmtK(stats.inputTokens) : '—'}
</td>
<td className="py-1.5 pr-4 text-right text-text-secondary">
{stats ? fmtK(stats.outputTokens) : '—'}
</td>
<td className="py-1.5 pr-4 text-right text-text-secondary">
{stats ? fmtK(stats.cacheRead) : '—'}
</td>
<td className="py-1.5 pr-4 text-right text-text-secondary">
{stats ? fmtK(stats.cacheCreation) : '—'}
</td>
<td className="py-1.5 pr-4 text-right font-medium text-text">{fmt(cost)}</td>
</tr>
{isExpanded && stats && pricing && (
<tr>
<td colSpan={6} className="px-4 pb-3 pt-1">
<CostBreakdownCard stats={stats} pricing={pricing} />
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</table>
)}
</ReportSection>
);
};

View file

@ -0,0 +1,103 @@
import { useState } from 'react';
import { AlertTriangle, ChevronDown, ChevronRight } from 'lucide-react';
import { ReportSection } from '../ReportSection';
import type { ReportErrors, ToolError } from '@renderer/types/sessionReport';
interface ErrorItemProps {
error: ToolError;
}
const ErrorItem = ({ error }: ErrorItemProps) => {
const [expanded, setExpanded] = useState(false);
return (
<div className="border-border/50 rounded border bg-surface p-2">
<button
onClick={() => setExpanded(!expanded)}
className="flex w-full items-center gap-2 text-left text-xs"
>
{expanded ? (
<ChevronDown className="size-3 text-text-muted" />
) : (
<ChevronRight className="size-3 text-text-muted" />
)}
<span className="font-medium text-text">{error.tool}</span>
{error.isPermissionDenial && (
<span
className="rounded px-1.5 py-0.5 text-[10px] font-medium"
style={{
backgroundColor: 'color-mix(in srgb, var(--assess-danger) 15%, transparent)',
color: 'var(--assess-danger)',
}}
>
Permission Denied
</span>
)}
<span className="ml-auto text-text-muted">msg #{error.messageIndex}</span>
</button>
{expanded && (
<div className="mt-2 flex flex-col gap-1.5">
{error.inputPreview && (
<div className="rounded bg-surface-raised p-2">
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-text-muted">
Input
</div>
<div className="whitespace-pre-wrap break-words font-mono text-xs text-text-secondary">
{error.inputPreview}
</div>
</div>
)}
<div className="rounded bg-surface-raised p-2">
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-text-muted">
Error
</div>
<div
className="whitespace-pre-wrap break-words text-xs"
style={{ color: 'var(--assess-danger)' }}
>
{error.error}
</div>
</div>
</div>
)}
</div>
);
};
interface ErrorSectionProps {
data: ReportErrors;
defaultCollapsed?: boolean;
}
export const ErrorSection = ({ data, defaultCollapsed }: ErrorSectionProps) => {
return (
<ReportSection title="Errors" icon={AlertTriangle} defaultCollapsed={defaultCollapsed}>
<div className="mb-3 flex items-center gap-3">
<span
className="rounded px-2 py-0.5 text-xs font-medium"
style={{
backgroundColor: 'color-mix(in srgb, var(--assess-danger) 15%, transparent)',
color: 'var(--assess-danger)',
}}
>
{data.errors.length} error{data.errors.length !== 1 ? 's' : ''}
</span>
{data.permissionDenials.count > 0 && (
<span className="text-xs text-text-muted">
{data.permissionDenials.count} permission denial
{data.permissionDenials.count !== 1 ? 's' : ''}
</span>
)}
</div>
<div className="flex flex-col gap-2">
{data.errors.map((error, idx) => (
<ErrorItem key={idx} error={error} />
))}
</div>
</ReportSection>
);
};

View file

@ -0,0 +1,97 @@
import { severityColor } from '@renderer/utils/reportAssessments';
import { MessageSquareWarning } from 'lucide-react';
import { AssessmentBadge } from '../AssessmentBadge';
import { ReportSection } from '../ReportSection';
import type { ReportFrictionSignals, ReportThrashingSignals } from '@renderer/types/sessionReport';
interface FrictionSectionProps {
data: ReportFrictionSignals;
thrashing: ReportThrashingSignals;
defaultCollapsed?: boolean;
}
export const FrictionSection = ({ data, thrashing, defaultCollapsed }: FrictionSectionProps) => {
const frictionSeverity =
data.frictionRate <= 0.1 ? 'good' : data.frictionRate <= 0.25 ? 'warning' : 'danger';
const frictionColor = severityColor(frictionSeverity);
return (
<ReportSection
title="Friction Signals"
icon={MessageSquareWarning}
defaultCollapsed={defaultCollapsed}
>
<div className="mb-4 flex items-center gap-3">
<span
className="rounded px-2 py-0.5 text-xs font-medium"
style={{
backgroundColor: `color-mix(in srgb, ${frictionColor} 12%, transparent)`,
color: frictionColor,
}}
>
Friction Rate: {(data.frictionRate * 100).toFixed(1)}%
</span>
<span className="text-xs text-text-muted">
{data.correctionCount} correction{data.correctionCount !== 1 ? 's' : ''}
</span>
</div>
{data.corrections.length > 0 && (
<div className="mb-4">
<div className="mb-2 text-xs font-medium text-text-muted">Corrections</div>
<div className="flex flex-col gap-1">
{data.corrections.map((corr, idx) => (
<div key={idx} className="flex items-start gap-2 rounded px-2 py-1 text-xs">
<span
className="shrink-0 rounded px-1.5 py-0.5 font-mono text-[10px]"
style={{
backgroundColor: 'color-mix(in srgb, var(--assess-warning) 15%, transparent)',
color: 'var(--assess-warning)',
}}
>
{corr.keyword}
</span>
<span className="truncate text-text-secondary">{corr.preview}</span>
</div>
))}
</div>
</div>
)}
{(thrashing.bashNearDuplicates.length > 0 || thrashing.editReworkFiles.length > 0) && (
<div>
<div className="mb-2 flex items-center gap-2">
<span className="text-xs font-medium text-text-muted">Thrashing Signals</span>
<AssessmentBadge assessment={thrashing.thrashingAssessment} metricKey="thrashing" />
</div>
{thrashing.bashNearDuplicates.length > 0 && (
<div className="mb-2">
<div className="mb-1 text-xs text-text-muted">Repeated Bash Commands</div>
{thrashing.bashNearDuplicates.map((dup, idx) => (
<div key={idx} className="flex items-center gap-2 px-2 py-0.5 text-xs">
<span className="text-text-muted">{dup.count}x</span>
<code className="truncate text-text-secondary">{dup.prefix}</code>
</div>
))}
</div>
)}
{thrashing.editReworkFiles.length > 0 && (
<div>
<div className="mb-1 text-xs text-text-muted">Reworked Files (3+ edits)</div>
{thrashing.editReworkFiles.map((file, idx) => (
<div key={idx} className="flex items-center gap-2 px-2 py-0.5 text-xs">
<span className="text-text-muted">{file.editIndices.length}x</span>
<span className="truncate text-text-secondary">{file.filePath}</span>
</div>
))}
</div>
)}
</div>
)}
</ReportSection>
);
};

View file

@ -0,0 +1,72 @@
import { GitBranch } from 'lucide-react';
import { ReportSection } from '../ReportSection';
import type { ReportGitActivity } from '@renderer/types/sessionReport';
interface GitSectionProps {
data: ReportGitActivity;
defaultCollapsed?: boolean;
}
export const GitSection = ({ data, defaultCollapsed }: GitSectionProps) => {
return (
<ReportSection title="Git Activity" icon={GitBranch} defaultCollapsed={defaultCollapsed}>
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
<div>
<div className="text-xs text-text-muted">Commits</div>
<div className="text-sm font-medium text-text">{data.commitCount}</div>
</div>
<div>
<div className="text-xs text-text-muted">Pushes</div>
<div className="text-sm font-medium text-text">{data.pushCount}</div>
</div>
<div>
<div className="text-xs text-text-muted">Lines Added</div>
<div className="text-sm font-medium" style={{ color: 'var(--assess-good)' }}>
+{data.linesAdded.toLocaleString()}
</div>
</div>
<div>
<div className="text-xs text-text-muted">Lines Removed</div>
<div className="text-sm font-medium" style={{ color: 'var(--assess-danger)' }}>
-{data.linesRemoved.toLocaleString()}
</div>
</div>
</div>
{data.commits.length > 0 && (
<div>
<div className="mb-2 text-xs font-medium text-text-muted">Commits</div>
<div className="flex flex-col gap-1">
{data.commits.map((commit, idx) => (
<div
key={idx}
className="flex items-center gap-2 rounded px-2 py-1 text-xs text-text"
>
<span className="text-text-muted">#{commit.messageIndex}</span>
<span className="truncate">{commit.messagePreview}</span>
</div>
))}
</div>
</div>
)}
{data.branchCreations.length > 0 && (
<div className="mt-3">
<div className="mb-1 text-xs font-medium text-text-muted">Branches Created</div>
<div className="flex flex-wrap gap-1">
{data.branchCreations.map((branch, idx) => (
<span
key={idx}
className="rounded bg-surface px-2 py-0.5 text-xs text-text-secondary"
>
{branch}
</span>
))}
</div>
</div>
)}
</ReportSection>
);
};

View file

@ -0,0 +1,207 @@
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[];
defaultCollapsed?: boolean;
}
export const InsightsSection = ({
skills,
bash,
lifecycleTasks,
userQuestions,
outOfScope,
agentTree,
subagentsList,
defaultCollapsed,
}: InsightsSectionProps) => {
return (
<ReportSection title="Session Insights" icon={Lightbulb} defaultCollapsed={defaultCollapsed}>
{/* 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: 'color-mix(in srgb, var(--assess-warning) 12%, transparent)',
color: 'var(--assess-warning)',
}}
>
{f.keyword}
</span>
<span className="text-xs text-text-secondary">{f.snippet}</span>
</div>
))}
</div>
</div>
)}
</ReportSection>
);
};

View file

@ -0,0 +1,55 @@
import { severityColor } from '@renderer/utils/reportAssessments';
import { AlertTriangle, CheckCircle, ChevronRight, Info, XCircle } from 'lucide-react';
import { sectionId } from '../ReportSection';
import type { Severity, Takeaway } from '@renderer/utils/reportAssessments';
const SEVERITY_ICONS: Record<Severity, React.ComponentType<{ className?: string }>> = {
danger: XCircle,
warning: AlertTriangle,
good: CheckCircle,
neutral: Info,
};
const scrollToSection = (sectionTitle: string) => {
const el = document.getElementById(sectionId(sectionTitle));
if (!el) return;
el.dispatchEvent(new CustomEvent('report-section-expand'));
};
interface KeyTakeawaysSectionProps {
takeaways: Takeaway[];
}
export const KeyTakeawaysSection = ({ takeaways }: KeyTakeawaysSectionProps) => {
return (
<div className="rounded-lg border border-border bg-surface-raised p-4">
<div className="mb-3 text-sm font-semibold text-text">Key Takeaways</div>
<div className="flex flex-col gap-2">
{takeaways.map((t, idx) => {
const Icon = SEVERITY_ICONS[t.severity];
const color = severityColor(t.severity);
return (
<button
key={idx}
type="button"
onClick={() => scrollToSection(t.sectionTitle)}
className="flex w-full items-start gap-3 rounded-md border-l-2 bg-surface px-3 py-2 text-left transition-colors hover:bg-surface-raised"
style={{ borderLeftColor: color }}
>
<span className="mt-0.5 shrink-0" style={{ color }}>
<Icon className="size-4" />
</span>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-text">{t.title}</div>
<div className="text-xs text-text-secondary">{t.detail}</div>
</div>
<ChevronRight className="mt-0.5 size-4 shrink-0 text-text-muted" />
</button>
);
})}
</div>
</div>
);
};

View file

@ -0,0 +1,64 @@
import { assessmentColor } from '@renderer/utils/reportAssessments';
import { Activity } from 'lucide-react';
import { ReportSection } from '../ReportSection';
import type { ReportOverview } from '@renderer/types/sessionReport';
interface OverviewSectionProps {
data: ReportOverview;
}
export const OverviewSection = ({ data }: OverviewSectionProps) => {
return (
<ReportSection title="Overview" icon={Activity}>
<div className="mb-3 truncate text-xs text-text-muted">{data.firstMessage}</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div>
<div className="text-xs text-text-muted">Duration</div>
<div className="text-sm font-medium text-text">{data.durationHuman}</div>
</div>
<div>
<div className="text-xs text-text-muted">Messages</div>
<div className="text-sm font-medium text-text">{data.totalMessages.toLocaleString()}</div>
</div>
<div>
<div className="text-xs text-text-muted">Context Usage</div>
<div
className="text-sm font-medium"
style={{ color: assessmentColor(data.contextAssessment) }}
>
{data.contextConsumptionPct != null ? `${data.contextConsumptionPct}%` : 'N/A'}
{data.contextAssessment && (
<span className="ml-1 text-xs">({data.contextAssessment})</span>
)}
</div>
</div>
<div>
<div className="text-xs text-text-muted">Compactions</div>
<div className="text-sm font-medium text-text">{data.compactionCount}</div>
</div>
<div>
<div className="text-xs text-text-muted">Branch</div>
<div className="truncate text-sm font-medium text-text">{data.gitBranch}</div>
</div>
<div>
<div className="text-xs text-text-muted">Subagents</div>
<div className="text-sm font-medium text-text">{data.hasSubagents ? 'Yes' : 'No'}</div>
</div>
<div>
<div className="text-xs text-text-muted">Project</div>
<div className="truncate text-sm font-medium text-text" title={data.projectPath}>
{data.projectPath}
</div>
</div>
<div>
<div className="text-xs text-text-muted">Session ID</div>
<div className="truncate text-sm font-medium text-text" title={data.sessionId}>
{data.sessionId.slice(0, 12)}...
</div>
</div>
</div>
</ReportSection>
);
};

View file

@ -0,0 +1,153 @@
import { severityColor } from '@renderer/utils/reportAssessments';
import { BarChart3 } from 'lucide-react';
import { AssessmentBadge } from '../AssessmentBadge';
import { ReportSection } from '../ReportSection';
import type {
ReportFileReadRedundancy,
ReportPromptQuality,
ReportStartupOverhead,
ReportTestProgression,
} from '@renderer/types/sessionReport';
interface QualitySectionProps {
prompt: ReportPromptQuality;
startup: ReportStartupOverhead;
testProgression: ReportTestProgression;
fileReadRedundancy: ReportFileReadRedundancy;
defaultCollapsed?: boolean;
}
export const QualitySection = ({
prompt,
startup,
testProgression,
fileReadRedundancy,
defaultCollapsed,
}: QualitySectionProps) => {
return (
<ReportSection title="Quality Signals" icon={BarChart3} defaultCollapsed={defaultCollapsed}>
{/* Prompt quality */}
<div className="mb-4">
<div className="mb-2 text-xs font-medium text-text-muted">Prompt Quality</div>
<div className="mb-2 flex items-center gap-2">
<AssessmentBadge assessment={prompt.assessment} metricKey="promptQuality" />
</div>
<div className="text-xs text-text-secondary">{prompt.note}</div>
<div className="mt-2 grid grid-cols-2 gap-3 sm:grid-cols-4">
<div>
<div className="text-xs text-text-muted">First Message</div>
<div className="text-sm font-medium text-text">
{prompt.firstMessageLengthChars.toLocaleString()} chars
</div>
</div>
<div>
<div className="text-xs text-text-muted">User Messages</div>
<div className="text-sm font-medium text-text">{prompt.userMessageCount}</div>
</div>
<div>
<div className="text-xs text-text-muted">Corrections</div>
<div className="text-sm font-medium text-text">{prompt.correctionCount}</div>
</div>
<div>
<div className="text-xs text-text-muted">Friction Rate</div>
<div className="text-sm font-medium text-text">
{(prompt.frictionRate * 100).toFixed(1)}%
</div>
</div>
</div>
</div>
{/* Startup overhead */}
<div className="mb-4">
<div className="mb-2 flex items-center gap-2">
<span className="text-xs font-medium text-text-muted">Startup Overhead</span>
<AssessmentBadge assessment={startup.overheadAssessment} metricKey="startup" />
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<div>
<div className="text-xs text-text-muted">Messages Before Work</div>
<div className="text-sm font-medium text-text">{startup.messagesBeforeFirstWork}</div>
</div>
<div>
<div className="text-xs text-text-muted">Tokens Before Work</div>
<div className="text-sm font-medium text-text">
{startup.tokensBeforeFirstWork.toLocaleString()}
</div>
</div>
<div>
<div className="text-xs text-text-muted">% of Total</div>
<div className="text-sm font-medium text-text">{startup.pctOfTotal}%</div>
</div>
</div>
</div>
{/* File read redundancy */}
<div className="mb-4">
<div className="mb-2 flex items-center gap-2">
<span className="text-xs font-medium text-text-muted">File Read Redundancy</span>
<AssessmentBadge
assessment={fileReadRedundancy.redundancyAssessment}
metricKey="fileReads"
/>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<div>
<div className="text-xs text-text-muted">Total Reads</div>
<div className="text-sm font-medium text-text">{fileReadRedundancy.totalReads}</div>
</div>
<div>
<div className="text-xs text-text-muted">Unique Files</div>
<div className="text-sm font-medium text-text">{fileReadRedundancy.uniqueFiles}</div>
</div>
<div>
<div className="text-xs text-text-muted">Reads/Unique File</div>
<div className="text-sm font-medium text-text">
{fileReadRedundancy.readsPerUniqueFile}x
</div>
</div>
</div>
</div>
{/* Test progression */}
<div>
<div className="mb-2 text-xs font-medium text-text-muted">Test Progression</div>
<div className="mb-2 flex items-center gap-2">
<AssessmentBadge assessment={testProgression.trajectory} metricKey="testTrajectory" />
<span className="text-xs text-text-muted">
{testProgression.snapshotCount} snapshot{testProgression.snapshotCount !== 1 ? 's' : ''}
</span>
</div>
{testProgression.firstSnapshot && testProgression.lastSnapshot && (
<div className="grid grid-cols-2 gap-3">
<div>
<div className="text-xs text-text-muted">First Run</div>
<div className="text-sm text-text">
<span style={{ color: severityColor('good') }}>
{testProgression.firstSnapshot.passed} passed
</span>
{' / '}
<span style={{ color: severityColor('danger') }}>
{testProgression.firstSnapshot.failed} failed
</span>
</div>
</div>
<div>
<div className="text-xs text-text-muted">Last Run</div>
<div className="text-sm text-text">
<span style={{ color: severityColor('good') }}>
{testProgression.lastSnapshot.passed} passed
</span>
{' / '}
<span style={{ color: severityColor('danger') }}>
{testProgression.lastSnapshot.failed} failed
</span>
</div>
</div>
</div>
)}
</div>
</ReportSection>
);
};

View file

@ -0,0 +1,88 @@
import { severityColor } from '@renderer/utils/reportAssessments';
import { Users } from 'lucide-react';
import { ReportSection } from '../ReportSection';
import type { ReportSubagentMetrics } from '@renderer/types/sessionReport';
const fmtCost = (v: number) => `$${v.toFixed(4)}`;
const fmtDuration = (ms: number) => {
const s = Math.round(ms / 1000);
const m = Math.floor(s / 60);
const sec = s % 60;
return m > 0 ? `${m}m ${sec}s` : `${sec}s`;
};
interface SubagentSectionProps {
data: ReportSubagentMetrics;
defaultCollapsed?: boolean;
}
export const SubagentSection = ({ data, defaultCollapsed }: SubagentSectionProps) => {
return (
<ReportSection title="Subagents" icon={Users} defaultCollapsed={defaultCollapsed}>
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
<div>
<div className="text-xs text-text-muted">Count</div>
<div className="text-sm font-medium text-text">{data.count}</div>
</div>
<div>
<div className="text-xs text-text-muted">Total Tokens</div>
<div className="text-sm font-medium text-text">{data.totalTokens.toLocaleString()}</div>
</div>
<div>
<div className="text-xs text-text-muted">Total Duration</div>
<div className="text-sm font-medium text-text">{fmtDuration(data.totalDurationMs)}</div>
</div>
<div>
<div className="text-xs text-text-muted">Total Cost</div>
<div className="text-sm font-medium text-text">{fmtCost(data.totalCostUsd)}</div>
</div>
</div>
{data.byAgent.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border text-left text-text-muted">
<th className="pb-2 pr-4">Description</th>
<th className="pb-2 pr-4">Type</th>
<th className="pb-2 pr-4 text-right">Tokens</th>
<th className="pb-2 pr-4 text-right">Duration</th>
<th className="pb-2 text-right">Cost</th>
</tr>
</thead>
<tbody>
{data.byAgent.map((agent, idx) => (
<tr key={idx} className="border-border/50 border-b">
<td className="max-w-48 py-1.5 pr-4 text-text">
<div className="truncate" title={agent.description}>
{agent.description}
</div>
{agent.modelMismatch && (
<div
className="mt-0.5 truncate text-[10px]"
style={{ color: severityColor('warning') }}
title={agent.modelMismatch.recommendation}
>
{agent.modelMismatch.recommendation}
</div>
)}
</td>
<td className="py-1.5 pr-4 text-text-secondary">{agent.subagentType}</td>
<td className="py-1.5 pr-4 text-right text-text">
{agent.totalTokens.toLocaleString()}
</td>
<td className="py-1.5 pr-4 text-right text-text">
{fmtDuration(agent.totalDurationMs)}
</td>
<td className="py-1.5 text-right text-text">{fmtCost(agent.costUsd)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</ReportSection>
);
};

View file

@ -0,0 +1,111 @@
import { assessmentColor, assessmentLabel } from '@renderer/utils/reportAssessments';
import { Clock } from 'lucide-react';
import { AssessmentBadge } from '../AssessmentBadge';
import { ReportSection } from '../ReportSection';
import type {
KeyEvent,
ReportIdleAnalysis,
ReportModelSwitches,
} from '@renderer/types/sessionReport';
interface TimelineSectionProps {
idle: ReportIdleAnalysis;
modelSwitches: ReportModelSwitches;
keyEvents: KeyEvent[];
defaultCollapsed?: boolean;
}
export const TimelineSection = ({
idle,
modelSwitches,
keyEvents,
defaultCollapsed,
}: TimelineSectionProps) => {
const idleColor = assessmentColor(idle.idleAssessment);
return (
<ReportSection title="Timeline & Activity" icon={Clock} defaultCollapsed={defaultCollapsed}>
{/* Idle stats */}
<div className="mb-4">
<div className="mb-2 flex items-center gap-2">
<span className="text-xs font-medium text-text-muted">Idle Analysis</span>
<AssessmentBadge assessment={idle.idleAssessment} metricKey="idle" />
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div>
<div className="text-xs text-text-muted">Idle Gaps</div>
<div className="text-sm font-medium text-text">{idle.idleGapCount}</div>
</div>
<div>
<div className="text-xs text-text-muted">Total Idle</div>
<div className="text-sm font-medium text-text">{idle.totalIdleHuman}</div>
</div>
<div>
<div className="text-xs text-text-muted">Active Time</div>
<div className="text-sm font-medium text-text">{idle.activeWorkingHuman}</div>
</div>
<div>
<div className="text-xs text-text-muted">Idle %</div>
<div className="text-sm font-medium" style={{ color: idleColor }}>
{idle.idlePct}%
</div>
</div>
</div>
</div>
{/* Model switches */}
{modelSwitches.count > 0 && (
<div className="mb-4">
<div className="mb-2 flex items-center gap-2">
<span className="text-xs font-medium text-text-muted">
Model Switches ({modelSwitches.count})
</span>
{modelSwitches.switchPattern && (
<span
className="rounded px-2 py-0.5 text-xs font-medium"
style={{
backgroundColor: `${assessmentColor(modelSwitches.switchPattern)}20`,
color: assessmentColor(modelSwitches.switchPattern),
}}
>
{assessmentLabel(modelSwitches.switchPattern)}
</span>
)}
</div>
<div className="flex flex-col gap-1">
{modelSwitches.switches.map((sw, idx) => (
<div key={idx} className="flex items-center gap-2 px-2 py-0.5 text-xs">
<span className="text-text-secondary">{sw.from}</span>
<span className="text-text-muted">&rarr;</span>
<span className="text-text">{sw.to}</span>
<span className="ml-auto text-text-muted">msg #{sw.messageIndex}</span>
</div>
))}
</div>
</div>
)}
{/* Key events */}
{keyEvents.length > 0 && (
<div>
<div className="mb-2 text-xs font-medium text-text-muted">Key Events</div>
<div className="flex flex-col gap-1">
{keyEvents.map((event, idx) => (
<div key={idx} className="flex items-center gap-2 px-2 py-0.5 text-xs">
<span className="shrink-0 text-text-muted">
{event.timestamp.toLocaleTimeString()}
</span>
<span className="truncate text-text">{event.label}</span>
{event.deltaHuman && (
<span className="ml-auto shrink-0 text-text-muted">+{event.deltaHuman}</span>
)}
</div>
))}
</div>
</div>
)}
</ReportSection>
);
};

View file

@ -0,0 +1,116 @@
import { Coins } from 'lucide-react';
import { AssessmentBadge } from '../AssessmentBadge';
import { ReportSection } from '../ReportSection';
import type { ReportCacheEconomics, ReportTokenUsage } from '@renderer/types/sessionReport';
const fmt = (v: number) => v.toLocaleString();
const fmtCost = (v: number) => `$${v.toFixed(4)}`;
interface TokenSectionProps {
data: ReportTokenUsage;
cacheEconomics: ReportCacheEconomics;
defaultCollapsed?: boolean;
}
export const TokenSection = ({ data, cacheEconomics, defaultCollapsed }: TokenSectionProps) => {
const modelEntries = Object.entries(data.byModel).sort((a, b) => b[1].costUsd - a[1].costUsd);
return (
<ReportSection title="Token Usage" icon={Coins} defaultCollapsed={defaultCollapsed}>
{/* By-model table */}
<div className="mb-4 overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border text-left text-text-muted">
<th className="pb-2 pr-4">Model</th>
<th className="pb-2 pr-4 text-right">API Calls</th>
<th className="pb-2 pr-4 text-right">Input</th>
<th className="pb-2 pr-4 text-right">Output</th>
<th className="pb-2 pr-4 text-right">Cache Read</th>
<th className="pb-2 pr-4 text-right">Cache Create</th>
<th className="pb-2 text-right">Cost</th>
</tr>
</thead>
<tbody>
{modelEntries.map(([model, stats]) => (
<tr key={model} className="border-border/50 border-b">
<td className="py-1.5 pr-4 text-text">{model}</td>
<td className="py-1.5 pr-4 text-right text-text">{fmt(stats.apiCalls)}</td>
<td className="py-1.5 pr-4 text-right text-text">{fmt(stats.inputTokens)}</td>
<td className="py-1.5 pr-4 text-right text-text">{fmt(stats.outputTokens)}</td>
<td className="py-1.5 pr-4 text-right text-text">{fmt(stats.cacheRead)}</td>
<td className="py-1.5 pr-4 text-right text-text">{fmt(stats.cacheCreation)}</td>
<td className="py-1.5 text-right text-text">{fmtCost(stats.costUsd)}</td>
</tr>
))}
{/* Totals row */}
<tr className="border-t border-border font-medium">
<td className="py-1.5 pr-4 text-text">Total</td>
<td className="py-1.5 pr-4 text-right text-text">
{fmt(modelEntries.reduce((s, [, st]) => s + st.apiCalls, 0))}
</td>
<td className="py-1.5 pr-4 text-right text-text">{fmt(data.totals.inputTokens)}</td>
<td className="py-1.5 pr-4 text-right text-text">{fmt(data.totals.outputTokens)}</td>
<td className="py-1.5 pr-4 text-right text-text">{fmt(data.totals.cacheRead)}</td>
<td className="py-1.5 pr-4 text-right text-text">{fmt(data.totals.cacheCreation)}</td>
<td className="py-1.5 text-right text-text">
{fmtCost(modelEntries.reduce((s, [, st]) => s + st.costUsd, 0))}
</td>
</tr>
</tbody>
</table>
</div>
{/* Cache economics */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div>
<div className="text-xs text-text-muted">Cache Efficiency</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-text">
{cacheEconomics.cacheEfficiencyPct}%
</span>
{cacheEconomics.cacheEfficiencyAssessment && (
<AssessmentBadge
assessment={cacheEconomics.cacheEfficiencyAssessment}
metricKey="cacheEfficiency"
/>
)}
</div>
</div>
<div>
<div className="text-xs text-text-muted">R/W Ratio</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-text">
{cacheEconomics.cacheReadToWriteRatio}x
</span>
{cacheEconomics.cacheRatioAssessment && (
<AssessmentBadge
assessment={cacheEconomics.cacheRatioAssessment}
metricKey="cacheRatio"
/>
)}
</div>
</div>
<div>
<div className="text-xs text-text-muted">Cache Read %</div>
<div className="text-sm font-medium text-text">{data.totals.cacheReadPct}%</div>
</div>
<div>
<div className="text-xs text-text-muted">Cold Start</div>
<div
className="text-sm font-medium"
style={{
color: cacheEconomics.coldStartDetected
? 'var(--assess-warning)'
: 'var(--assess-good)',
}}
>
{cacheEconomics.coldStartDetected ? 'Yes' : 'No'}
</div>
</div>
</div>
</ReportSection>
);
};

View file

@ -0,0 +1,77 @@
import { assessmentColor } from '@renderer/utils/reportAssessments';
import { Wrench } from 'lucide-react';
import { AssessmentBadge } from '../AssessmentBadge';
import { ReportSection, sectionId } from '../ReportSection';
import type { ReportToolUsage } from '@renderer/types/sessionReport';
interface ToolSectionProps {
data: ReportToolUsage;
defaultCollapsed?: boolean;
}
export const ToolSection = ({ data, defaultCollapsed }: ToolSectionProps) => {
const toolEntries = Object.entries(data.successRates).sort(
(a, b) => b[1].totalCalls - a[1].totalCalls
);
return (
<ReportSection title="Tool Usage" icon={Wrench} defaultCollapsed={defaultCollapsed}>
<div className="mb-2 flex items-center gap-2">
<span className="text-xs text-text-muted">
{data.totalCalls.toLocaleString()} total calls across {toolEntries.length} tools
</span>
<AssessmentBadge assessment={data.overallToolHealth} metricKey="toolHealth" />
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border text-left text-text-muted">
<th className="pb-2 pr-4">Tool</th>
<th className="pb-2 pr-4 text-right">Calls</th>
<th className="pb-2 pr-4 text-right">Errors</th>
<th className="pb-2 pr-4 text-right">Success %</th>
<th className="pb-2 text-right">Health</th>
</tr>
</thead>
<tbody>
{toolEntries.map(([tool, stats]) => {
const color = assessmentColor(stats.assessment);
return (
<tr key={tool} className="border-border/50 border-b">
<td className="py-1.5 pr-4 text-text">{tool}</td>
<td className="py-1.5 pr-4 text-right text-text">
{stats.totalCalls.toLocaleString()}
</td>
<td className="py-1.5 pr-4 text-right text-text">
{stats.errors > 0 ? (
<button
type="button"
onClick={() => {
const el = document.getElementById(sectionId('Errors'));
if (el) el.dispatchEvent(new CustomEvent('report-section-expand'));
}}
className="text-red-400 underline decoration-red-400/30 underline-offset-2 hover:decoration-red-400"
>
{stats.errors.toLocaleString()}
</button>
) : (
stats.errors.toLocaleString()
)}
</td>
<td className="py-1.5 pr-4 text-right" style={{ color }}>
{stats.successRatePct}%
</td>
<td className="py-1.5 text-right">
<AssessmentBadge assessment={stats.assessment} metricKey="toolHealth" />
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</ReportSection>
);
};

View file

@ -31,6 +31,8 @@ export interface SafeConfig {
defaultTab: 'dashboard' | 'last-session';
claudeRootPath: string | null;
agentLanguage: string;
autoExpandAIGroups: boolean;
useNativeTitleBar: boolean;
};
notifications: {
enabled: boolean;
@ -156,6 +158,8 @@ export function useSettingsConfig(): UseSettingsConfigReturn {
defaultTab: displayConfig?.general?.defaultTab ?? 'dashboard',
claudeRootPath: displayConfig?.general?.claudeRootPath ?? null,
agentLanguage: displayConfig?.general?.agentLanguage ?? 'system',
autoExpandAIGroups: displayConfig?.general?.autoExpandAIGroups ?? false,
useNativeTitleBar: displayConfig?.general?.useNativeTitleBar ?? false,
},
notifications: {
enabled: displayConfig?.notifications?.enabled ?? true,

View file

@ -296,6 +296,8 @@ export function useSettingsHandlers({
defaultTab: 'dashboard',
claudeRootPath: null,
agentLanguage: 'system',
autoExpandAIGroups: false,
useNativeTitleBar: false,
},
display: {
showTimestamps: true,

View file

@ -17,6 +17,7 @@ import { SettingRow, SettingsSectionHeader, SettingsSelect, SettingsToggle } fro
import type { SafeConfig } from '../hooks/useSettingsConfig';
import type { ClaudeRootInfo, WslClaudeRootCandidate } from '@shared/types';
import type { HttpServerStatus } from '@shared/types/api';
import type { AppConfig } from '@shared/types/notifications';
// Theme options
const THEME_OPTIONS = [
@ -28,7 +29,7 @@ const THEME_OPTIONS = [
interface GeneralSectionProps {
readonly safeConfig: SafeConfig;
readonly saving: boolean;
readonly onGeneralToggle: (key: 'launchAtLogin' | 'showDockIcon', value: boolean) => void;
readonly onGeneralToggle: (key: keyof AppConfig['general'], value: boolean) => void;
readonly onThemeChange: (value: 'dark' | 'light' | 'system') => void;
readonly onLanguageChange: (value: string) => void;
}
@ -341,6 +342,41 @@ export const GeneralSection = ({
disabled={saving}
/>
</SettingRow>
<SettingRow
label="Expand AI responses by default"
description="Automatically expand each response turn when opening a transcript or receiving a new message"
>
<SettingsToggle
enabled={safeConfig.general.autoExpandAIGroups ?? false}
onChange={(v) => onGeneralToggle('autoExpandAIGroups', v)}
disabled={saving}
/>
</SettingRow>
{isElectron && !window.navigator.userAgent.includes('Macintosh') && (
<SettingRow
label="Use native title bar"
description="Use the default system window frame instead of the custom title bar"
>
<SettingsToggle
enabled={safeConfig.general.useNativeTitleBar}
onChange={async (v) => {
const shouldRelaunch = await confirm({
title: 'Restart required',
message: 'The app needs to restart to apply the title bar change. Restart now?',
confirmLabel: 'Restart',
});
if (shouldRelaunch) {
onGeneralToggle('useNativeTitleBar', v);
// Small delay to let config persist before relaunch
setTimeout(() => {
void window.electronAPI?.windowControls?.relaunch();
}, 200);
}
}}
disabled={saving}
/>
</SettingRow>
)}
{isElectron && (
<>

View file

@ -7,6 +7,7 @@
import { useEffect, useRef, useState } from 'react';
import { MAX_PANES } from '@renderer/types/panes';
import { formatShortcut } from '@renderer/utils/stringUtils';
import { Check, ClipboardCopy, Eye, EyeOff, Pin, PinOff, Terminal } from 'lucide-react';
interface SessionContextMenuProps {
@ -98,7 +99,11 @@ export const SessionContextMenu = ({
}}
>
<MenuItem label="Open in Current Pane" onClick={handleClick(onOpenInCurrentPane)} />
<MenuItem label="Open in New Tab" shortcut="⌘ Click" onClick={handleClick(onOpenInNewTab)} />
<MenuItem
label="Open in New Tab"
shortcut={`${formatShortcut('')}Click`}
onClick={handleClick(onOpenInNewTab)}
/>
<div className="mx-2 my-1 border-t" style={{ borderColor: 'var(--color-border)' }} />
<MenuItem
label="Split Right and Open"

View file

@ -138,6 +138,8 @@ export function useAutoScrollBottom(
const disabledRef = useRef(disabled);
// Track resetKey to detect changes
const prevResetKeyRef = useRef(resetKey);
// Set true when resetKey changes; consumed by the content effect to force scroll on first load
const needsInitialScrollRef = useRef(false);
/**
* Check if the scroll container is at the bottom.
@ -223,34 +225,47 @@ export function useAutoScrollBottom(
disabledRef.current = disabled;
}, [disabled]);
// Reset isAtBottom state when resetKey changes (e.g., tab/session switch)
// This ensures new content will auto-scroll to bottom
// Reset isAtBottom state when resetKey changes (e.g., tab/session switch).
// Sets needsInitialScrollRef so the content effect scrolls to bottom on first load.
useEffect(() => {
if (resetKey !== prevResetKeyRef.current) {
isAtBottomRef.current = true;
wasAtBottomBeforeUpdateRef.current = true;
prevResetKeyRef.current = resetKey;
needsInitialScrollRef.current = true;
}
}, [resetKey]);
/**
* After content updates (dependencies change), scroll to bottom if we were at bottom.
* After content updates (dependencies change), scroll to bottom if:
* - User was already near the bottom before the update, OR
* - This is the first load after a tab/session switch (needsInitialScrollRef)
* Uses double-RAF + cleanup so React StrictMode's double-invoke doesn't fire twice.
*/
useEffect(() => {
// Skip if disabled (e.g., during navigation) or not enabled
if (!enabled || disabled) return;
// Use requestAnimationFrame to ensure DOM has updated
requestAnimationFrame(() => {
// Re-check disabled state inside RAF - it might have changed between effect and callback
// This prevents auto-scroll from firing if navigation started after the effect ran
if (disabledRef.current) return;
let id1 = 0;
let id2 = 0;
// Only auto-scroll if user was at bottom before the update
if (wasAtBottomBeforeUpdateRef.current) {
scrollToBottom(autoBehavior);
}
id1 = requestAnimationFrame(() => {
id2 = requestAnimationFrame(() => {
// Re-check disabled state — navigation may have started between effect and RAF
if (disabledRef.current) return;
const shouldScroll = needsInitialScrollRef.current || wasAtBottomBeforeUpdateRef.current;
if (shouldScroll) {
needsInitialScrollRef.current = false;
scrollToBottom(autoBehavior);
}
});
});
return () => {
cancelAnimationFrame(id1);
cancelAnimationFrame(id2);
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- Dynamic dependencies array is intentional design
}, [...dependencies, enabled, disabled, autoBehavior, scrollToBottom]);

View file

@ -181,6 +181,12 @@
--card-text-lighter: #e2e8f0;
--card-separator: #2a2c38;
/* Assessment severity colors (badges, health indicators) */
--assess-good: #4ade80;
--assess-warning: #fbbf24;
--assess-danger: #f87171;
--assess-neutral: #a1a1aa;
/* Sticky Context button — indigo glass */
--context-btn-bg: rgba(148, 163, 184, 0.08);
--context-btn-bg-hover: rgba(148, 163, 184, 0.14);
@ -206,6 +212,12 @@
--color-text-secondary: #4d4b46; /* Warm secondary text */
--color-text-muted: #6d6b65; /* Warm muted text */
/* Assessment severity colors - darker for light backgrounds */
--assess-good: #16a34a;
--assess-warning: #d97706;
--assess-danger: #dc2626;
--assess-neutral: #57534e;
/* Scrollbar colors for light mode */
--scrollbar-thumb: rgba(0, 0, 0, 0.15);
--scrollbar-thumb-hover: rgba(0, 0, 0, 0.28);

View file

@ -416,6 +416,15 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
sessionPhaseInfo: phaseInfo,
});
// Auto-expand all AI groups if the setting is enabled
if (tabId && conversation?.items && get().appConfig?.general?.autoExpandAIGroups) {
for (const item of conversation.items) {
if (item.type === 'ai') {
get().expandAIGroupForTab(tabId, item.group.id);
}
}
}
// Store per-tab session data
if (tabId) {
const prev = get().tabSessionData;
@ -554,6 +563,14 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
}
}
// Snapshot existing AI group IDs before overwriting state, so the
// auto-expand diff below can correctly identify which groups are new.
const prevGroupIds = new Set(
(latestState.conversation?.items ?? [])
.filter((item) => item.type === 'ai')
.map((item) => (item as { type: 'ai'; group: { id: string } }).group.id)
);
// Update only the data, preserve UI states
set((state) => ({
sessionDetail: detail,
@ -572,6 +589,29 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
// so expansion states are preserved
}));
// Auto-expand newly arrived AI groups if the setting is enabled.
// Uses prevGroupIds snapshotted before set() so the diff is accurate.
if (get().appConfig?.general?.autoExpandAIGroups) {
const oldGroupIds = prevGroupIds;
const newGroupIds = newConversation.items
.filter(
(item) =>
item.type === 'ai' &&
!oldGroupIds.has((item as { type: 'ai'; group: { id: string } }).group.id)
)
.map((item) => (item as { type: 'ai'; group: { id: string } }).group.id);
if (newGroupIds.length > 0) {
for (const tab of latestAllTabs) {
if (tab.type === 'session' && tab.sessionId === sessionId) {
for (const groupId of newGroupIds) {
get().expandAIGroupForTab(tab.id, groupId);
}
}
}
}
}
// Also update per-tab session data for all tabs viewing this session
const latestTabSessionData = { ...get().tabSessionData };
for (const tab of latestAllTabs) {

View file

@ -51,6 +51,7 @@ export interface TabSlice {
closeTab: (tabId: string) => void;
setActiveTab: (tabId: string) => void;
openDashboard: () => void;
openSessionReport: (sourceTabId: string) => void;
getActiveTab: () => Tab | null;
isSessionOpen: (sessionId: string) => boolean;
enqueueTabNavigation: (tabId: string, request: TabNavigationRequest) => void;
@ -423,6 +424,28 @@ export const createTabSlice: StateCreator<AppState, [], [], TabSlice> = (set, ge
set(syncFromLayout(newLayout));
},
// Open a session report tab based on a source session tab
openSessionReport: (sourceTabId: string) => {
const state = get();
const allTabs = getAllTabs(state.paneLayout);
const sourceTab = allTabs.find((t) => t.id === sourceTabId);
if (sourceTab?.type !== 'session') return;
if (!sourceTab.sessionId || !sourceTab.projectId) return;
const tabData = state.tabSessionData[sourceTabId];
const firstMsg = tabData?.sessionDetail?.session.firstMessage;
const label = firstMsg
? `Report: ${firstMsg.slice(0, 30)}${firstMsg.length > 30 ? '…' : ''}`
: 'Session Report';
state.openTab({
type: 'report',
label,
projectId: sourceTab.projectId,
sessionId: sourceTab.sessionId,
});
},
// Get the currently active tab (from the focused pane)
getActiveTab: () => {
const state = get();

View file

@ -0,0 +1,386 @@
/**
* Session analysis report types.
* Output of analyzeSession() one interface per report section.
*/
import type {
CacheAssessment,
CostAssessment,
IdleAssessment,
ModelMismatch,
OverheadAssessment,
RedundancyAssessment,
SubagentCostShareAssessment,
SwitchPattern,
ThrashingAssessment,
ToolHealthAssessment,
} from '@renderer/utils/reportAssessments';
// =============================================================================
// Pricing
// =============================================================================
export type { DisplayPricing as ModelPricing } from '@shared/utils/pricing';
// =============================================================================
// Report Sections
// =============================================================================
export interface ReportOverview {
sessionId: string;
projectId: string;
projectPath: string;
firstMessage: string;
messageCount: number;
hasSubagents: boolean;
contextConsumption: number;
contextConsumptionPct: number | null;
contextAssessment: 'critical' | 'high' | 'moderate' | 'healthy' | null;
compactionCount: number;
gitBranch: string;
startTime: Date | null;
endTime: Date | null;
durationSeconds: number;
durationHuman: string;
totalMessages: number;
}
export interface ModelTokenStats {
apiCalls: number;
inputTokens: number;
outputTokens: number;
cacheCreation: number;
cacheRead: number;
costUsd: number;
}
export interface TokenTotals {
inputTokens: number;
outputTokens: number;
cacheCreation: number;
cacheRead: number;
grandTotal: number;
cacheReadPct: number;
}
export interface ReportTokenUsage {
byModel: Record<string, ModelTokenStats>;
totals: TokenTotals;
}
export interface ReportCostAnalysis {
parentCostUsd: number;
subagentCostUsd: number;
totalSessionCostUsd: number;
costByModel: Record<string, number>;
costPerCommit: number | null;
costPerLineChanged: number | null;
costPerCommitAssessment: CostAssessment | null;
costPerLineAssessment: CostAssessment | null;
subagentCostSharePct: number | null;
subagentCostShareAssessment: SubagentCostShareAssessment | null;
}
export interface ReportCacheEconomics {
cacheRead: number;
cacheEfficiencyPct: number;
coldStartDetected: boolean;
cacheReadToWriteRatio: number;
cacheEfficiencyAssessment: CacheAssessment | null;
cacheRatioAssessment: CacheAssessment | null;
}
export interface ToolSuccessRate {
totalCalls: number;
errors: number;
successRatePct: number;
assessment: ToolHealthAssessment;
}
export interface ReportToolUsage {
counts: Record<string, number>;
totalCalls: number;
successRates: Record<string, ToolSuccessRate>;
overallToolHealth: ToolHealthAssessment;
}
export interface SubagentEntry {
description: string;
subagentType: string;
model: string;
totalTokens: number;
totalDurationMs: number;
totalToolUseCount: number;
costUsd: number;
costNote?: string;
modelMismatch: ModelMismatch | null;
}
export interface ReportSubagentMetrics {
count: number;
totalTokens: number;
totalDurationMs: number;
totalToolUseCount: number;
totalCostUsd: number;
byAgent: SubagentEntry[];
}
export interface ToolError {
tool: string;
inputPreview: string;
error: string;
messageIndex: number;
isPermissionDenial: boolean;
}
export interface ReportErrors {
errors: ToolError[];
permissionDenials: {
count: number;
denials: ToolError[];
affectedTools: string[];
};
}
export interface GitCommit {
messagePreview: string;
messageIndex: number;
}
export interface ReportGitActivity {
commitCount: number;
commits: GitCommit[];
pushCount: number;
branchCreations: string[];
linesAdded: number;
linesRemoved: number;
linesChanged: number;
}
export interface FrictionCorrection {
messageIndex: number;
keyword: string;
preview: string;
}
export interface ReportFrictionSignals {
correctionCount: number;
corrections: FrictionCorrection[];
frictionRate: number;
}
export interface ReportThrashingSignals {
bashNearDuplicates: { prefix: string; count: number }[];
editReworkFiles: { filePath: string; editIndices: number[] }[];
thrashingAssessment: ThrashingAssessment;
}
export interface ReportConversationTree {
totalNodes: number;
maxDepth: number;
sidechainCount: number;
branchPoints: number;
branchDetails: {
parentUuid: string;
childCount: number;
parentMessageIndex: number | undefined;
}[];
}
export interface IdleGap {
gapSeconds: number;
gapHuman: string;
afterMessageIndex: number;
}
export interface ReportIdleAnalysis {
idleThresholdSeconds: number;
idleGapCount: number;
totalIdleSeconds: number;
totalIdleHuman: string;
wallClockSeconds: number;
activeWorkingSeconds: number;
activeWorkingHuman: string;
idlePct: number;
longestGaps: IdleGap[];
idleAssessment: IdleAssessment;
}
export interface ModelSwitch {
from: string;
to: string;
messageIndex: number;
timestamp: Date | null;
}
export interface ReportModelSwitches {
count: number;
switches: ModelSwitch[];
modelsUsed: string[];
switchPattern: SwitchPattern | null;
}
export interface ReportWorkingDirectories {
uniqueDirectories: string[];
directoryCount: number;
changes: { from: string; to: string; messageIndex: number }[];
changeCount: number;
isMultiDirectory: boolean;
}
export interface TestSnapshot {
messageIndex: number;
passed: number;
failed: number;
total: number;
raw: string;
}
export interface ReportTestProgression {
snapshotCount: number;
snapshots: TestSnapshot[];
trajectory: 'improving' | 'regressing' | 'stable' | 'insufficient_data';
firstSnapshot: TestSnapshot | null;
lastSnapshot: TestSnapshot | null;
}
export interface ReportStartupOverhead {
messagesBeforeFirstWork: number;
tokensBeforeFirstWork: number;
pctOfTotal: number;
overheadAssessment: OverheadAssessment;
}
export interface ReportTokenDensityTimeline {
quartiles: { q: number; avgTokens: number; messageCount: number }[];
}
export interface ReportPromptQuality {
firstMessageLengthChars: number;
userMessageCount: number;
correctionCount: number;
frictionRate: number;
assessment: 'underspecified' | 'verbose_but_unclear' | 'well_specified' | 'moderate_friction';
note: string;
}
export interface ThinkingBlockAnalysis {
messageIndex: number;
preview: string;
charLength: number;
signals: Record<string, boolean>;
}
export interface ReportThinkingBlocks {
count: number;
analyzedCount: number;
signalSummary: Record<string, number>;
notableBlocks: ThinkingBlockAnalysis[];
}
export interface KeyEvent {
timestamp: Date;
label: string;
deltaSeconds?: number;
deltaHuman?: string;
}
export interface ReportFileReadRedundancy {
totalReads: number;
uniqueFiles: number;
readsPerUniqueFile: number;
redundantFiles: Record<string, number>;
redundancyAssessment: RedundancyAssessment;
}
// =============================================================================
// 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
// =============================================================================
export interface SessionReport {
overview: ReportOverview;
tokenUsage: ReportTokenUsage;
costAnalysis: ReportCostAnalysis;
cacheEconomics: ReportCacheEconomics;
toolUsage: ReportToolUsage;
subagentMetrics: ReportSubagentMetrics;
subagentsList: SubagentBasicEntry[];
errors: ReportErrors;
gitActivity: ReportGitActivity;
frictionSignals: ReportFrictionSignals;
thrashingSignals: ReportThrashingSignals;
conversationTree: ReportConversationTree;
idleAnalysis: ReportIdleAnalysis;
modelSwitches: ReportModelSwitches;
workingDirectories: ReportWorkingDirectories;
testProgression: ReportTestProgression;
startupOverhead: ReportStartupOverhead;
tokenDensityTimeline: ReportTokenDensityTimeline;
promptQuality: ReportPromptQuality;
thinkingBlocks: ReportThinkingBlocks;
keyEvents: KeyEvent[];
messageTypes: Record<string, number>;
fileReadRedundancy: ReportFileReadRedundancy;
compaction: ReportCompaction;
gitBranches: string[];
skillsInvoked: SkillInvocation[];
bashCommands: ReportBashCommands;
lifecycleTasks: string[];
userQuestions: UserQuestion[];
outOfScopeFindings: OutOfScopeFindings[];
agentTree: ReportAgentTree;
}

View file

@ -76,7 +76,7 @@ export interface Tab {
id: string;
/** Type of content displayed in this tab */
type: 'session' | 'dashboard' | 'notifications' | 'settings' | 'teams' | 'team';
type: 'session' | 'dashboard' | 'notifications' | 'settings' | 'teams' | 'team' | 'report';
/** Session ID (required when type === 'session') */
sessionId?: string;

View file

@ -0,0 +1,555 @@
/**
* Centralized assessment severity/color utilities for session reports.
*
* Maps raw assessment values to severity levels and colors,
* replacing duplicated assessmentColor() functions across report sections.
*/
// =============================================================================
// Types
// =============================================================================
export type Severity = 'good' | 'warning' | 'danger' | 'neutral';
// =============================================================================
// Colors
// =============================================================================
const SEVERITY_CSS_VAR: Record<Severity, string> = {
good: '--assess-good',
warning: '--assess-warning',
danger: '--assess-danger',
neutral: '--assess-neutral',
};
const SEVERITY_FALLBACKS: Record<Severity, string> = {
good: '#4ade80',
warning: '#fbbf24',
danger: '#f87171',
neutral: '#a1a1aa',
};
export function severityColor(severity: Severity): string {
if (typeof document === 'undefined') return SEVERITY_FALLBACKS[severity];
const value = getComputedStyle(document.documentElement)
.getPropertyValue(SEVERITY_CSS_VAR[severity])
.trim();
return value || SEVERITY_FALLBACKS[severity];
}
// =============================================================================
// Assessment → Severity Mapping
// =============================================================================
const ASSESSMENT_SEVERITY: Record<string, Severity> = {
// Context
healthy: 'good',
moderate: 'warning',
high: 'danger',
critical: 'danger',
// Cost / subagent share
efficient: 'good',
normal: 'good',
expensive: 'warning',
red_flag: 'danger',
very_high: 'danger',
// Cache
good: 'good',
concerning: 'warning',
// Tool health
degraded: 'warning',
unreliable: 'danger',
// Idle ('moderate' already mapped above under Context)
high_idle: 'danger',
// File read
wasteful: 'warning',
// Startup
heavy: 'warning',
// Thrashing
none: 'good',
mild: 'warning',
severe: 'danger',
// Prompt quality
well_specified: 'good',
moderate_friction: 'warning',
underspecified: 'danger',
verbose_but_unclear: 'danger',
// Test trajectory
improving: 'good',
stable: 'warning',
regressing: 'danger',
insufficient_data: 'neutral',
// Model switch
opus_plan_mode: 'good',
manual_switch: 'neutral',
};
export function assessmentSeverity(assessment: string | null | undefined): Severity {
if (!assessment) return 'neutral';
return ASSESSMENT_SEVERITY[assessment] ?? 'neutral';
}
export function assessmentColor(assessment: string | null | undefined): string {
return severityColor(assessmentSeverity(assessment));
}
// =============================================================================
// Label Formatting
// =============================================================================
export function assessmentLabel(value: string): string {
return value
.split('_')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
}
// =============================================================================
// Threshold Constants
// =============================================================================
export const THRESHOLDS = {
costPerCommit: {
efficient: 0.5,
normal: 2,
expensive: 5,
},
costPerLine: {
efficient: 0.01,
normal: 0.05,
expensive: 0.2,
},
subagentCostShare: {
normal: 30,
high: 60,
veryHigh: 80,
},
cacheEfficiency: {
good: 95,
},
cacheRwRatio: {
good: 20,
},
toolSuccess: {
healthy: 95,
degraded: 80,
},
idle: {
efficient: 20,
moderate: 50,
},
fileReadsPerUnique: {
normal: 2.0,
},
startupOverhead: {
normal: 5,
},
} as const;
// =============================================================================
// Metric Keys & Explanations
// =============================================================================
export type MetricKey =
| 'costPerCommit'
| 'costPerLine'
| 'subagentCostShare'
| 'cacheEfficiency'
| 'cacheRatio'
| 'toolHealth'
| 'idle'
| 'fileReads'
| 'startup'
| 'thrashing'
| 'promptQuality'
| 'testTrajectory';
const EXPLANATIONS: Record<string, Record<string, string>> = {
costPerCommit: {
efficient: `Under $${THRESHOLDS.costPerCommit.efficient}/commit`,
normal: `$${THRESHOLDS.costPerCommit.efficient}\u2013$${THRESHOLDS.costPerCommit.normal}/commit`,
expensive: `$${THRESHOLDS.costPerCommit.normal}\u2013$${THRESHOLDS.costPerCommit.expensive}/commit`,
red_flag: `Over $${THRESHOLDS.costPerCommit.expensive}/commit`,
},
costPerLine: {
efficient: `Under $${THRESHOLDS.costPerLine.efficient}/line`,
normal: `$${THRESHOLDS.costPerLine.efficient}\u2013$${THRESHOLDS.costPerLine.normal}/line`,
expensive: `$${THRESHOLDS.costPerLine.normal}\u2013$${THRESHOLDS.costPerLine.expensive}/line`,
red_flag: `Over $${THRESHOLDS.costPerLine.expensive}/line`,
},
subagentCostShare: {
normal: `Under ${THRESHOLDS.subagentCostShare.normal}% of total cost`,
high: `${THRESHOLDS.subagentCostShare.normal}\u2013${THRESHOLDS.subagentCostShare.high}% of total cost`,
very_high: `${THRESHOLDS.subagentCostShare.high}\u2013${THRESHOLDS.subagentCostShare.veryHigh}% of total cost`,
red_flag: `Over ${THRESHOLDS.subagentCostShare.veryHigh}% of total cost`,
},
cacheEfficiency: {
good: `${THRESHOLDS.cacheEfficiency.good}%+ cache hit rate`,
concerning: `Below ${THRESHOLDS.cacheEfficiency.good}% cache hit rate`,
},
cacheRatio: {
good: `${THRESHOLDS.cacheRwRatio.good}x+ read-to-write ratio`,
concerning: `Below ${THRESHOLDS.cacheRwRatio.good}x read-to-write ratio`,
},
toolHealth: {
healthy: `Over ${THRESHOLDS.toolSuccess.healthy}% success rate`,
degraded: `${THRESHOLDS.toolSuccess.degraded}\u2013${THRESHOLDS.toolSuccess.healthy}% success rate`,
unreliable: `Below ${THRESHOLDS.toolSuccess.degraded}% success rate`,
},
idle: {
efficient: `Under ${THRESHOLDS.idle.efficient}% idle time`,
moderate: `${THRESHOLDS.idle.efficient}\u2013${THRESHOLDS.idle.moderate}% idle time`,
high_idle: `Over ${THRESHOLDS.idle.moderate}% idle time`,
},
fileReads: {
normal: `${THRESHOLDS.fileReadsPerUnique.normal}x or fewer reads per unique file`,
wasteful: `Over ${THRESHOLDS.fileReadsPerUnique.normal}x reads per unique file`,
},
startup: {
normal: `${THRESHOLDS.startupOverhead.normal}% or less of tokens before first work`,
heavy: `Over ${THRESHOLDS.startupOverhead.normal}% of tokens before first work`,
},
thrashing: {
none: 'No repeated commands or reworked files',
mild: '1\u20132 thrashing signals detected',
severe: '3+ thrashing signals detected',
},
promptQuality: {
well_specified: 'Clear first message with low friction rate',
moderate_friction: 'Some corrections needed mid-session',
underspecified: 'Short initial prompt led to many corrections',
verbose_but_unclear: 'Long initial prompt but still high friction',
},
testTrajectory: {
improving: 'Test failures decreased over the session',
stable: 'Test results stayed roughly the same',
regressing: 'Test failures increased over the session',
insufficient_data: 'Not enough test runs to determine trend',
},
};
export function assessmentExplanation(metricKey: MetricKey, assessment: string): string {
return EXPLANATIONS[metricKey]?.[assessment] ?? '';
}
// =============================================================================
// Assessment Computers
// =============================================================================
export type CostAssessment = 'efficient' | 'normal' | 'expensive' | 'red_flag';
export type CacheAssessment = 'good' | 'concerning';
export type ToolHealthAssessment = 'healthy' | 'degraded' | 'unreliable';
export type IdleAssessment = 'efficient' | 'moderate' | 'high_idle';
export type RedundancyAssessment = 'normal' | 'wasteful';
export type OverheadAssessment = 'normal' | 'heavy';
export type ThrashingAssessment = 'none' | 'mild' | 'severe';
export type SubagentCostShareAssessment = 'normal' | 'high' | 'very_high' | 'red_flag';
export type SwitchPattern = 'opus_plan_mode' | 'manual_switch' | 'none';
export function computeCostPerCommitAssessment(costPerCommit: number): CostAssessment {
if (costPerCommit < THRESHOLDS.costPerCommit.efficient) return 'efficient';
if (costPerCommit < THRESHOLDS.costPerCommit.normal) return 'normal';
if (costPerCommit < THRESHOLDS.costPerCommit.expensive) return 'expensive';
return 'red_flag';
}
export function computeCostPerLineAssessment(costPerLine: number): CostAssessment {
if (costPerLine < THRESHOLDS.costPerLine.efficient) return 'efficient';
if (costPerLine < THRESHOLDS.costPerLine.normal) return 'normal';
if (costPerLine < THRESHOLDS.costPerLine.expensive) return 'expensive';
return 'red_flag';
}
export function computeSubagentCostShareAssessment(pct: number): SubagentCostShareAssessment {
if (pct < THRESHOLDS.subagentCostShare.normal) return 'normal';
if (pct < THRESHOLDS.subagentCostShare.high) return 'high';
if (pct < THRESHOLDS.subagentCostShare.veryHigh) return 'very_high';
return 'red_flag';
}
export function computeCacheEfficiencyAssessment(pct: number): CacheAssessment {
return pct >= THRESHOLDS.cacheEfficiency.good ? 'good' : 'concerning';
}
export function computeCacheRatioAssessment(ratio: number): CacheAssessment {
return ratio >= THRESHOLDS.cacheRwRatio.good ? 'good' : 'concerning';
}
export function computeToolHealthAssessment(successPct: number): ToolHealthAssessment {
if (successPct > THRESHOLDS.toolSuccess.healthy) return 'healthy';
if (successPct >= THRESHOLDS.toolSuccess.degraded) return 'degraded';
return 'unreliable';
}
export function computeIdleAssessment(idlePct: number): IdleAssessment {
if (idlePct < THRESHOLDS.idle.efficient) return 'efficient';
if (idlePct < THRESHOLDS.idle.moderate) return 'moderate';
return 'high_idle';
}
export function computeRedundancyAssessment(readsPerUnique: number): RedundancyAssessment {
return readsPerUnique <= THRESHOLDS.fileReadsPerUnique.normal ? 'normal' : 'wasteful';
}
export function computeOverheadAssessment(pctOfTotal: number): OverheadAssessment {
return pctOfTotal <= THRESHOLDS.startupOverhead.normal ? 'normal' : 'heavy';
}
export function computeThrashingAssessment(signalCount: number): ThrashingAssessment {
if (signalCount === 0) return 'none';
if (signalCount <= 2) return 'mild';
return 'severe';
}
export interface ModelMismatch {
description: string;
expectedComplexity: 'mechanical' | 'read_only';
recommendation: string;
}
const MECHANICAL_PATTERNS = /\b(rename|move|lint|format|delete|remove|copy|replace)\b/i;
const READ_ONLY_PATTERNS = /\b(explore|search|find|verify|check|scan|discover|list|read)\b/i;
export function detectModelMismatch(description: string, model: string): ModelMismatch | null {
const isOpus = model.toLowerCase().includes('opus');
if (!isOpus) return null;
if (MECHANICAL_PATTERNS.test(description)) {
return {
description,
expectedComplexity: 'mechanical',
recommendation: 'Consider using Haiku for mechanical tasks to reduce cost.',
};
}
if (READ_ONLY_PATTERNS.test(description)) {
return {
description,
expectedComplexity: 'read_only',
recommendation: 'Consider using Haiku or Sonnet for read-only exploration tasks.',
};
}
return null;
}
export function detectSwitchPattern(
switches: { from: string; to: string }[]
): SwitchPattern | null {
if (switches.length === 0) return null;
if (switches.length < 2) return 'manual_switch';
// Look for Sonnet→Opus→Sonnet pattern (plan mode)
for (let i = 0; i < switches.length - 1; i++) {
const s1 = switches[i];
const s2 = switches[i + 1];
if (
s1.from.toLowerCase().includes('sonnet') &&
s1.to.toLowerCase().includes('opus') &&
s2.from.toLowerCase().includes('opus') &&
s2.to.toLowerCase().includes('sonnet')
) {
return 'opus_plan_mode';
}
}
return 'manual_switch';
}
// =============================================================================
// Key Takeaways
// =============================================================================
export interface Takeaway {
severity: Severity;
title: string;
detail: string;
sectionTitle: string;
}
interface TakeawayReport {
costAnalysis: {
costPerCommitAssessment: string | null;
costPerLineAssessment: string | null;
totalSessionCostUsd: number;
};
cacheEconomics: {
cacheEfficiencyAssessment: string | null;
cacheEfficiencyPct: number;
};
toolUsage: {
overallToolHealth: string;
};
thrashingSignals: {
thrashingAssessment: string;
bashNearDuplicates: unknown[];
editReworkFiles: unknown[];
};
idleAnalysis: {
idleAssessment: string;
idlePct: number;
};
promptQuality: {
assessment: string;
frictionRate: number;
};
overview: {
contextAssessment: string | null;
compactionCount: number;
};
fileReadRedundancy: {
redundancyAssessment: string;
readsPerUniqueFile: number;
};
testProgression: {
trajectory: string;
};
}
export function computeTakeaways(report: TakeawayReport): Takeaway[] {
const items: Takeaway[] = [];
// Cost red flags
const costSev = assessmentSeverity(report.costAnalysis.costPerCommitAssessment);
if (costSev === 'danger') {
items.push({
severity: 'danger',
title: 'High cost per commit',
detail: `$${report.costAnalysis.totalSessionCostUsd.toFixed(2)} total \u2014 consider smaller, focused sessions`,
sectionTitle: 'Cost Analysis',
});
} else if (costSev === 'warning') {
items.push({
severity: 'warning',
title: 'Elevated cost per commit',
detail: 'Cost per commit is above typical range',
sectionTitle: 'Cost Analysis',
});
}
// Cache efficiency
if (report.cacheEconomics.cacheEfficiencyAssessment === 'concerning') {
items.push({
severity: 'warning',
title: 'Low cache efficiency',
detail: `${report.cacheEconomics.cacheEfficiencyPct}% cache hit rate \u2014 prompt structure may reduce caching`,
sectionTitle: 'Token Usage',
});
}
// Tool health
const toolSev = assessmentSeverity(report.toolUsage.overallToolHealth);
if (toolSev === 'danger') {
items.push({
severity: 'danger',
title: 'Tool reliability issues',
detail: 'Multiple tool calls are failing \u2014 check error section for details',
sectionTitle: 'Tool Usage',
});
} else if (toolSev === 'warning') {
items.push({
severity: 'warning',
title: 'Degraded tool health',
detail: 'Some tools have elevated failure rates',
sectionTitle: 'Tool Usage',
});
}
// Thrashing
if (report.thrashingSignals.thrashingAssessment === 'severe') {
items.push({
severity: 'danger',
title: 'Significant thrashing detected',
detail: 'Repeated commands and file rework suggest unclear direction',
sectionTitle: 'Friction Signals',
});
} else if (report.thrashingSignals.thrashingAssessment === 'mild') {
items.push({
severity: 'warning',
title: 'Mild thrashing detected',
detail: 'Some repeated commands or file rework occurred',
sectionTitle: 'Friction Signals',
});
}
// Idle time
if (report.idleAnalysis.idleAssessment === 'high_idle') {
items.push({
severity: 'warning',
title: 'High idle time',
detail: `${report.idleAnalysis.idlePct}% of wall-clock time was idle`,
sectionTitle: 'Timeline & Activity',
});
}
// Prompt quality
const promptSev = assessmentSeverity(report.promptQuality.assessment);
if (promptSev === 'danger') {
items.push({
severity: 'danger',
title: 'Prompt quality issues',
detail: `${(report.promptQuality.frictionRate * 100).toFixed(0)}% friction rate \u2014 try more detailed initial prompts`,
sectionTitle: 'Quality Signals',
});
}
// Context pressure
if (
report.overview.contextAssessment === 'critical' ||
report.overview.contextAssessment === 'high'
) {
items.push({
severity: report.overview.contextAssessment === 'critical' ? 'danger' : 'warning',
title: 'Context window pressure',
detail: `${report.overview.compactionCount} compaction${report.overview.compactionCount !== 1 ? 's' : ''} occurred \u2014 session may lose early context`,
sectionTitle: 'Overview',
});
}
// File read redundancy
if (report.fileReadRedundancy.redundancyAssessment === 'wasteful') {
items.push({
severity: 'warning',
title: 'Redundant file reads',
detail: `${report.fileReadRedundancy.readsPerUniqueFile}x reads per unique file`,
sectionTitle: 'Quality Signals',
});
}
// Test regression
if (report.testProgression.trajectory === 'regressing') {
items.push({
severity: 'danger',
title: 'Tests regressing',
detail: 'Test failures increased over the session',
sectionTitle: 'Quality Signals',
});
}
// Sort by severity (danger first), then limit to 4
const severityOrder: Record<Severity, number> = { danger: 0, warning: 1, neutral: 2, good: 3 };
items.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
if (items.length === 0) {
return [
{
severity: 'good',
title: 'Session looks healthy',
detail: 'No significant issues detected across all metrics',
sectionTitle: 'Overview',
},
];
}
return items.slice(0, 4);
}

File diff suppressed because it is too large Load diff

View file

@ -2,6 +2,27 @@
* String utilities for display formatting.
*/
const isMacPlatform =
typeof window !== 'undefined' && window.navigator.userAgent.includes('Macintosh');
/** Returns '⌘' on macOS, 'Ctrl' on Windows/Linux. */
export const modKey = isMacPlatform ? '⌘' : 'Ctrl+';
/** Returns '⇧' on macOS, 'Shift+' on Windows/Linux. */
export const shiftKey = isMacPlatform ? '⇧' : 'Shift+';
/**
* Formats a keyboard shortcut for the current platform.
* @example formatShortcut('R') '⌘R' on Mac, 'Ctrl+R' on Windows/Linux
* @example formatShortcut('W', { shift: true }) '⇧⌘W' on Mac, 'Ctrl+Shift+W' on Windows/Linux
*/
export function formatShortcut(key: string, opts?: { shift?: boolean }): string {
if (opts?.shift) {
return isMacPlatform ? `${shiftKey}${modKey}${key}` : `${modKey}${shiftKey}${key}`;
}
return `${modKey}${key}`;
}
/**
* Truncates a string in the middle to preserve both the beginning and end.
* Useful for branch names where the unique identifier is often at the end.

View file

@ -56,6 +56,30 @@ import type {
SubagentDetail,
} from '@main/types';
// =============================================================================
// Cost Calculation Types
// =============================================================================
/**
* Detailed cost breakdown by token type for a session or chunk
*/
export interface CostBreakdown {
/** Cost for input tokens */
inputCost: number;
/** Cost for output tokens */
outputCost: number;
/** Cost for cache creation tokens */
cacheCreationCost: number;
/** Cost for cache read tokens */
cacheReadCost: number;
/** Total cost (sum of all components) */
totalCost: number;
/** Model name used for calculation */
model: string;
/** Source of the cost data */
source: 'calculated' | 'precalculated' | 'unavailable';
}
// =============================================================================
// Agent Config
// =============================================================================
@ -492,6 +516,7 @@ export interface ElectronAPI {
close: () => Promise<void>;
isMaximized: () => Promise<boolean>;
isFullScreen: () => Promise<boolean>;
relaunch: () => Promise<void>;
};
/** Subscribe to fullscreen changes (e.g. to remove macOS traffic light padding in fullscreen) */

View file

@ -264,6 +264,10 @@ export interface AppConfig {
claudeRootPath: string | null;
/** Agent communication language ('system' = use OS locale) */
agentLanguage: string;
/** Whether to auto-expand AI response groups when opening a transcript or receiving new messages */
autoExpandAIGroups: boolean;
/** Whether to use the native OS title bar instead of the custom one (Linux/Windows) */
useNativeTitleBar: boolean;
};
/** Display and UI settings */
display: {

View file

@ -0,0 +1,45 @@
/**
* Cost formatting utilities
*/
/**
* Format USD cost with appropriate precision
* - $0.001 or more: 2 decimal places ($1.23)
* - Less than $0.001: 3-4 decimal places for precision ($0.0012)
* - Zero: $0.00
*/
export function formatCostUsd(cost: number): string {
if (cost === 0) {
return '$0.00';
}
if (cost >= 0.01) {
// Standard currency format for amounts >= 1 cent
return `$${cost.toFixed(2)}`;
} else if (cost >= 0.001) {
// 3 decimal places for sub-cent amounts
return `$${cost.toFixed(3)}`;
} else {
// 4 decimal places for very small amounts
return `$${cost.toFixed(4)}`;
}
}
/**
* Format cost compactly for display in badges
* - Rounds to 2 decimal places
* - Omits $ prefix for brevity
*/
export function formatCostCompact(cost: number): string {
if (cost === 0) {
return '0.00';
}
if (cost >= 0.01) {
return cost.toFixed(2);
} else if (cost >= 0.001) {
return cost.toFixed(3);
} else {
return cost.toFixed(4);
}
}

121
src/shared/utils/pricing.ts Normal file
View file

@ -0,0 +1,121 @@
// eslint-disable-next-line no-restricted-imports -- resources/ is outside src/, no alias available
import pricingData from '../../../resources/pricing.json';
export interface LiteLLMPricing {
input_cost_per_token: number;
output_cost_per_token: number;
cache_creation_input_token_cost?: number;
cache_read_input_token_cost?: number;
input_cost_per_token_above_200k_tokens?: number;
output_cost_per_token_above_200k_tokens?: number;
cache_creation_input_token_cost_above_200k_tokens?: number;
cache_read_input_token_cost_above_200k_tokens?: number;
[key: string]: unknown;
}
export interface DisplayPricing {
input: number;
output: number;
cache_read: number;
cache_creation: number;
}
const TIER_THRESHOLD = 200_000;
const PRICING_MAP = pricingData as Record<string, unknown>;
// Pre-compute lowercase key map for O(1) case-insensitive lookups
const LOWERCASE_KEY_MAP = new Map<string, string>();
for (const key of Object.keys(PRICING_MAP)) {
if (!LOWERCASE_KEY_MAP.has(key.toLowerCase())) {
LOWERCASE_KEY_MAP.set(key.toLowerCase(), key);
}
}
function isLiteLLMPricing(entry: unknown): entry is LiteLLMPricing {
return (
!!entry &&
typeof entry === 'object' &&
'input_cost_per_token' in entry &&
'output_cost_per_token' in entry
);
}
function tryGetPricing(key: string): LiteLLMPricing | null {
const entry = PRICING_MAP[key];
return isLiteLLMPricing(entry) ? entry : null;
}
export function getPricing(modelName: string): LiteLLMPricing | null {
const exact = tryGetPricing(modelName);
if (exact) return exact;
const lowerName = modelName.toLowerCase();
const originalKey = LOWERCASE_KEY_MAP.get(lowerName);
if (originalKey) {
return tryGetPricing(originalKey);
}
return null;
}
export function calculateTieredCost(tokens: number, baseRate: number, tieredRate?: number): number {
if (tokens <= 0) return 0;
if (tieredRate == null || tokens <= TIER_THRESHOLD) {
return tokens * baseRate;
}
const costBelow = TIER_THRESHOLD * baseRate;
const costAbove = (tokens - TIER_THRESHOLD) * tieredRate;
return costBelow + costAbove;
}
export function calculateMessageCost(
modelName: string,
inputTokens: number,
outputTokens: number,
cacheReadTokens: number,
cacheCreationTokens: number
): number {
const pricing = getPricing(modelName);
if (!pricing) {
if (inputTokens > 0 || outputTokens > 0 || cacheReadTokens > 0 || cacheCreationTokens > 0) {
console.warn(`[pricing] No pricing data for model "${modelName}", cost will be $0`);
}
return 0;
}
const inputCost = calculateTieredCost(
inputTokens,
pricing.input_cost_per_token,
pricing.input_cost_per_token_above_200k_tokens
);
const outputCost = calculateTieredCost(
outputTokens,
pricing.output_cost_per_token,
pricing.output_cost_per_token_above_200k_tokens
);
const cacheCreationCost = calculateTieredCost(
cacheCreationTokens,
pricing.cache_creation_input_token_cost ?? 0,
pricing.cache_creation_input_token_cost_above_200k_tokens
);
const cacheReadCost = calculateTieredCost(
cacheReadTokens,
pricing.cache_read_input_token_cost ?? 0,
pricing.cache_read_input_token_cost_above_200k_tokens
);
return inputCost + outputCost + cacheCreationCost + cacheReadCost;
}
export function getDisplayPricing(modelName: string): DisplayPricing | null {
const pricing = getPricing(modelName);
if (!pricing) return null;
return {
input: pricing.input_cost_per_token * 1_000_000,
output: pricing.output_cost_per_token * 1_000_000,
cache_read: (pricing.cache_read_input_token_cost ?? 0) * 1_000_000,
cache_creation: (pricing.cache_creation_input_token_cost ?? 0) * 1_000_000,
};
}

View file

@ -20,6 +20,28 @@ describe('configValidation', () => {
}
});
it('accepts general.autoExpandAIGroups boolean toggle', () => {
const resultOn = validateConfigUpdatePayload('general', { autoExpandAIGroups: true });
expect(resultOn.valid).toBe(true);
if (resultOn.valid) {
expect(resultOn.data).toEqual({ autoExpandAIGroups: true });
}
const resultOff = validateConfigUpdatePayload('general', { autoExpandAIGroups: false });
expect(resultOff.valid).toBe(true);
if (resultOff.valid) {
expect(resultOff.data).toEqual({ autoExpandAIGroups: false });
}
});
it('rejects non-boolean general.autoExpandAIGroups', () => {
const result = validateConfigUpdatePayload('general', { autoExpandAIGroups: 'yes' });
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.error).toContain('boolean');
}
});
it('accepts absolute general.claudeRootPath updates', () => {
const result = validateConfigUpdatePayload('general', {
claudeRootPath: '/Users/test/.claude',

View file

@ -0,0 +1,556 @@
/**
* Tests for cost calculation in jsonl.ts
*/
import { describe, it, expect, vi } from 'vitest';
import { calculateMetrics } from '@main/utils/jsonl';
import type { ParsedMessage } from '@main/types';
describe('Cost Calculation', () => {
describe('Basic Cost Calculation', () => {
it('should calculate cost for simple token usage', () => {
const messages: ParsedMessage[] = [
{
type: 'assistant',
uuid: 'msg-1',
timestamp: new Date(),
content: [],
model: 'claude-3-5-sonnet-20241022',
usage: {
input_tokens: 1000,
output_tokens: 500,
},
toolCalls: [],
toolResults: [],
isSidechain: false,
},
];
const metrics = calculateMetrics(messages);
// Expected: (1000 * 0.000003) + (500 * 0.000015) = 0.003 + 0.0075 = 0.0105
expect(metrics.costUsd).toBeCloseTo(0.0105, 6);
});
it('should calculate cost with cache tokens', () => {
const messages: ParsedMessage[] = [
{
type: 'assistant',
uuid: 'msg-1',
timestamp: new Date(),
content: [],
model: 'claude-3-5-sonnet-20241022',
usage: {
input_tokens: 1000,
output_tokens: 500,
cache_creation_input_tokens: 200,
cache_read_input_tokens: 300,
},
toolCalls: [],
toolResults: [],
isSidechain: false,
},
];
const metrics = calculateMetrics(messages);
// Input: 1000 * 0.000003 = 0.003
// Output: 500 * 0.000015 = 0.0075
// Cache creation: 200 * 0.00000375 = 0.00075
// Cache read: 300 * 0.0000003 = 0.00009
// Total: 0.01134
expect(metrics.costUsd).toBeCloseTo(0.01134, 6);
});
it('should return 0 cost when no model is specified', () => {
const messages: ParsedMessage[] = [
{
type: 'assistant',
uuid: 'msg-1',
timestamp: new Date(),
content: [],
usage: {
input_tokens: 1000,
output_tokens: 500,
},
toolCalls: [],
toolResults: [],
isSidechain: false,
},
];
const metrics = calculateMetrics(messages);
expect(metrics.costUsd).toBe(0);
});
it('should return 0 cost when model pricing not found', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const messages: ParsedMessage[] = [
{
type: 'assistant',
uuid: 'msg-1',
timestamp: new Date(),
content: [],
model: 'unknown-model',
usage: {
input_tokens: 1000,
output_tokens: 500,
},
toolCalls: [],
toolResults: [],
isSidechain: false,
},
];
const metrics = calculateMetrics(messages);
expect(metrics.costUsd).toBe(0);
warnSpy.mockRestore();
});
});
describe('Tiered Pricing', () => {
it('should use base rates for tokens below 200k threshold', () => {
const messages: ParsedMessage[] = [
{
type: 'assistant',
uuid: 'msg-1',
timestamp: new Date(),
content: [],
model: 'claude-3-5-sonnet-20241022',
usage: {
input_tokens: 100_000,
output_tokens: 50_000,
},
toolCalls: [],
toolResults: [],
isSidechain: false,
},
];
const metrics = calculateMetrics(messages);
// Input: 100000 * 0.000003 = 0.3
// Output: 50000 * 0.000015 = 0.75
// Total: 1.05
expect(metrics.costUsd).toBeCloseTo(1.05, 6);
});
it('should use base rates for input tokens above 200k when model has no tiered pricing', () => {
const messages: ParsedMessage[] = [
{
type: 'assistant',
uuid: 'msg-1',
timestamp: new Date(),
content: [],
model: 'claude-3-5-sonnet-20241022',
usage: {
input_tokens: 250_000,
output_tokens: 1_000,
},
toolCalls: [],
toolResults: [],
isSidechain: false,
},
];
const metrics = calculateMetrics(messages);
// claude-3-5-sonnet-20241022 has no tiered rates in pricing.json, so base rates apply
// Input: 250000 * 0.000003 = 0.75
// Output: 1000 * 0.000015 = 0.015
// Total: 0.765
expect(metrics.costUsd).toBeCloseTo(0.765, 6);
});
it('should use base rates for output tokens above 200k when model has no tiered pricing', () => {
const messages: ParsedMessage[] = [
{
type: 'assistant',
uuid: 'msg-1',
timestamp: new Date(),
content: [],
model: 'claude-3-5-sonnet-20241022',
usage: {
input_tokens: 1_000,
output_tokens: 250_000,
},
toolCalls: [],
toolResults: [],
isSidechain: false,
},
];
const metrics = calculateMetrics(messages);
// No tiered rates, so base rates for all tokens
// Input: 1000 * 0.000003 = 0.003
// Output: 250000 * 0.000015 = 3.75
// Total: 3.753
expect(metrics.costUsd).toBeCloseTo(3.753, 6);
});
it('should use base rates for cache tokens above 200k when model has no tiered pricing', () => {
const messages: ParsedMessage[] = [
{
type: 'assistant',
uuid: 'msg-1',
timestamp: new Date(),
content: [],
model: 'claude-3-5-sonnet-20241022',
usage: {
input_tokens: 1_000,
output_tokens: 1_000,
cache_creation_input_tokens: 250_000,
cache_read_input_tokens: 250_000,
},
toolCalls: [],
toolResults: [],
isSidechain: false,
},
];
const metrics = calculateMetrics(messages);
// No tiered rates for this model, so base rates apply
// Input: 1000 * 0.000003 = 0.003
// Output: 1000 * 0.000015 = 0.015
// Cache creation: 250000 * 0.00000375 = 0.9375
// Cache read: 250000 * 0.0000003 = 0.075
// Total: 1.0305
expect(metrics.costUsd).toBeCloseTo(1.0305, 6);
});
it('should handle model without tiered pricing', () => {
const messages: ParsedMessage[] = [
{
type: 'assistant',
uuid: 'msg-1',
timestamp: new Date(),
content: [],
model: 'claude-3-opus-20240229',
usage: {
input_tokens: 250_000,
output_tokens: 250_000,
},
toolCalls: [],
toolResults: [],
isSidechain: false,
},
];
const metrics = calculateMetrics(messages);
// No tiered rates, so use base rates even above 200k
// Input: 250000 * 0.000015 = 3.75
// Output: 250000 * 0.000075 = 18.75
// Total: 22.5
expect(metrics.costUsd).toBeCloseTo(22.5, 6);
});
it('should use tiered rates for a model that has them (claude-4-sonnet)', () => {
const messages: ParsedMessage[] = [
{
type: 'assistant',
uuid: 'msg-1',
timestamp: new Date(),
content: [],
model: 'claude-4-sonnet-20250514',
usage: {
input_tokens: 250_000,
output_tokens: 1_000,
},
toolCalls: [],
toolResults: [],
isSidechain: false,
},
];
const metrics = calculateMetrics(messages);
// claude-4-sonnet has tiered rates:
// input base=0.000003, above_200k=0.000006
// Input: (200000 * 0.000003) + (50000 * 0.000006) = 0.6 + 0.3 = 0.9
// Output: 1000 * 0.000015 = 0.015
// Total: 0.915
expect(metrics.costUsd).toBeCloseTo(0.915, 6);
});
});
describe('Multiple Messages', () => {
it('should aggregate costs across multiple messages', () => {
const messages: ParsedMessage[] = [
{
type: 'assistant',
uuid: 'msg-1',
timestamp: new Date(),
content: [],
model: 'claude-3-5-sonnet-20241022',
usage: {
input_tokens: 1000,
output_tokens: 500,
},
toolCalls: [],
toolResults: [],
isSidechain: false,
},
{
type: 'assistant',
uuid: 'msg-2',
timestamp: new Date(),
content: [],
model: 'claude-3-5-sonnet-20241022',
usage: {
input_tokens: 2000,
output_tokens: 1000,
},
toolCalls: [],
toolResults: [],
isSidechain: false,
},
];
const metrics = calculateMetrics(messages);
// Message 1: (1000 * 0.000003) + (500 * 0.000015) = 0.0105
// Message 2: (2000 * 0.000003) + (1000 * 0.000015) = 0.021
// Total: 0.0315
expect(metrics.costUsd).toBeCloseTo(0.0315, 6);
});
it("should calculate cost per-message using each message's model", () => {
const messages: ParsedMessage[] = [
{
type: 'assistant',
uuid: 'msg-1',
timestamp: new Date(),
content: [],
model: 'claude-3-5-sonnet-20241022',
usage: {
input_tokens: 1000,
output_tokens: 500,
},
toolCalls: [],
toolResults: [],
isSidechain: false,
},
{
type: 'assistant',
uuid: 'msg-2',
timestamp: new Date(),
content: [],
model: 'claude-3-opus-20240229', // Different model
usage: {
input_tokens: 1000,
output_tokens: 500,
},
toolCalls: [],
toolResults: [],
isSidechain: false,
},
];
const metrics = calculateMetrics(messages);
// Each message uses its own model's pricing
// Message 1 (sonnet): (1000 * 0.000003) + (500 * 0.000015) = 0.003 + 0.0075 = 0.0105
// Message 2 (opus): (1000 * 0.000015) + (500 * 0.000075) = 0.015 + 0.0375 = 0.0525
// Total cost: 0.0105 + 0.0525 = 0.063
expect(metrics.costUsd).toBeCloseTo(0.063, 6);
});
});
describe('Edge Cases', () => {
it('should handle zero tokens', () => {
const messages: ParsedMessage[] = [
{
type: 'assistant',
uuid: 'msg-1',
timestamp: new Date(),
content: [],
model: 'claude-3-5-sonnet-20241022',
usage: {
input_tokens: 0,
output_tokens: 0,
},
toolCalls: [],
toolResults: [],
isSidechain: false,
},
];
const metrics = calculateMetrics(messages);
expect(metrics.costUsd).toBe(0);
});
it('should handle messages without usage data', () => {
const messages: ParsedMessage[] = [
{
type: 'assistant',
uuid: 'msg-1',
timestamp: new Date(),
content: [],
model: 'claude-3-5-sonnet-20241022',
toolCalls: [],
toolResults: [],
isSidechain: false,
},
];
const metrics = calculateMetrics(messages);
expect(metrics.costUsd).toBe(0);
});
it('should handle empty messages array', () => {
const messages: ParsedMessage[] = [];
const metrics = calculateMetrics(messages);
expect(metrics.costUsd).toBe(0);
});
});
describe('Model Name Lookup', () => {
it('should find model with exact match', () => {
const messages: ParsedMessage[] = [
{
type: 'assistant',
uuid: 'msg-1',
timestamp: new Date(),
content: [],
model: 'claude-3-5-sonnet-20241022',
usage: {
input_tokens: 1000,
output_tokens: 500,
},
toolCalls: [],
toolResults: [],
isSidechain: false,
},
];
const metrics = calculateMetrics(messages);
expect(metrics.costUsd).toBeGreaterThan(0);
});
it('should find model with case-insensitive match', () => {
const messages: ParsedMessage[] = [
{
type: 'assistant',
uuid: 'msg-1',
timestamp: new Date(),
content: [],
model: 'CLAUDE-3-5-SONNET-20241022',
usage: {
input_tokens: 1000,
output_tokens: 500,
},
toolCalls: [],
toolResults: [],
isSidechain: false,
},
];
const metrics = calculateMetrics(messages);
expect(metrics.costUsd).toBeGreaterThan(0);
});
});
describe('Per-Message Tiering', () => {
it('should apply tiered pricing per-message, not to aggregated totals', () => {
// Scenario: Many messages each with cache_read tokens < 200k,
// but aggregated total > 200k
// Each message should use base rates, not tiered rates
const messages: ParsedMessage[] = [];
// Create 10 messages, each with 50k cache_read tokens (500k total)
for (let i = 0; i < 10; i++) {
messages.push({
type: 'assistant',
uuid: `msg-${i}`,
timestamp: new Date(),
content: [],
model: 'claude-3-5-sonnet-20241022',
usage: {
input_tokens: 0,
output_tokens: 0,
cache_read_input_tokens: 50000,
},
toolCalls: [],
toolResults: [],
isSidechain: false,
});
}
const metrics = calculateMetrics(messages);
// Per-message tiering: Each message uses base rate (< 200k threshold)
// Each message: 50,000 * 0.0000003 = $0.015
// Total: 10 * $0.015 = $0.15
const expectedCost = 10 * 50000 * 0.0000003;
expect(metrics.costUsd).toBeCloseTo(expectedCost, 6);
// Verify this is NOT using tiered rate on aggregated total
// If incorrectly aggregated: (200k * 0.0000003) + (300k * 0.0000006) = $0.24
const incorrectAggregatedCost = 0.24;
expect(metrics.costUsd).not.toBeCloseTo(incorrectAggregatedCost, 2);
});
it('should use base rates when individual messages exceed 200k and model has no tiered rates', () => {
const messages: ParsedMessage[] = [
{
type: 'assistant',
uuid: 'msg-1',
timestamp: new Date(),
content: [],
model: 'claude-3-5-sonnet-20241022',
usage: {
input_tokens: 0,
output_tokens: 0,
cache_read_input_tokens: 300000, // Exceeds 200k threshold
},
toolCalls: [],
toolResults: [],
isSidechain: false,
},
];
const metrics = calculateMetrics(messages);
// No tiered rates for this model, so all 300k at base rate
// 300,000 * 0.0000003 = $0.09
const expectedCost = 300000 * 0.0000003;
expect(metrics.costUsd).toBeCloseTo(expectedCost, 6);
});
});
describe('Integration with Other Metrics', () => {
it('should include cost alongside other session metrics', () => {
const messages: ParsedMessage[] = [
{
type: 'assistant',
uuid: 'msg-1',
timestamp: new Date(),
content: [],
model: 'claude-3-5-sonnet-20241022',
usage: {
input_tokens: 1000,
output_tokens: 500,
},
toolCalls: [],
toolResults: [],
isSidechain: false,
},
];
const metrics = calculateMetrics(messages);
// Check that all expected metrics are present
expect(metrics).toHaveProperty('totalTokens');
expect(metrics).toHaveProperty('inputTokens');
expect(metrics).toHaveProperty('outputTokens');
expect(metrics).toHaveProperty('costUsd');
expect(metrics.totalTokens).toBe(1500);
expect(metrics.inputTokens).toBe(1000);
expect(metrics.outputTokens).toBe(500);
expect(metrics.costUsd).toBeCloseTo(0.0105, 6);
});
});
});

View file

@ -0,0 +1,398 @@
import { describe, it, expect } from 'vitest';
import {
assessmentColor,
assessmentExplanation,
assessmentLabel,
assessmentSeverity,
computeCacheEfficiencyAssessment,
computeCacheRatioAssessment,
computeCostPerCommitAssessment,
computeCostPerLineAssessment,
computeIdleAssessment,
computeOverheadAssessment,
computeRedundancyAssessment,
computeSubagentCostShareAssessment,
computeTakeaways,
computeThrashingAssessment,
computeToolHealthAssessment,
detectModelMismatch,
detectSwitchPattern,
severityColor,
THRESHOLDS,
} from '@renderer/utils/reportAssessments';
import type { MetricKey } from '@renderer/utils/reportAssessments';
describe('reportAssessments', () => {
describe('severityColor', () => {
it('maps severity to hex color', () => {
expect(severityColor('good')).toBe('#4ade80');
expect(severityColor('warning')).toBe('#fbbf24');
expect(severityColor('danger')).toBe('#f87171');
expect(severityColor('neutral')).toBe('#a1a1aa');
});
});
describe('assessmentSeverity', () => {
it('maps known assessments to severity', () => {
expect(assessmentSeverity('healthy')).toBe('good');
expect(assessmentSeverity('efficient')).toBe('good');
expect(assessmentSeverity('expensive')).toBe('warning');
expect(assessmentSeverity('red_flag')).toBe('danger');
expect(assessmentSeverity('very_high')).toBe('danger');
expect(assessmentSeverity('degraded')).toBe('warning');
expect(assessmentSeverity('unreliable')).toBe('danger');
expect(assessmentSeverity('high_idle')).toBe('danger');
expect(assessmentSeverity('moderate')).toBe('warning');
});
it('returns neutral for null/undefined/unknown', () => {
expect(assessmentSeverity(null)).toBe('neutral');
expect(assessmentSeverity(undefined)).toBe('neutral');
expect(assessmentSeverity('unknown_value')).toBe('neutral');
});
});
describe('assessmentColor', () => {
it('returns correct color for assessment string', () => {
expect(assessmentColor('healthy')).toBe('#4ade80');
expect(assessmentColor('red_flag')).toBe('#f87171');
expect(assessmentColor(null)).toBe('#a1a1aa');
});
});
describe('assessmentLabel', () => {
it('converts snake_case to Title Case', () => {
expect(assessmentLabel('red_flag')).toBe('Red Flag');
expect(assessmentLabel('well_specified')).toBe('Well Specified');
expect(assessmentLabel('healthy')).toBe('Healthy');
expect(assessmentLabel('high_idle')).toBe('High Idle');
expect(assessmentLabel('opus_plan_mode')).toBe('Opus Plan Mode');
});
});
describe('computeCostPerCommitAssessment', () => {
it('returns efficient below threshold', () => {
expect(computeCostPerCommitAssessment(0.3)).toBe('efficient');
});
it('returns normal in range', () => {
expect(computeCostPerCommitAssessment(1.0)).toBe('normal');
});
it('returns expensive in range', () => {
expect(computeCostPerCommitAssessment(3.0)).toBe('expensive');
});
it('returns red_flag above threshold', () => {
expect(computeCostPerCommitAssessment(10.0)).toBe('red_flag');
});
it('respects threshold boundaries', () => {
expect(computeCostPerCommitAssessment(THRESHOLDS.costPerCommit.efficient - 0.01)).toBe(
'efficient'
);
expect(computeCostPerCommitAssessment(THRESHOLDS.costPerCommit.efficient)).toBe('normal');
});
});
describe('computeCostPerLineAssessment', () => {
it('returns efficient below threshold', () => {
expect(computeCostPerLineAssessment(0.005)).toBe('efficient');
});
it('returns red_flag above threshold', () => {
expect(computeCostPerLineAssessment(0.5)).toBe('red_flag');
});
});
describe('computeSubagentCostShareAssessment', () => {
it('returns normal below 30%', () => {
expect(computeSubagentCostShareAssessment(20)).toBe('normal');
});
it('returns high in range', () => {
expect(computeSubagentCostShareAssessment(45)).toBe('high');
});
it('returns very_high in range', () => {
expect(computeSubagentCostShareAssessment(70)).toBe('very_high');
});
it('returns red_flag above 80%', () => {
expect(computeSubagentCostShareAssessment(90)).toBe('red_flag');
});
});
describe('computeCacheEfficiencyAssessment', () => {
it('returns good above 95%', () => {
expect(computeCacheEfficiencyAssessment(96)).toBe('good');
});
it('returns concerning below 95%', () => {
expect(computeCacheEfficiencyAssessment(90)).toBe('concerning');
});
});
describe('computeCacheRatioAssessment', () => {
it('returns good above 20', () => {
expect(computeCacheRatioAssessment(25)).toBe('good');
});
it('returns concerning below 20', () => {
expect(computeCacheRatioAssessment(10)).toBe('concerning');
});
});
describe('computeToolHealthAssessment', () => {
it('returns healthy above 95%', () => {
expect(computeToolHealthAssessment(98)).toBe('healthy');
});
it('returns degraded between 80-95%', () => {
expect(computeToolHealthAssessment(85)).toBe('degraded');
});
it('returns unreliable below 80%', () => {
expect(computeToolHealthAssessment(70)).toBe('unreliable');
});
it('boundary: 95 is degraded, 95.1 is healthy', () => {
expect(computeToolHealthAssessment(95)).toBe('degraded');
expect(computeToolHealthAssessment(95.1)).toBe('healthy');
});
});
describe('computeIdleAssessment', () => {
it('returns efficient below 20%', () => {
expect(computeIdleAssessment(10)).toBe('efficient');
});
it('returns moderate between 20-50%', () => {
expect(computeIdleAssessment(35)).toBe('moderate');
});
it('returns high_idle above 50%', () => {
expect(computeIdleAssessment(60)).toBe('high_idle');
});
});
describe('computeRedundancyAssessment', () => {
it('returns normal at or below 2.0', () => {
expect(computeRedundancyAssessment(1.5)).toBe('normal');
expect(computeRedundancyAssessment(2.0)).toBe('normal');
});
it('returns wasteful above 2.0', () => {
expect(computeRedundancyAssessment(3.0)).toBe('wasteful');
});
});
describe('computeOverheadAssessment', () => {
it('returns normal at or below 5%', () => {
expect(computeOverheadAssessment(3)).toBe('normal');
expect(computeOverheadAssessment(5)).toBe('normal');
});
it('returns heavy above 5%', () => {
expect(computeOverheadAssessment(10)).toBe('heavy');
});
});
describe('computeThrashingAssessment', () => {
it('returns none for 0 signals', () => {
expect(computeThrashingAssessment(0)).toBe('none');
});
it('returns mild for 1-2 signals', () => {
expect(computeThrashingAssessment(1)).toBe('mild');
expect(computeThrashingAssessment(2)).toBe('mild');
});
it('returns severe for 3+ signals', () => {
expect(computeThrashingAssessment(3)).toBe('severe');
expect(computeThrashingAssessment(5)).toBe('severe');
});
});
describe('detectModelMismatch', () => {
it('returns null for non-opus models', () => {
expect(detectModelMismatch('rename files', 'claude-sonnet-4')).toBeNull();
});
it('detects mechanical tasks on opus', () => {
const result = detectModelMismatch('rename all variables', 'claude-opus-4');
expect(result).not.toBeNull();
expect(result!.expectedComplexity).toBe('mechanical');
});
it('detects read-only tasks on opus', () => {
const result = detectModelMismatch('explore the codebase', 'claude-opus-4');
expect(result).not.toBeNull();
expect(result!.expectedComplexity).toBe('read_only');
});
it('returns null for complex tasks on opus', () => {
expect(detectModelMismatch('implement authentication system', 'claude-opus-4')).toBeNull();
});
it('detects various mechanical keywords', () => {
for (const kw of ['lint', 'format', 'delete', 'move', 'copy', 'replace']) {
expect(detectModelMismatch(`${kw} the code`, 'opus')).not.toBeNull();
}
});
it('detects various read-only keywords', () => {
for (const kw of ['search', 'find', 'verify', 'check', 'scan', 'discover']) {
expect(detectModelMismatch(`${kw} for errors`, 'opus')).not.toBeNull();
}
});
});
describe('detectSwitchPattern', () => {
it('returns null for no switches', () => {
expect(detectSwitchPattern([])).toBeNull();
});
it('returns manual_switch for single switch', () => {
expect(detectSwitchPattern([{ from: 'claude-sonnet-4', to: 'claude-haiku-4' }])).toBe(
'manual_switch'
);
});
it('detects opus_plan_mode pattern', () => {
expect(
detectSwitchPattern([
{ from: 'claude-sonnet-4', to: 'claude-opus-4' },
{ from: 'claude-opus-4', to: 'claude-sonnet-4' },
])
).toBe('opus_plan_mode');
});
it('returns manual_switch for non-plan-mode switches', () => {
expect(
detectSwitchPattern([
{ from: 'claude-sonnet-4', to: 'claude-haiku-4' },
{ from: 'claude-haiku-4', to: 'claude-sonnet-4' },
])
).toBe('manual_switch');
});
});
describe('assessmentExplanation', () => {
const ALL_METRIC_ASSESSMENTS: Record<MetricKey, string[]> = {
costPerCommit: ['efficient', 'normal', 'expensive', 'red_flag'],
costPerLine: ['efficient', 'normal', 'expensive', 'red_flag'],
subagentCostShare: ['normal', 'high', 'very_high', 'red_flag'],
cacheEfficiency: ['good', 'concerning'],
cacheRatio: ['good', 'concerning'],
toolHealth: ['healthy', 'degraded', 'unreliable'],
idle: ['efficient', 'moderate', 'high_idle'],
fileReads: ['normal', 'wasteful'],
startup: ['normal', 'heavy'],
thrashing: ['none', 'mild', 'severe'],
promptQuality: [
'well_specified',
'moderate_friction',
'underspecified',
'verbose_but_unclear',
],
testTrajectory: ['improving', 'stable', 'regressing', 'insufficient_data'],
};
it('returns non-empty string for all valid metric/assessment combos', () => {
for (const [metricKey, assessments] of Object.entries(ALL_METRIC_ASSESSMENTS)) {
for (const assessment of assessments) {
const result = assessmentExplanation(metricKey as MetricKey, assessment);
expect(result, `${metricKey}/${assessment}`).not.toBe('');
}
}
});
it('returns empty string for unknown combinations', () => {
expect(assessmentExplanation('costPerCommit', 'unknown_value')).toBe('');
expect(assessmentExplanation('toolHealth' as MetricKey, 'nonexistent')).toBe('');
});
it('includes threshold values in explanations', () => {
expect(assessmentExplanation('costPerCommit', 'efficient')).toContain(
String(THRESHOLDS.costPerCommit.efficient)
);
expect(assessmentExplanation('toolHealth', 'healthy')).toContain(
String(THRESHOLDS.toolSuccess.healthy)
);
});
});
describe('computeTakeaways', () => {
const healthyReport = {
costAnalysis: {
costPerCommitAssessment: 'efficient',
costPerLineAssessment: 'efficient',
totalSessionCostUsd: 0.5,
},
cacheEconomics: { cacheEfficiencyAssessment: 'good', cacheEfficiencyPct: 97 },
toolUsage: { overallToolHealth: 'healthy' },
thrashingSignals: {
thrashingAssessment: 'none',
bashNearDuplicates: [],
editReworkFiles: [],
},
idleAnalysis: { idleAssessment: 'efficient', idlePct: 10 },
promptQuality: { assessment: 'well_specified', frictionRate: 0.05 },
overview: { contextAssessment: 'healthy', compactionCount: 0 },
fileReadRedundancy: { redundancyAssessment: 'normal', readsPerUniqueFile: 1.5 },
testProgression: { trajectory: 'improving' },
};
it('returns healthy message when all metrics are good', () => {
const result = computeTakeaways(healthyReport);
expect(result).toHaveLength(1);
expect(result[0].severity).toBe('good');
expect(result[0].title).toContain('healthy');
});
it('detects cost red flags', () => {
const report = {
...healthyReport,
costAnalysis: {
...healthyReport.costAnalysis,
costPerCommitAssessment: 'red_flag',
totalSessionCostUsd: 15,
},
};
const result = computeTakeaways(report);
expect(result.some((t) => t.severity === 'danger' && t.title.includes('cost'))).toBe(true);
});
it('detects thrashing', () => {
const report = {
...healthyReport,
thrashingSignals: {
thrashingAssessment: 'severe',
bashNearDuplicates: [{}],
editReworkFiles: [],
},
};
const result = computeTakeaways(report);
expect(result.some((t) => t.title.includes('thrashing'))).toBe(true);
});
it('limits to 4 takeaways', () => {
const report = {
...healthyReport,
costAnalysis: {
...healthyReport.costAnalysis,
costPerCommitAssessment: 'red_flag',
totalSessionCostUsd: 15,
},
cacheEconomics: { cacheEfficiencyAssessment: 'concerning', cacheEfficiencyPct: 80 },
toolUsage: { overallToolHealth: 'unreliable' },
thrashingSignals: {
thrashingAssessment: 'severe',
bashNearDuplicates: [{}],
editReworkFiles: [],
},
promptQuality: { assessment: 'underspecified', frictionRate: 0.5 },
overview: { contextAssessment: 'critical', compactionCount: 3 },
fileReadRedundancy: { redundancyAssessment: 'wasteful', readsPerUniqueFile: 4 },
testProgression: { trajectory: 'regressing' },
};
const result = computeTakeaways(report);
expect(result.length).toBeLessThanOrEqual(4);
});
it('sorts danger before warning', () => {
const report = {
...healthyReport,
cacheEconomics: { cacheEfficiencyAssessment: 'concerning', cacheEfficiencyPct: 80 },
toolUsage: { overallToolHealth: 'unreliable' },
};
const result = computeTakeaways(report);
expect(result.length).toBeGreaterThanOrEqual(2);
expect(result[0].severity).toBe('danger');
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,228 @@
/**
* Tests for cost formatting utilities
*/
import { describe, it, expect } from 'vitest';
import { formatCostUsd, formatCostCompact } from '@shared/utils/costFormatting';
describe('Cost Formatting', () => {
describe('formatCostUsd', () => {
describe('Zero values', () => {
it('should format zero as $0.00', () => {
expect(formatCostUsd(0)).toBe('$0.00');
});
it('should format negative zero as $0.00', () => {
expect(formatCostUsd(-0)).toBe('$0.00');
});
});
describe('Standard amounts (>= $0.01)', () => {
it('should format 1 cent with 2 decimal places', () => {
expect(formatCostUsd(0.01)).toBe('$0.01');
});
it('should format 1 dollar with 2 decimal places', () => {
expect(formatCostUsd(1.0)).toBe('$1.00');
});
it('should format dollars and cents', () => {
expect(formatCostUsd(1.23)).toBe('$1.23');
});
it('should format large amounts', () => {
expect(formatCostUsd(999.99)).toBe('$999.99');
expect(formatCostUsd(1234.56)).toBe('$1234.56');
});
it('should round to 2 decimal places for amounts >= 1 cent', () => {
expect(formatCostUsd(1.234)).toBe('$1.23');
expect(formatCostUsd(1.235)).toBe('$1.24'); // Rounds up
expect(formatCostUsd(1.999)).toBe('$2.00');
});
});
describe('Sub-cent amounts ($0.001 - $0.01)', () => {
it('should format 1 tenth of a cent with 3 decimal places', () => {
expect(formatCostUsd(0.001)).toBe('$0.001');
});
it('should format sub-cent amounts with 3 decimal places', () => {
expect(formatCostUsd(0.005)).toBe('$0.005');
expect(formatCostUsd(0.009)).toBe('$0.009');
});
it('should round to 3 decimal places for sub-cent amounts', () => {
expect(formatCostUsd(0.0012)).toBe('$0.001');
expect(formatCostUsd(0.0015)).toBe('$0.002'); // Rounds up
expect(formatCostUsd(0.0099)).toBe('$0.010');
});
});
describe('Very small amounts (< $0.001)', () => {
it('should format tiny amounts with 4 decimal places', () => {
expect(formatCostUsd(0.0001)).toBe('$0.0001');
expect(formatCostUsd(0.0005)).toBe('$0.0005');
expect(formatCostUsd(0.0009)).toBe('$0.0009');
});
it('should round to 4 decimal places for tiny amounts', () => {
expect(formatCostUsd(0.00012)).toBe('$0.0001');
expect(formatCostUsd(0.00016)).toBe('$0.0002'); // Rounds up
expect(formatCostUsd(0.00099)).toBe('$0.0010');
});
it('should handle very tiny amounts', () => {
expect(formatCostUsd(0.000001)).toBe('$0.0000');
});
});
describe('Edge cases', () => {
it('should handle negative amounts with 4 decimal places', () => {
// Negative numbers don't match >= comparisons, so they use 4 decimals
expect(formatCostUsd(-1.23)).toBe('$-1.2300');
expect(formatCostUsd(-0.001)).toBe('$-0.0010');
expect(formatCostUsd(-0.0001)).toBe('$-0.0001');
});
it('should handle very large amounts', () => {
expect(formatCostUsd(1000000)).toBe('$1000000.00');
});
it('should handle precision boundaries', () => {
// Boundary between 2 and 3 decimal places
expect(formatCostUsd(0.01)).toBe('$0.01');
expect(formatCostUsd(0.00999)).toBe('$0.010'); // Just below threshold, uses 3 decimals
// Boundary between 3 and 4 decimal places
expect(formatCostUsd(0.001)).toBe('$0.001');
expect(formatCostUsd(0.00099)).toBe('$0.0010'); // Just below threshold, uses 4 decimals
});
});
describe('Real-world API cost examples', () => {
it('should format typical Claude API costs', () => {
// 1M input tokens at $3.00/M
expect(formatCostUsd(3.0)).toBe('$3.00');
// 100k input tokens at $3.00/M
expect(formatCostUsd(0.3)).toBe('$0.30');
// 10k cache read tokens at $0.30/M
expect(formatCostUsd(0.003)).toBe('$0.003');
// 1k cache read tokens at $0.30/M
expect(formatCostUsd(0.0003)).toBe('$0.0003');
});
it('should format session totals', () => {
// Small session
expect(formatCostUsd(0.15)).toBe('$0.15');
// Medium session
expect(formatCostUsd(5.67)).toBe('$5.67');
// Large session
expect(formatCostUsd(29.57)).toBe('$29.57');
});
});
});
describe('formatCostCompact', () => {
describe('Zero values', () => {
it('should format zero as 0.00', () => {
expect(formatCostCompact(0)).toBe('0.00');
});
it('should format negative zero as 0.00', () => {
expect(formatCostCompact(-0)).toBe('0.00');
});
});
describe('Standard amounts (>= $0.01)', () => {
it('should format amounts without $ prefix', () => {
expect(formatCostCompact(0.01)).toBe('0.01');
expect(formatCostCompact(1.0)).toBe('1.00');
expect(formatCostCompact(1.23)).toBe('1.23');
});
it('should format large amounts', () => {
expect(formatCostCompact(999.99)).toBe('999.99');
expect(formatCostCompact(1234.56)).toBe('1234.56');
});
it('should round to 2 decimal places', () => {
expect(formatCostCompact(1.234)).toBe('1.23');
expect(formatCostCompact(1.235)).toBe('1.24'); // Rounds up
expect(formatCostCompact(1.999)).toBe('2.00');
});
});
describe('Sub-cent amounts ($0.001 - $0.01)', () => {
it('should format sub-cent amounts with 3 decimal places', () => {
expect(formatCostCompact(0.001)).toBe('0.001');
expect(formatCostCompact(0.005)).toBe('0.005');
expect(formatCostCompact(0.009)).toBe('0.009');
});
it('should round to 3 decimal places', () => {
expect(formatCostCompact(0.0012)).toBe('0.001');
expect(formatCostCompact(0.0015)).toBe('0.002'); // Rounds up
expect(formatCostCompact(0.0099)).toBe('0.010');
});
});
describe('Very small amounts (< $0.001)', () => {
it('should format tiny amounts with 4 decimal places', () => {
expect(formatCostCompact(0.0001)).toBe('0.0001');
expect(formatCostCompact(0.0005)).toBe('0.0005');
expect(formatCostCompact(0.0009)).toBe('0.0009');
});
it('should round to 4 decimal places', () => {
expect(formatCostCompact(0.00012)).toBe('0.0001');
expect(formatCostCompact(0.00016)).toBe('0.0002'); // Rounds up
expect(formatCostCompact(0.00099)).toBe('0.0010');
});
});
describe('Edge cases', () => {
it('should handle negative amounts with 4 decimal places', () => {
// Negative numbers don't match >= comparisons, so they use 4 decimals
expect(formatCostCompact(-1.23)).toBe('-1.2300');
expect(formatCostCompact(-0.001)).toBe('-0.0010');
expect(formatCostCompact(-0.0001)).toBe('-0.0001');
});
it('should handle very large amounts', () => {
expect(formatCostCompact(1000000)).toBe('1000000.00');
});
});
describe('Comparison with formatCostUsd', () => {
it('should match formatCostUsd except for $ prefix', () => {
const testCases = [0, 0.0001, 0.001, 0.01, 1.23, 999.99];
testCases.forEach((cost) => {
const withPrefix = formatCostUsd(cost);
const compact = formatCostCompact(cost);
// Compact should equal the USD format without the $
expect(compact).toBe(withPrefix.substring(1));
});
});
});
describe('Badge display use cases', () => {
it('should format for badge display', () => {
// Small per-message costs
expect(formatCostCompact(0.0015)).toBe('0.002');
expect(formatCostCompact(0.01)).toBe('0.01');
// Session totals in badges
expect(formatCostCompact(2.5)).toBe('2.50');
expect(formatCostCompact(15.0)).toBe('15.00');
});
});
});
});

View file

@ -0,0 +1,85 @@
import { describe, it, expect, vi } from 'vitest';
import {
getPricing,
calculateTieredCost,
calculateMessageCost,
getDisplayPricing,
} from '@shared/utils/pricing';
describe('Shared Pricing Module', () => {
describe('getPricing', () => {
it('should find pricing by exact model name', () => {
const pricing = getPricing('claude-3-5-sonnet-20241022');
expect(pricing).not.toBeNull();
expect(pricing!.input_cost_per_token).toBeGreaterThan(0);
expect(pricing!.output_cost_per_token).toBeGreaterThan(0);
});
it('should find pricing case-insensitively', () => {
const pricing = getPricing('Claude-3-5-Sonnet-20241022');
expect(pricing).not.toBeNull();
});
it('should return null for unknown models', () => {
const pricing = getPricing('totally-fake-model-xyz');
expect(pricing).toBeNull();
});
});
describe('calculateTieredCost', () => {
it('should use base rate for tokens below 200k', () => {
const cost = calculateTieredCost(100_000, 0.000003);
expect(cost).toBeCloseTo(0.3, 6);
});
it('should apply tiered rate above 200k', () => {
const cost = calculateTieredCost(250_000, 0.000003, 0.000006);
expect(cost).toBeCloseTo(0.9, 6);
});
it('should use base rate when no tiered rate provided', () => {
const cost = calculateTieredCost(250_000, 0.000015);
expect(cost).toBeCloseTo(3.75, 6);
});
it('should return 0 for zero or negative tokens', () => {
expect(calculateTieredCost(0, 0.000003)).toBe(0);
expect(calculateTieredCost(-100, 0.000003)).toBe(0);
});
});
describe('calculateMessageCost', () => {
it('should compute cost for a known model', () => {
const cost = calculateMessageCost('claude-3-5-sonnet-20241022', 1000, 500, 0, 0);
expect(cost).toBeCloseTo(0.0105, 6);
});
it('should return 0 for unknown models', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const cost = calculateMessageCost('unknown-model', 1000, 500, 0, 0);
expect(cost).toBe(0);
expect(warnSpy).toHaveBeenCalledWith(
'[pricing] No pricing data for model "unknown-model", cost will be $0'
);
warnSpy.mockRestore();
});
it('should include cache token costs', () => {
const cost = calculateMessageCost('claude-3-5-sonnet-20241022', 1000, 500, 300, 200);
expect(cost).toBeGreaterThan(0.0105);
});
});
describe('getDisplayPricing', () => {
it('should return per-million rates for a known model', () => {
const dp = getDisplayPricing('claude-3-5-sonnet-20241022');
expect(dp).not.toBeNull();
expect(dp!.input).toBeCloseTo(3.0, 1);
expect(dp!.output).toBeCloseTo(15.0, 1);
});
it('should return null for unknown models', () => {
expect(getDisplayPricing('unknown-model')).toBeNull();
});
});
});