merge: sync with upstream/main — session reports, cost calculation, Linux title bar, auto-expand AI groups
Brings in upstream changes: - Session analysis reports (overview, cost, tokens, tools, git, quality) - Unified cost calculation with LiteLLM pricing data - Custom title bar for Linux with native toggle - Auto-expand AI response groups setting - MoreMenu toolbar component - Various fixes (window drag, Ctrl+R, notification guard) All merge conflicts resolved preserving both fork features (team management, agent language, fullscreen, diff view) and upstream additions.
This commit is contained in:
commit
06bf5d4381
68 changed files with 11945 additions and 91 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:`).
|
||||
|
|
|
|||
|
|
@ -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
4195
resources/pricing.json
Normal file
File diff suppressed because it is too large
Load diff
113
scripts/fetch-pricing-data.ts
Normal file
113
scripts/fetch-pricing-data.ts
Normal 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();
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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}` };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -461,6 +461,7 @@ export const EMPTY_METRICS: SessionMetrics = {
|
|||
cacheReadTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
messageCount: 0,
|
||||
costUsd: 0,
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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) */
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
216
src/renderer/components/layout/MoreMenu.tsx
Normal file
216
src/renderer/components/layout/MoreMenu.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) */}
|
||||
|
|
|
|||
78
src/renderer/components/report/AssessmentBadge.tsx
Normal file
78
src/renderer/components/report/AssessmentBadge.tsx
Normal 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
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
58
src/renderer/components/report/ReportSection.tsx
Normal file
58
src/renderer/components/report/ReportSection.tsx
Normal 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 };
|
||||
99
src/renderer/components/report/SessionReportTab.tsx
Normal file
99
src/renderer/components/report/SessionReportTab.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
260
src/renderer/components/report/sections/CostSection.tsx
Normal file
260
src/renderer/components/report/sections/CostSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
103
src/renderer/components/report/sections/ErrorSection.tsx
Normal file
103
src/renderer/components/report/sections/ErrorSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
97
src/renderer/components/report/sections/FrictionSection.tsx
Normal file
97
src/renderer/components/report/sections/FrictionSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
72
src/renderer/components/report/sections/GitSection.tsx
Normal file
72
src/renderer/components/report/sections/GitSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
207
src/renderer/components/report/sections/InsightsSection.tsx
Normal file
207
src/renderer/components/report/sections/InsightsSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
64
src/renderer/components/report/sections/OverviewSection.tsx
Normal file
64
src/renderer/components/report/sections/OverviewSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
153
src/renderer/components/report/sections/QualitySection.tsx
Normal file
153
src/renderer/components/report/sections/QualitySection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
88
src/renderer/components/report/sections/SubagentSection.tsx
Normal file
88
src/renderer/components/report/sections/SubagentSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
111
src/renderer/components/report/sections/TimelineSection.tsx
Normal file
111
src/renderer/components/report/sections/TimelineSection.tsx
Normal 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">→</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>
|
||||
);
|
||||
};
|
||||
116
src/renderer/components/report/sections/TokenSection.tsx
Normal file
116
src/renderer/components/report/sections/TokenSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
77
src/renderer/components/report/sections/ToolSection.tsx
Normal file
77
src/renderer/components/report/sections/ToolSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -296,6 +296,8 @@ export function useSettingsHandlers({
|
|||
defaultTab: 'dashboard',
|
||||
claudeRootPath: null,
|
||||
agentLanguage: 'system',
|
||||
autoExpandAIGroups: false,
|
||||
useNativeTitleBar: false,
|
||||
},
|
||||
display: {
|
||||
showTimestamps: true,
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
386
src/renderer/types/sessionReport.ts
Normal file
386
src/renderer/types/sessionReport.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
555
src/renderer/utils/reportAssessments.ts
Normal file
555
src/renderer/utils/reportAssessments.ts
Normal 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);
|
||||
}
|
||||
1346
src/renderer/utils/sessionAnalyzer.ts
Normal file
1346
src/renderer/utils/sessionAnalyzer.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) */
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
45
src/shared/utils/costFormatting.ts
Normal file
45
src/shared/utils/costFormatting.ts
Normal 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
121
src/shared/utils/pricing.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
556
test/main/utils/costCalculation.test.ts
Normal file
556
test/main/utils/costCalculation.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
398
test/renderer/utils/reportAssessments.test.ts
Normal file
398
test/renderer/utils/reportAssessments.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
1509
test/renderer/utils/sessionAnalyzer.test.ts
Normal file
1509
test/renderer/utils/sessionAnalyzer.test.ts
Normal file
File diff suppressed because it is too large
Load diff
228
test/shared/utils/costFormatting.test.ts
Normal file
228
test/shared/utils/costFormatting.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
85
test/shared/utils/pricing.test.ts
Normal file
85
test/shared/utils/pricing.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue