diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index f996ae0..e66ffad 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,6 +1,6 @@ ## Summary - + Closes # @@ -20,6 +20,20 @@ Closes # - [ ] Documentation - [ ] Chore / dependency update +## Commit / PR title + + + +- [ ] PR title follows Conventional Commits (`type(scope): description` or `type: description`) +- [ ] All commit messages in this PR follow Conventional Commits + ## Testing @@ -38,7 +52,9 @@ Closes # ## Checklist +- [ ] This PR targets `dev` (feature/fix work should not target `main`) - [ ] My code follows the project's coding conventions - [ ] I have read the [CONTRIBUTING guide](../CONTRIBUTING.md) - [ ] No new dependencies added without discussion - [ ] No `console.log` or debug statements left in +- [ ] If this PR will be merged to `main`, it will use **Squash and merge** or **Rebase and merge** instead of **Create a merge commit** diff --git a/CLAUDE.md b/CLAUDE.md index c4481c3..cfc515d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,7 +104,7 @@ Data flows one way: `JSONL files → FileWatcher → DashboardStore → Panel.bu | View | File | Description | |---|---|---| | Dashboard | `views/Dashboard.tsx` | 3 tabs: Overview (recap, stats, projects), Charts (usage, cost), Insights (heatmap, efficiency, tools, hot files) | -| ProjectDetail | `views/ProjectDetail.tsx` | 8 tabs: Sessions, Weekly, CLAUDE.md, Commands, Tools, MCP Servers, Subagents, Files | +| ProjectDetail | `views/ProjectDetail.tsx` | 12 tabs: Sessions, Weekly, CLAUDE.md, Commands, Tools, MCP Servers, Subagents, Files, Memory, Todos, Commits, Settings | | Sidebar | `views/Sidebar.tsx` | Compact project list grouped by Active/Recent/Older with stats | **Components (all in `components/`):** @@ -149,12 +149,16 @@ Session turns are **lazy-loaded**: initial state ships sessions with `turns: []` ### Key Data Types (in `webview-ui/src/types.ts`) - **Project** — `{ id, name, path, lastActive, isActive, sessionCount, totalTokens, totalCostUsd, techStack[] }` -- **Session** — `{ id, projectId, parentSessionId, startTime, endTime, durationMs, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens, totalTokens, costUsd, promptCount, toolCallCount, filesModified[], filesCreated[], turns[], sessionSummary, hasThinking, thinkingTokens, cacheHitRate, subagentCostUsd, idleTimeMs, activeTimeMs, activityRatio }` +- **Session** — `{ id, projectId, parentSessionId, startTime, endTime, durationMs, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens, totalTokens, costUsd, promptCount, toolCallCount, filesModified[], filesCreated[], turns[], sessionSummary, hasThinking, thinkingTokens, cacheHitRate, subagentCostUsd, idleTimeMs, activeTimeMs, activityRatio, model }` - **Turn** — `{ id, role, content, inputTokens, outputTokens, toolCalls[], timestamp }` - **ToolCall** — `{ id, name, input, output?, mcpServer? }` -- **ProjectConfig** — `{ claudeMd, mcpServers, projectSettings, commands[] }` +- **ProjectConfig** — `{ claudeMd, mcpServers, projectSettings, commands[], memory, hooks[] }` - **ProjectStats** — `{ usageOverTime[], toolUsage[], promptPatterns[], efficiency, recentToolCalls[], weeklyStats }` - **DashboardStats** — `{ totalProjects, activeSessionCount, tokensTodayTotal, costTodayUsd, tokensWeekTotal, costWeekUsd }` +- **ProjectMemory** — `{ index, files: MemoryFile[] }` where `MemoryFile` = `{ name, description, type, content }` +- **SessionTodoSnapshot** — `{ sessionId, sessionDate, sessionSummary, todos: { content, status }[], timestamp }` +- **ClaudeCommit** — `{ hash, shortHash, author, date, subject, filesChanged }` +- **HookConfig** — `{ event, matcher?, command }` Other types: `DailyUsage`, `ProjectUsage`, `HeatmapCell`, `PatternCount`, `ToolUsageStat`, `HotFile`, `ProjectedCost`, `StreakData`, `EfficiencyStats`, `WeeklyRecap`, `RecentFileChange`, `ProductivityHour`, `BudgetStatus`, `ProjectFile`, `ProjectToolCall`, `WeeklyProjectStats`, `McpServer`, `PromptSearchResult` @@ -190,12 +194,8 @@ Other types: `DailyUsage`, `ProjectUsage`, `HeatmapCell`, `PatternCount`, `ToolU ### Not Yet Implemented These Claude Code features are **not captured or displayed** by the dashboard: -- **Memory** — `~/.claude/projects/{path}/memory/MEMORY.md` + individual memory files -- **Todos/Tasks** — `TodoWrite` tool calls within sessions -- **Plans** — Implementation plans created during sessions -- **Git commits by Claude** — commits with `Co-Authored-By: Claude` signature -- **Scheduled triggers** — cron-based remote agents -- **Worktrees** — isolated git worktrees created by subagents -- **Model per session** — parsed for cost calc but not shown in UI -- **Project settings** — parsed but not surfaced in webview -- **Hook configuration** — used internally but not visualized +- **Plans** — Implementation plans created during sessions (conversational, no persistent data) +- **Scheduled triggers** — cron-based remote agents (no local data source) +- **Worktrees** — isolated git worktrees created by subagents (visible in Tools tab as tool calls) +- **Session diff viewer** — `~/.claude/file-history/` before/after file changes +- **Prompt library** — starred/saved prompts and pattern detection diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 6c79824..a6f795e 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -175,7 +175,7 @@ Expressed as a percentage and shown in green in the session detail bar. A high r ## 4. Cost Calculation -Cost is calculated accurately using all 4 token types at their correct rates per model: +Cost is estimated locally using all 4 token types, a bundled pricing table, and a heuristic model-family mapping. It is intended to be directionally useful inside the dashboard and may not exactly match Anthropic billing. ### Pricing rates (per 1M tokens) @@ -207,11 +207,13 @@ Note: cache read is the dominant cost driver in long sessions, even at 0.1× the ### Model detection -The model name is read from `message.model` on each assistant turn (e.g. `"claude-sonnet-4-6"`). We match it to a pricing tier: +The model name is read from `message.model` on each assistant turn (e.g. `"claude-sonnet-4-6"`). We map it to a pricing tier heuristically: - Contains `"opus"` → claude-opus-4 rates - Contains `"haiku"` → claude-haiku-4 rates - Everything else → claude-sonnet-4 rates (default) +This means unknown or future model names currently fall back to Sonnet pricing unless they contain `opus` or `haiku`. + --- ## 5. Session Metadata @@ -375,19 +377,19 @@ All analytics are computed in-memory from the parsed session data. Nothing is st | Metric | How it's computed | |---|---| | **Tokens today** | Sum `totalTokens` for sessions where `startTime` > midnight today | -| **Cost today** | Sum `costUsd` for sessions where `startTime` > midnight today | -| **Usage over time** | Group sessions by calendar day, sum tokens and cost per day | +| **Cost today** | Sum estimated session cost for sessions where `startTime` > midnight today, including attributed `subagentCostUsd` | +| **Usage over time** | Group sessions by calendar day, sum tokens and estimated cost per day | | **Hot files** | Count occurrences of each path across all sessions' `filesModified` arrays, across all projects | | **Tool usage** | Count each `toolCall.name` across all turns in all sessions | | **Streak** | Build a set of unique calendar dates with any session activity, walk backwards from today | | **Efficiency — avg tokens/prompt** | `totalTokens / promptCount` across all sessions | | **Efficiency — first-turn resolution** | % of sessions where `promptCount === 1` | -| **Projected monthly cost** | `(currentMonthCost / daysElapsed) × daysInMonth` | +| **Projected monthly cost** | `(currentMonthCost / daysElapsed) × daysInMonth`, using estimated cost including attributed `subagentCostUsd` | | **Productivity by hour** | Group sessions by `new Date(startTime).getHours()`, average tool calls and files modified | | **Heatmap** | Group sessions by `[dayOfWeek, hour]`, sum tokens per cell | | **Prompt patterns** | Classify each user turn by keyword regex into: Fix/Bug, Explain, Refactor, Feature, Test, Other | | **Cache hit rate** | Per session: `cacheRead / (input + cacheCreation + cacheRead) × 100` | -| **Monthly usage** | Sum `totalTokens` and `costUsd` (including `subagentCostUsd`) for sessions since the 1st of the current month | +| **Monthly usage** | Sum `totalTokens` and estimated session cost (including `subagentCostUsd`) for sessions since the 1st of the current month | ### Per-project diff --git a/PRODUCT.md b/PRODUCT.md index 2b2b3e3..05e7c0b 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -2,7 +2,7 @@ ## What It Is -Claude Code Dashboard is a VS Code extension that turns the raw session data Claude Code writes to `~/.claude/` into a living analytics layer for developers. Every time you run a Claude session — asking it to fix a bug, build a feature, refactor a module — Claude records the conversation in a JSONL file on disk. The dashboard reads those files and surfaces the things developers actually want to know: what did I spend today, what sessions cost the most, which files does Claude keep touching, how efficient are my prompts, and is Claude running right now? +Claude Code Dashboard is a VS Code extension that turns the raw session data Claude Code writes to `~/.claude/` into a living analytics layer for developers. Every time you run a Claude session — asking it to fix a bug, build a feature, refactor a module — Claude records the conversation in a JSONL file on disk. The dashboard reads those files and surfaces the things developers actually want to know: what is my token usage today, what is my estimated spend, which sessions seem most expensive, which files does Claude keep touching, how efficient are my prompts, and is Claude running right now? It is not a SaaS product. It does not send any data anywhere. It reads files that already exist on your machine and presents them inside VS Code. @@ -25,16 +25,16 @@ The data to answer all of these questions is sitting in `~/.claude/projects/` ## Who It Is For -**Primary user:** A developer who uses Claude Code every day across multiple projects and wants visibility into their usage, costs, and patterns. They are comfortable with VS Code extensions and are not looking for a web dashboard — they want this information where they already work. +**Primary user:** A developer who uses Claude Code every day across multiple projects and wants visibility into their usage, estimated costs, and patterns. They are comfortable with VS Code extensions and are not looking for a web dashboard — they want this information where they already work. -**Secondary user:** A team lead or freelancer who bills clients by AI-assisted work and needs accurate records of which projects consumed what. +**Secondary user:** A team lead or freelancer who wants project-level usage allocation for AI-assisted work and needs a fast local approximation of which projects consumed what. This is operational visibility, not billing-grade reporting. --- ## Core Value Propositions ### 1. Cost awareness in real time -The status bar shows today's token count and dollar cost at a glance, updated whenever a session file changes. No waiting for a monthly bill. You know what you spent before you close your laptop. +The status bar shows today's token count and estimated dollar cost at a glance, updated whenever a session file changes. No waiting for a monthly bill. You get a fast local approximation of spend before you close your laptop. ### 2. Session-level accountability Every session is a record: when it started, how long it ran, how many prompts it took, which files it touched, and exactly what the first prompt was. The session summary line (the first user prompt shown as a preview) answers "what was I doing in this session?" without having to open it. @@ -53,7 +53,7 @@ Knowing whether Claude is actually running right now (not just "was active 20 mi ## Feature Inventory ### Status Bar -- Live token count and cost for today +- Live token count and estimated cost for today - Session count indicator (pulses when a session is active) - Clicks open the full dashboard @@ -65,16 +65,16 @@ Knowing whether Claude is actually running right now (not just "was active 20 mi ### Dashboard — Overview Tab - Budget alert banner (yellow at 80%, red at 100%) -- Weekly recap card: sessions, projects, tokens, cost, files modified, top project, streak -- Stats strip: tokens today / cost today / tokens this week / cost this week +- Weekly recap card: sessions, projects, tokens, estimated cost, files modified, top project, streak +- Stats strip: tokens today / estimated cost today / tokens this week / estimated cost this week - Active project cards with live pulse animation -- Filterable, sortable project list (filter by name; sort by last active, cost, or session count) +- Filterable, sortable project list (filter by name; sort by last active, estimated cost, or session count) ### Dashboard — Charts Tab - Token usage over the last 30 days (line chart) - Token usage by project (bar chart) -- Projected monthly cost with progress bar -- Cost by project this month (horizontal bar chart) +- Projected monthly estimated cost with progress bar +- Estimated cost by project this month (horizontal bar chart) ### Dashboard — Search Tab - Full-text search across every user prompt ever written @@ -92,7 +92,7 @@ Knowing whether Claude is actually running right now (not just "was active 20 mi ### Project Detail View - Project header with tech stack badges, path, and live indicator -- Stats: total tokens, total cost, session count +- Stats: total tokens, estimated cost, session count - Sessions tab: chronological session list with: - Session summary line (first user prompt as italic preview) - Extended thinking badge (⚡) for sessions that used thinking mode @@ -106,7 +106,7 @@ Knowing whether Claude is actually running right now (not just "was active 20 mi ### Alert System - Monthly token budget: alert when monthly token count exceeds configured limit -- Monthly cost budget: alert at 80% and 100% of configured USD amount +- Monthly estimated cost budget: alert at 80% and 100% of configured USD amount - Weekly digest: Monday morning recap (sessions, projects, tokens, top project) - All alerts fire as VS Code notifications; max once per day to avoid spam @@ -134,6 +134,17 @@ The extension never writes to any of these files except `settings.json` (to inje --- +## Token And Cost Semantics + +- Token counts come from Claude's local JSONL session logs. +- Displayed `totalTokens` exclude cache-read tokens, because cache reads can dwarf the meaningful token count in long sessions. +- Estimated cost is computed locally from parsed token usage, detected model family, and a static pricing table bundled with the extension. +- Model detection is heuristic: `opus` maps to Opus pricing, `haiku` maps to Haiku pricing, and everything else falls back to Sonnet pricing. +- Aggregate estimated cost includes subagent-attributed cost when Claude spawns subagents. +- These numbers are helpful operational estimates, but they are not guaranteed to match Anthropic billing, invoices, or future price changes exactly. + +--- + ## What It Does Not Do - It does not intercept Claude's network calls or modify Claude's behaviour. diff --git a/README.md b/README.md index a7231ff..495a8fc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Claude Code Dashboard -A VS Code extension that shows you exactly what Claude is doing — tokens, costs, sessions, and insights across all your projects, right inside VS Code. +A VS Code extension that shows you what Claude is doing on your machine — token usage, estimated costs, sessions, and insights across all your projects, right inside VS Code. **No API key required. No data leaves your machine.** @@ -14,10 +14,10 @@ A VS Code extension that shows you exactly what Claude is doing — tokens, cost ## Screenshots ![Dashboard Overview](https://raw.githubusercontent.com/jspw/Claude-Code-Dashboard/main/images/screenshots/dashboard.png) -*Main dashboard — projects, stats, weekly recap, and cost overview* +*Main dashboard — projects, stats, weekly recap, and estimated cost overview* ![Dashboard Charts](https://raw.githubusercontent.com/jspw/Claude-Code-Dashboard/main/images/screenshots/dashboard-chart.png) -*Charts tab — 30-day token trend, usage by project, projected monthly cost* +*Charts tab — 30-day token trend, usage by project, projected monthly estimated cost* ![Dashboard Insights](https://raw.githubusercontent.com/jspw/Claude-Code-Dashboard/main/images/screenshots/dashbpard-insights.png) *Insights tab — heatmap, tool usage, productivity by hour, hot files* @@ -52,12 +52,20 @@ Search for **Claude Code Dashboard** in the VS Code Extensions panel, or install | Tab | What you see | |---|---| -| **Overview** | Weekly recap, today's tokens & cost, active sessions, full project list | -| **Charts** | 30-day token trend, usage by project, projected monthly cost | +| **Overview** | Weekly recap, today's tokens & estimated cost, active sessions, full project list | +| **Charts** | 30-day token trend, usage by project, projected monthly estimated cost | | **Search** | Full-text search across every prompt you've ever sent to Claude | | **Insights** | Usage heatmap, tool breakdown, productivity by hour, hot files | -**Project detail view** — click any project to see its full session history, turn-by-turn conversation, token breakdown, files touched, CLAUDE.md, and MCP servers. Export to JSON or CSV any time. +**Project detail view** — click any project to see its full session history, turn-by-turn conversation, token breakdown, estimated cost, files touched, CLAUDE.md, and MCP servers. Export to JSON or CSV any time. + +## Token and cost notes + +- Token counts come from local Claude session logs in `~/.claude/projects/`. +- Displayed `totalTokens` exclude cache-read tokens, which Claude may reuse heavily across long sessions. +- Cost is a local estimate based on detected model family, a static pricing table, and parsed token usage. +- Aggregate cost views include subagent-attributed cost when Claude spawns subagents. +- Estimated cost may differ from Anthropic billing, invoices, or future pricing changes. --- @@ -68,7 +76,7 @@ Search for **Claude Code Dashboard** in VS Code settings (`Cmd+,` / `Ctrl+,`). | Setting | Default | Description | |---|---|---| | `claudeDashboard.monthlyTokenBudget` | `0` | Monthly token cap. Set to `0` to disable. | -| `claudeDashboard.monthlyBudgetUsd` | `0` | Monthly cost cap in USD. Alerts at 80% and 100%. Set to `0` to disable. | +| `claudeDashboard.monthlyBudgetUsd` | `0` | Monthly estimated cost cap in USD. Alerts at 80% and 100%. Set to `0` to disable. | --- @@ -81,7 +89,7 @@ Search for **Claude Code Dashboard** in VS Code settings (`Cmd+,` / `Ctrl+,`). — Confirm hooks were configured at first run. Check `~/.claude/settings.json` for entries referencing `.dashboard-events.jsonl`. The file watcher fallback still updates within ~300ms. **Cost numbers look off** -— Costs are estimated from Anthropic's published per-model token rates. Cache read tokens are tracked separately and excluded from `totalTokens` (they bill at ~10% of regular input tokens). +— Cost is estimated locally from parsed token usage, detected model family, and a static pricing table. Cache read tokens are tracked separately and excluded from `totalTokens`, but still contribute to estimated cost. **Corrupted `settings.json` after hook injection** — Restore the backup: `cp ~/.claude/settings.json.bak ~/.claude/settings.json` diff --git a/TECHNICAL.md b/TECHNICAL.md index ad91b7f..c2f8a6e 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -154,7 +154,8 @@ Token accounting rules: - `totalTokens = inputTokens + cacheCreationTokens + outputTokens` - `cacheReadTokens` are tracked separately and excluded from `totalTokens` -- `costUsd` uses input, output, cache write, and cache read rates +- `costUsd` is a local estimate using input, output, cache write, and cache read rates +- aggregate estimated cost also includes `subagentCostUsd` where applicable Model pricing is inferred by substring matching in the assistant model name: @@ -162,6 +163,8 @@ Model pricing is inferred by substring matching in the assistant model name: - `haiku` → `claude-haiku-4` - otherwise → `claude-sonnet-4` +This is a heuristic mapping, so unknown or future model names currently fall back to Sonnet pricing. + ### `SettingsParser` [`src/parsers/SettingsParser.ts`](/Users/mhshifat/Documents/personal-work/products/claude-code-dashboard/claude-dashboard/src/parsers/SettingsParser.ts) reads configuration from several locations: diff --git a/src/__tests__/extension.test.ts b/src/__tests__/extension.test.ts index 07564f2..ecae9b6 100644 --- a/src/__tests__/extension.test.ts +++ b/src/__tests__/extension.test.ts @@ -30,12 +30,13 @@ const mockFileWatcher = { start: vi.fn() }; const mockEventWatcher = { start: vi.fn() }; const mockHookManager = { injectHooks: vi.fn(() => Promise.resolve()), needsReinjection: vi.fn(() => false) }; const mockAlertManager = { checkWeeklyDigest: vi.fn() }; +const mockSidebarProvider = { setSelectedProject: vi.fn(), clearSelectedProject: vi.fn() }; vi.mock('../store/DashboardStore', () => ({ DashboardStore: vi.fn(() => mockStore) })); vi.mock('../watchers/FileWatcher', () => ({ FileWatcher: vi.fn(() => mockFileWatcher) })); vi.mock('../watchers/EventWatcher', () => ({ EventWatcher: vi.fn(() => mockEventWatcher) })); vi.mock('../hooks/HookManager', () => ({ HookManager: vi.fn(() => mockHookManager) })); -vi.mock('../providers/SidebarProvider', () => ({ SidebarProvider: vi.fn(() => ({})) })); +vi.mock('../providers/SidebarProvider', () => ({ SidebarProvider: vi.fn(() => mockSidebarProvider) })); vi.mock('../providers/StatusBarProvider', () => ({ StatusBarProvider: vi.fn(() => ({ dispose: vi.fn() })) })); vi.mock('../webviews/DashboardPanel', () => ({ DashboardPanel: { createOrShow: vi.fn() } })); vi.mock('../webviews/ProjectPanel', () => ({ ProjectPanel: { createOrShow: vi.fn() } })); @@ -97,6 +98,7 @@ describe('extension activate', () => { const openProjectHandler = getRegisteredCommand('claudeDashboard.openProject'); openProjectHandler('p1'); + expect(mockSidebarProvider.setSelectedProject).toHaveBeenCalledWith('p1'); expect(ProjectPanel.createOrShow).toHaveBeenCalledWith(context, mockStore, 'p1'); }); @@ -155,6 +157,7 @@ describe('extension activate', () => { await exportHandler('missing-project', 'json'); await exportHandler('p1', 'json'); + expect(mockSidebarProvider.clearSelectedProject).toHaveBeenCalledOnce(); expect(DashboardPanel.createOrShow).toHaveBeenCalledTimes(2); expect(vscode.workspace.fs.writeFile).not.toHaveBeenCalled(); expect(mockHookManager.injectHooks).toHaveBeenCalledWith(context.globalState); diff --git a/src/__tests__/fixtures/sessions.ts b/src/__tests__/fixtures/sessions.ts index 7b05ed3..c1bcdbf 100644 --- a/src/__tests__/fixtures/sessions.ts +++ b/src/__tests__/fixtures/sessions.ts @@ -58,6 +58,7 @@ export function makeSession(overrides: Partial = {}): Session { idleTimeMs: null, activeTimeMs: null, activityRatio: null, + model: null, ...overrides, }; } diff --git a/src/extension.ts b/src/extension.ts index 1bf532e..75862a0 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -35,9 +35,11 @@ export async function activate(context: vscode.ExtensionContext) { context.subscriptions.push( sidebarDisposable, vscode.commands.registerCommand('claudeDashboard.openDashboard', () => { + sidebarProvider.clearSelectedProject(); DashboardPanel.createOrShow(context, store); }), vscode.commands.registerCommand('claudeDashboard.openProject', (projectId: string) => { + sidebarProvider.setSelectedProject(projectId); ProjectPanel.createOrShow(context, store, projectId); }), vscode.commands.registerCommand('claudeDashboard.refresh', () => { diff --git a/src/parsers/SessionParser.ts b/src/parsers/SessionParser.ts index aa2cc37..6c1d6bf 100644 --- a/src/parsers/SessionParser.ts +++ b/src/parsers/SessionParser.ts @@ -229,6 +229,7 @@ export class SessionParser { idleTimeMs: computedIdleTimeMs, activeTimeMs: computedActiveTimeMs, activityRatio, + model: detectedModel !== 'default' ? modelKey(detectedModel) : null, }; } catch (e) { console.error('Failed to parse session file:', filePath, e); diff --git a/src/parsers/SettingsParser.ts b/src/parsers/SettingsParser.ts index b35b89b..f53533d 100644 --- a/src/parsers/SettingsParser.ts +++ b/src/parsers/SettingsParser.ts @@ -7,12 +7,36 @@ export interface ClaudeSettings { [key: string]: unknown; } +type ParsedMarkdownFile = { + name: string; + description: string; + content: string; +}; + export class SettingsParser { private readJson(filePath: string): Record { if (!fs.existsSync(filePath)) { return {}; } try { return JSON.parse(fs.readFileSync(filePath, 'utf-8')); } catch { return {}; } } + private parseMarkdownFile(raw: string, fallbackName: string): ParsedMarkdownFile { + let name = fallbackName; + let description = ''; + let content = raw; + + const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); + if (fmMatch) { + const fm = fmMatch[1]; + content = fmMatch[2].trim(); + const nameMatch = fm.match(/^name:\s*(.+)$/m); + const descMatch = fm.match(/^description:\s*(.+)$/m); + if (nameMatch) { name = nameMatch[1].trim(); } + if (descMatch) { description = descMatch[1].trim(); } + } + + return { name, description, content }; + } + readGlobalSettings(claudeDir: string): ClaudeSettings { return this.readJson(path.join(claudeDir, 'settings.json')); } @@ -37,6 +61,77 @@ export class SettingsParser { try { return fs.readFileSync(mdPath, 'utf-8'); } catch { return null; } } + readProjectMemory(claudeDir: string, projectId: string): { index: string | null; files: { fileName: string; name: string; description: string; type: string; content: string }[] } { + const memDir = path.join(claudeDir, 'projects', projectId, 'memory'); + if (!fs.existsSync(memDir)) { return { index: null, files: [] }; } + + let index: string | null = null; + const indexPath = path.join(memDir, 'MEMORY.md'); + try { if (fs.existsSync(indexPath)) { index = fs.readFileSync(indexPath, 'utf-8'); } } catch { /* ignore */ } + + const files: { fileName: string; name: string; description: string; type: string; content: string }[] = []; + try { + const entries = fs.readdirSync(memDir).filter(f => f.endsWith('.md') && f !== 'MEMORY.md'); + for (const file of entries) { + try { + const raw = fs.readFileSync(path.join(memDir, file), 'utf-8'); + const parsed = this.parseMarkdownFile(raw, file.replace(/\.md$/, '')); + let type = 'unknown'; + let content = parsed.content; + + const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); + if (fmMatch) { + const fm = fmMatch[1]; + const typeMatch = fm.match(/^type:\s*(.+)$/m); + if (typeMatch) { type = typeMatch[1].trim(); } + } + + files.push({ fileName: file, name: parsed.name, description: parsed.description, type, content }); + } catch { /* skip unreadable */ } + } + } catch { /* ignore */ } + return { index, files: files.sort((a, b) => a.name.localeCompare(b.name)) }; + } + + readProjectPlans(projectPath: string): { fileName: string; name: string; description: string; content: string }[] { + const rootCandidates = ['PLAN.md', 'PLANS.md', 'plan.md', 'plans.md']; + const claudeCandidates = rootCandidates.map(file => path.join('.claude', file)); + const planDir = path.join(projectPath, '.claude', 'plans'); + const discovered = new Map(); + + for (const relativePath of [...rootCandidates, ...claudeCandidates]) { + const fullPath = path.join(projectPath, relativePath); + if (fs.existsSync(fullPath)) { + discovered.set(path.basename(relativePath).toLowerCase(), fullPath); + } + } + + if (fs.existsSync(planDir)) { + try { + const planFiles = fs.readdirSync(planDir).filter(file => file.endsWith('.md')); + for (const file of planFiles) { + discovered.set(path.join('plans', file).toLowerCase(), path.join(planDir, file)); + } + } catch { /* ignore */ } + } + + const plans: { fileName: string; name: string; description: string; content: string }[] = []; + for (const fullPath of discovered.values()) { + try { + const raw = fs.readFileSync(fullPath, 'utf-8'); + const parsed = this.parseMarkdownFile(raw, path.basename(fullPath, '.md')); + plans.push({ + fileName: path.relative(projectPath, fullPath), + name: parsed.name, + description: parsed.description, + content: parsed.content, + }); + } catch { /* ignore */ } + } + + return plans.sort((a, b) => a.name.localeCompare(b.name)); + } + readProjectCommands(projectPath: string): { name: string; content: string }[] { const commandsDir = path.join(projectPath, '.claude', 'commands'); if (!fs.existsSync(commandsDir)) { return []; } diff --git a/src/parsers/__tests__/SessionParser.test.ts b/src/parsers/__tests__/SessionParser.test.ts index e2cb725..7e6276c 100644 --- a/src/parsers/__tests__/SessionParser.test.ts +++ b/src/parsers/__tests__/SessionParser.test.ts @@ -60,6 +60,7 @@ describe('SessionParser', () => { expect(result?.sessionSummary).toBe('Hello'); expect(result?.isActiveSession).toBe(false); expect(result?.endTime).toBeGreaterThan(0); + expect(result?.model).toBe('claude-sonnet-4'); }); it('extracts array content, strips command messages, and counts prompts', () => { @@ -105,8 +106,10 @@ describe('SessionParser', () => { expect(cacheResult?.cacheReadTokens).toBe(15000); expect(cacheResult?.cacheHitRate).toBe(83.3); expect(cacheResult?.costUsd).toBeGreaterThan(0); + expect(cacheResult?.model).toBe('claude-sonnet-4'); expect(thinkingResult?.hasThinking).toBe(true); expect(thinkingResult?.thinkingTokens).toBe(5000); + expect(thinkingResult?.model).toBe('claude-opus-4'); }); it('computes idle, active, and duration metrics', () => { diff --git a/src/parsers/__tests__/SettingsParser.test.ts b/src/parsers/__tests__/SettingsParser.test.ts index 19b1f4a..e0e66df 100644 --- a/src/parsers/__tests__/SettingsParser.test.ts +++ b/src/parsers/__tests__/SettingsParser.test.ts @@ -55,6 +55,90 @@ describe('SettingsParser', () => { ]); }); + it('reads project memory index and markdown files with frontmatter', () => { + vi.mocked(fs.existsSync).mockImplementation((file) => !String(file).endsWith('missing/memory')); + vi.mocked(fs.readdirSync).mockReturnValue(asDirEntries(['zeta.md', 'MEMORY.md', 'alpha.md', 'broken.md', 'notes.txt'])); + vi.mocked(fs.readFileSync).mockImplementation((file) => { + const target = String(file); + if (target.endsWith('MEMORY.md')) { + return asReadResult('# Memory Index'); + } + if (target.endsWith('alpha.md')) { + return asReadResult([ + '---', + 'name: Alpha Memory', + 'description: Useful notes', + 'type: user', + '---', + 'Remember the alpha flow.', + ].join('\n')); + } + if (target.endsWith('zeta.md')) { + return asReadResult('Fallback content'); + } + throw new Error('unreadable'); + }); + + expect(parser.readProjectMemory('/claude', 'project-1')).toEqual({ + index: '# Memory Index', + files: [ + { + fileName: 'alpha.md', + name: 'Alpha Memory', + description: 'Useful notes', + type: 'user', + content: 'Remember the alpha flow.', + }, + { + fileName: 'zeta.md', + name: 'zeta', + description: '', + type: 'unknown', + content: 'Fallback content', + }, + ], + }); + }); + + it('reads project plans from common locations and frontmatter', () => { + vi.mocked(fs.existsSync).mockImplementation((file) => { + const target = String(file); + return target.endsWith('PLAN.md') || target.includes('/.claude/plans') || target.endsWith('\\.claude\\plans'); + }); + vi.mocked(fs.readdirSync).mockReturnValue(asDirEntries(['delivery.md', 'notes.txt'])); + vi.mocked(fs.readFileSync).mockImplementation((file) => { + const target = String(file); + if (target.endsWith('PLAN.md')) { + return asReadResult([ + '---', + 'name: Product Plan', + 'description: Main project roadmap', + '---', + '# Plan', + ].join('\n')); + } + if (target.endsWith('delivery.md')) { + return asReadResult('## Delivery Plan'); + } + throw new Error('missing'); + }); + + expect(parser.readProjectPlans('/project')).toEqual([ + { + fileName: '.claude/plans/delivery.md', + name: 'delivery', + description: '', + content: '## Delivery Plan', + }, + { + fileName: '.claude/PLAN.md', + name: 'Product Plan', + description: 'Main project roadmap', + content: '# Plan', + }, + ]); + }); + it('returns null or empty arrays when markdown and command reads fail', () => { vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.readFileSync).mockImplementation(() => { @@ -66,5 +150,15 @@ describe('SettingsParser', () => { expect(parser.readClaudeMd('/project')).toBeNull(); expect(parser.readProjectCommands('/project')).toEqual([]); + expect(parser.readProjectPlans('/project')).toEqual([]); + }); + + it('returns empty memory when the project memory directory is missing', () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + expect(parser.readProjectMemory('/claude', 'missing')).toEqual({ + index: null, + files: [], + }); }); }); diff --git a/src/providers/SidebarProvider.ts b/src/providers/SidebarProvider.ts index b912a7a..8a7cea8 100644 --- a/src/providers/SidebarProvider.ts +++ b/src/providers/SidebarProvider.ts @@ -4,6 +4,7 @@ import { getWebviewContent } from '../webviews/getWebviewContent'; export class SidebarProvider implements vscode.WebviewViewProvider { private view?: vscode.WebviewView; + private selectedProjectId: string | null = null; constructor(private store: DashboardStore, private context: vscode.ExtensionContext) { store.on('updated', () => { @@ -27,15 +28,36 @@ export class SidebarProvider implements vscode.WebviewViewProvider { ); webviewView.webview.onDidReceiveMessage((msg) => { if (msg.type === 'openDashboard') { + this.clearSelectedProject(); vscode.commands.executeCommand('claudeDashboard.openDashboard'); } if (msg.type === 'openProject') { + this.setSelectedProject(msg.projectId); vscode.commands.executeCommand('claudeDashboard.openProject', msg.projectId); } }); } + setSelectedProject(projectId: string | null) { + this.selectedProjectId = projectId; + this.postStateUpdate(); + } + + clearSelectedProject() { + this.setSelectedProject(null); + } + + private postStateUpdate() { + if (this.view) { + this.view.webview.postMessage({ type: 'stateUpdate', payload: this.buildState() }); + } + } + private buildState() { - return { projects: this.store.getProjects(), stats: this.store.getStats() }; + return { + projects: this.store.getProjects(), + stats: this.store.getStats(), + selectedProjectId: this.selectedProjectId, + }; } } diff --git a/src/store/DashboardStore.ts b/src/store/DashboardStore.ts index a2e9444..8137cc7 100644 --- a/src/store/DashboardStore.ts +++ b/src/store/DashboardStore.ts @@ -1,6 +1,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import { execSync } from 'child_process'; import { EventEmitter } from 'events'; import { SessionParser } from '../parsers/SessionParser'; import { SettingsParser } from '../parsers/SettingsParser'; @@ -108,6 +109,7 @@ export interface Session { idleTimeMs: number | null; // sum of gaps >5min between assistant→user turns activeTimeMs: number | null; // durationMs - idleTimeMs activityRatio: number | null; // activeTimeMs / durationMs * 100 + model: string | null; // detected model (e.g. 'claude-opus-4', 'claude-sonnet-4') } export interface Turn { @@ -161,11 +163,57 @@ export interface McpServer { toolCallCount: number; // total calls across all project sessions (0 if unused) } +export interface MemoryFile { + fileName: string; + name: string; + description: string; + type: string; + content: string; +} + +export interface ProjectMemory { + index: string | null; + files: MemoryFile[]; +} + +export interface PlanFile { + fileName: string; + name: string; + description: string; + content: string; +} + +export interface ClaudeCommit { + hash: string; + shortHash: string; + author: string; + date: number; // unix timestamp ms + subject: string; + filesChanged: number; +} + +export interface SessionTodoSnapshot { + sessionId: string; + sessionDate: number; + sessionSummary: string | null; + todos: { content: string; status: string }[]; + timestamp: number; +} + +export interface HookConfig { + event: string; // e.g. 'PostToolUse', 'PreToolUse', 'Stop' + matcher?: string; // optional tool name matcher + command: string; // shell command to run +} + export interface ProjectConfig { claudeMd: string | null; mcpServers: Record; projectSettings: Record; commands: { name: string; content: string }[]; + plans: PlanFile[]; + memory: ProjectMemory; + hooks: HookConfig[]; } const CACHE_VERSION = 2; @@ -464,6 +512,10 @@ export class DashboardStore extends EventEmitter { return this.subagentSessions.get(projectId) ?? []; } + private getSessionTotalCost(session: Session): number { + return session.costUsd + (session.subagentCostUsd ?? 0); + } + getStats(): DashboardStats { const now = Date.now(); const dayMs = 86_400_000; @@ -480,8 +532,8 @@ export class DashboardStore extends EventEmitter { const sessions = this.getSessions(project.id); for (const session of sessions) { if (session.isActiveSession) { activeSessionCount++; } - if (session.startTime > now - dayMs) { tokensTodayTotal += session.totalTokens; costTodayUsd += session.costUsd; } - if (session.startTime > now - weekMs) { tokensWeekTotal += session.totalTokens; costWeekUsd += session.costUsd; } + if (session.startTime > now - dayMs) { tokensTodayTotal += session.totalTokens; costTodayUsd += this.getSessionTotalCost(session); } + if (session.startTime > now - weekMs) { tokensWeekTotal += session.totalTokens; costWeekUsd += this.getSessionTotalCost(session); } } } @@ -512,7 +564,7 @@ export class DashboardStore extends EventEmitter { for (const session of sessions) { if (session.startTime >= dayStart && session.startTime < dayEnd) { tokens += session.totalTokens; - costUsd += session.costUsd; + costUsd += this.getSessionTotalCost(session); } } } @@ -631,7 +683,7 @@ export class DashboardStore extends EventEmitter { getProjectConfig(projectId: string): ProjectConfig { const project = this.projects.get(projectId); if (!project) { - return { claudeMd: null, mcpServers: {}, projectSettings: {}, commands: [] }; + return { claudeMd: null, mcpServers: {}, projectSettings: {}, commands: [], plans: [], memory: { index: null, files: [] }, hooks: [] }; } const claudeMd = this.settingsParser.readClaudeMd(project.path); @@ -675,15 +727,123 @@ export class DashboardStore extends EventEmitter { } const commands = this.settingsParser.readProjectCommands(project.path); + const plans = this.settingsParser.readProjectPlans(project.path); + const memory = this.settingsParser.readProjectMemory(this.claudeDir, projectId); + + // Extract hooks from global and project settings + const hooks: HookConfig[] = []; + const rawGlobalHooks = (globalSettings.hooks ?? {}) as Record; + const rawProjectHooks = (projectSettings.hooks ?? {}) as Record; + const mergedHooks = { ...rawGlobalHooks, ...rawProjectHooks }; + for (const [event, entries] of Object.entries(mergedHooks)) { + if (!Array.isArray(entries)) { continue; } + for (const entry of entries) { + const e = entry as Record; + if (typeof e.command === 'string') { + hooks.push({ + event, + matcher: typeof e.matcher === 'string' ? e.matcher : undefined, + command: e.command, + }); + } + } + } return { claudeMd, mcpServers, projectSettings: projectSettings as Record, commands, + plans, + memory, + hooks, }; } + getProjectTodos(projectId: string): SessionTodoSnapshot[] { + const sessions = this.getSessions(projectId); + const results: SessionTodoSnapshot[] = []; + + for (const session of sessions) { + // Find the last TodoWrite call in this session to get final state + let lastTodoCall: ToolCall | null = null; + let lastTodoTimestamp = 0; + for (const turn of session.turns) { + for (const tc of turn.toolCalls) { + if (tc.name === 'TodoWrite' && tc.input?.todos) { + lastTodoCall = tc; + lastTodoTimestamp = turn.timestamp; + } + } + } + if (lastTodoCall && Array.isArray(lastTodoCall.input.todos)) { + const todos = (lastTodoCall.input.todos as Array<{ content?: string; status?: string; activeForm?: string }>) + .map(t => ({ + content: (t.content as string) ?? '', + status: (t.status as string) ?? 'pending', + })); + results.push({ + sessionId: session.id, + sessionDate: session.startTime, + sessionSummary: session.sessionSummary, + todos, + timestamp: lastTodoTimestamp, + }); + } + } + + results.sort((a, b) => b.timestamp - a.timestamp); + return results; + } + + getClaudeCommits(projectId: string): ClaudeCommit[] { + const project = this.projects.get(projectId); + if (!project) { return []; } + + try { + // Use git log with --grep to find commits co-authored by Claude + const raw = execSync( + 'git log --all --grep="Co-Authored-By:.*Claude" --format="%H|%an|%at|%s" --shortstat -100', + { cwd: project.path, timeout: 5000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] } + ); + + const commits: ClaudeCommit[] = []; + const lines = raw.trim().split('\n').filter(Boolean); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (!line.includes('|')) { continue; } + const [hash, author, atStr, ...subjectParts] = line.split('|'); + const subject = subjectParts.join('|'); + + // Next line might be the --shortstat line + let filesChanged = 0; + if (i + 1 < lines.length) { + const statLine = lines[i + 1]; + const filesMatch = statLine.match(/(\d+) file/); + if (filesMatch) { + filesChanged = parseInt(filesMatch[1], 10); + i++; // skip the stat line + } + } + + commits.push({ + hash, + shortHash: hash.slice(0, 8), + author, + date: parseInt(atStr, 10) * 1000, + subject, + filesChanged, + }); + } + + return commits; + } catch { + // Not a git repo or git not available + return []; + } + } + getMonthlyTokens(): number { return this.getMonthlyUsage().tokens; } @@ -699,7 +859,7 @@ export class DashboardStore extends EventEmitter { for (const session of sessions) { if (session.startTime >= monthStart) { tokens += session.totalTokens; - costUsd += session.costUsd + (session.subagentCostUsd ?? 0); + costUsd += this.getSessionTotalCost(session); } } } @@ -833,7 +993,7 @@ export class DashboardStore extends EventEmitter { for (const [, sessions] of this.sessions) { for (const session of sessions) { if (session.startTime >= monthStart) { - currentMonthCost += session.costUsd; + currentMonthCost += this.getSessionTotalCost(session); } } } @@ -960,7 +1120,7 @@ export class DashboardStore extends EventEmitter { if (session.startTime < weekAgo) { continue; } sessions++; tokens += session.totalTokens; - costUsd += session.costUsd; + costUsd += this.getSessionTotalCost(session); session.filesModified.forEach(f => filesModifiedSet.add(f)); projectsInWeek.add(project.id); @@ -1057,7 +1217,7 @@ export class DashboardStore extends EventEmitter { for (const s of sessions) { if (s.startTime >= dayStart && s.startTime < dayEnd) { tokens += s.totalTokens; - costUsd += s.costUsd; + costUsd += this.getSessionTotalCost(s); } } usageOverTime.push({ date: dateStr, tokens, costUsd }); @@ -1143,7 +1303,7 @@ export class DashboardStore extends EventEmitter { for (const s of sessions) { if (s.startTime >= dayStart && s.startTime < dayEnd) { tokens += s.totalTokens; - costUsd += s.costUsd; + costUsd += this.getSessionTotalCost(s); sessionsCount++; } } @@ -1152,7 +1312,7 @@ export class DashboardStore extends EventEmitter { const weeklyStats = { sessions: weeklySessions.length, tokens: weeklySessions.reduce((sum, s) => sum + s.totalTokens, 0), - costUsd: weeklySessions.reduce((sum, s) => sum + s.costUsd, 0), + costUsd: weeklySessions.reduce((sum, s) => sum + this.getSessionTotalCost(s), 0), dailyBreakdown: weeklyDailyBreakdown, }; diff --git a/src/store/__tests__/DashboardStore.test.ts b/src/store/__tests__/DashboardStore.test.ts index 482276f..bd5b74f 100644 --- a/src/store/__tests__/DashboardStore.test.ts +++ b/src/store/__tests__/DashboardStore.test.ts @@ -1,10 +1,12 @@ import * as fs from 'fs'; +import { execSync } from 'child_process'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { DashboardStore } from '../DashboardStore'; import { createPopulatedStore } from '../../__tests__/helpers/store-helpers'; import { makeProject, makeSession, makeToolCall, makeTurn } from '../../__tests__/fixtures/sessions'; vi.mock('fs'); +vi.mock('child_process', () => ({ execSync: vi.fn() })); type SettingsParserLike = { readClaudeMd(projectPath: string): string | null; @@ -13,6 +15,11 @@ type SettingsParserLike = { readUserClaudeJson(homeDir: string): Record; readProjectMcpJson(projectPath: string): Record; readProjectCommands(projectPath: string): { name: string; content: string }[]; + readProjectPlans(projectPath: string): { fileName: string; name: string; description: string; content: string }[]; + readProjectMemory(claudeDir: string, projectId: string): { + index: string | null; + files: { fileName: string; name: string; description: string; type: string; content: string }[]; + }; }; type StoreWithSettingsParser = { settingsParser: SettingsParserLike; @@ -110,8 +117,11 @@ describe('DashboardStore', () => { expect(stats.activeSessionCount).toBe(1); expect(stats.tokensTodayTotal).toBe(5000); expect(stats.tokensWeekTotal).toBe(8000); + expect(stats.costTodayUsd).toBeCloseTo(0.5); + expect(stats.costWeekUsd).toBeCloseTo(0.6); expect(usage).toHaveLength(7); expect(usage[6].tokens).toBe(5000); + expect(usage[6].costUsd).toBeCloseTo(0.5); expect(monthly).toEqual({ tokens: 17000, costUsd: 1.1 }); }); @@ -138,35 +148,191 @@ describe('DashboardStore', () => { const files = store.getProjectFiles('p1'); const productivity = store.getProductivityByHour(); - expect(projected.currentMonthCost).toBeCloseTo(0.8); + expect(projected.currentMonthCost).toBeCloseTo(1.1); expect(streak.totalActiveDays).toBe(3); expect(efficiency.avgToolCallsPerSession).toBeCloseTo(1.0); expect(recap.sessions).toBe(2); + expect(recap.costUsd).toBeCloseTo(0.6); expect(recap.topProject).toBe('Alpha'); expect(changes[0]?.type).toBe('modified'); expect(projectStats.toolUsage[0].tool).toBe('Edit'); expect(projectStats.recentToolCalls[0].tool).toBe('Edit'); + expect(projectStats.weeklyStats.costUsd).toBeCloseTo(0.6); expect(files[0]).toMatchObject({ fullPath: '/app/index.ts', type: 'modified' }); expect(productivity).toHaveLength(24); }); - it('merges config sources and MCP tool counts', () => { + it('merges config sources, memory, hooks, and MCP tool counts', () => { const store = makeStore(); const settingsParser = (store as unknown as StoreWithSettingsParser).settingsParser; vi.spyOn(settingsParser, 'readClaudeMd').mockReturnValue('# Rules'); - vi.spyOn(settingsParser, 'readProjectSettings').mockReturnValue({ mcpServers: { github: { command: 'from-project' } }, project: true }); - vi.spyOn(settingsParser, 'readGlobalSettings').mockReturnValue({ mcpServers: { github: { command: 'from-global' }, local: { type: 'stdio' } } }); + vi.spyOn(settingsParser, 'readProjectSettings').mockReturnValue({ + mcpServers: { github: { command: 'from-project' } }, + project: true, + hooks: { + Stop: [{ matcher: 'Edit', command: 'echo project-stop' }], + PostToolUse: [{ command: 'echo post-tool' }], + }, + }); + vi.spyOn(settingsParser, 'readGlobalSettings').mockReturnValue({ + mcpServers: { github: { command: 'from-global' }, local: { type: 'stdio' } }, + hooks: { + Stop: [{ command: 'echo global-stop' }], + PreToolUse: 'skip-me' as unknown as unknown[], + }, + }); vi.spyOn(settingsParser, 'readUserClaudeJson').mockReturnValue({ mcpServers: { user: { url: 'http://localhost' } } }); vi.spyOn(settingsParser, 'readProjectMcpJson').mockReturnValue({ mcpServers: { github: { command: 'from-mcp-json' } } }); vi.spyOn(settingsParser, 'readProjectCommands').mockReturnValue([{ name: 'deploy', content: 'Deploy it' }]); + vi.spyOn(settingsParser, 'readProjectPlans').mockReturnValue([{ fileName: 'PLAN.md', name: 'Execution Plan', description: 'Current roadmap', content: '# Plan' }]); + vi.spyOn(settingsParser, 'readProjectMemory').mockReturnValue({ + index: '# Memory', + files: [{ fileName: 'prefs.md', name: 'prefs', description: 'Saved preferences', type: 'user', content: 'Use tests first.' }], + }); const config = store.getProjectConfig('p1'); expect(config.claudeMd).toBe('# Rules'); - expect(config.projectSettings).toEqual({ mcpServers: { github: { command: 'from-project' } }, project: true }); + expect(config.projectSettings).toEqual({ + mcpServers: { github: { command: 'from-project' } }, + project: true, + hooks: { + Stop: [{ matcher: 'Edit', command: 'echo project-stop' }], + PostToolUse: [{ command: 'echo post-tool' }], + }, + }); expect(config.mcpServers.github.command).toBe('from-mcp-json'); expect(config.mcpServers.github.toolCallCount).toBe(2); expect(config.commands).toEqual([{ name: 'deploy', content: 'Deploy it' }]); + expect(config.plans).toEqual([{ fileName: 'PLAN.md', name: 'Execution Plan', description: 'Current roadmap', content: '# Plan' }]); + expect(config.memory).toEqual({ + index: '# Memory', + files: [{ fileName: 'prefs.md', name: 'prefs', description: 'Saved preferences', type: 'user', content: 'Use tests first.' }], + }); + expect(config.hooks).toEqual([ + { event: 'Stop', matcher: 'Edit', command: 'echo project-stop' }, + { event: 'PostToolUse', matcher: undefined, command: 'echo post-tool' }, + ]); + }); + + it('returns todo snapshots using the latest TodoWrite state per session', () => { + const project = makeProject({ id: 'todos', name: 'Todos', lastActive: NOW }); + const todoSession = makeSession({ + id: 'todo-session', + projectId: 'todos', + startTime: NOW - HOUR, + sessionSummary: 'Prepare release', + turns: [ + makeTurn({ + role: 'assistant', + timestamp: NOW - HOUR + 1000, + toolCalls: [ + makeToolCall({ + name: 'TodoWrite', + input: { todos: [{ content: 'Draft notes', status: 'pending' }] }, + }), + ], + }), + makeTurn({ + role: 'assistant', + timestamp: NOW - HOUR + 3000, + toolCalls: [ + makeToolCall({ + name: 'TodoWrite', + input: { + todos: [ + { content: 'Draft notes', status: 'completed' }, + { content: 'Tag release' }, + ], + }, + }), + ], + }), + ], + }); + const olderTodoSession = makeSession({ + id: 'older-todo-session', + projectId: 'todos', + startTime: NOW - 2 * HOUR, + sessionSummary: 'Older todos', + turns: [ + makeTurn({ + role: 'assistant', + timestamp: NOW - 2 * HOUR + 1000, + toolCalls: [ + makeToolCall({ + name: 'TodoWrite', + input: { todos: [{ content: 'Backfill docs', status: 'in_progress' }] }, + }), + ], + }), + ], + }); + const ignoredSession = makeSession({ + id: 'ignored', + projectId: 'todos', + turns: [makeTurn({ role: 'assistant', toolCalls: [makeToolCall({ name: 'Read' })] })], + }); + const store = createPopulatedStore([project], { todos: [todoSession, olderTodoSession, ignoredSession] }); + + expect(store.getProjectTodos('todos')).toEqual([ + { + sessionId: 'todo-session', + sessionDate: NOW - HOUR, + sessionSummary: 'Prepare release', + todos: [ + { content: 'Draft notes', status: 'completed' }, + { content: 'Tag release', status: 'pending' }, + ], + timestamp: NOW - HOUR + 3000, + }, + { + sessionId: 'older-todo-session', + sessionDate: NOW - 2 * HOUR, + sessionSummary: 'Older todos', + todos: [{ content: 'Backfill docs', status: 'in_progress' }], + timestamp: NOW - 2 * HOUR + 1000, + }, + ]); + }); + + it('parses Claude co-authored commits and handles missing git data', () => { + const project = makeProject({ id: 'git-project', path: '/repo/git-project' }); + const store = createPopulatedStore([project], { 'git-project': [] }); + vi.mocked(execSync).mockReturnValue([ + 'abcdef1234567890|Alice|1736940000|feat: add tests', + ' 2 files changed, 10 insertions(+)', + '1122334455667788|Bob|1736936400|fix: handle pipes | safely', + ].join('\n') as ReturnType); + + expect(store.getClaudeCommits('missing')).toEqual([]); + expect(store.getClaudeCommits('git-project')).toEqual([ + { + hash: 'abcdef1234567890', + shortHash: 'abcdef12', + author: 'Alice', + date: 1736940000 * 1000, + subject: 'feat: add tests', + filesChanged: 2, + }, + { + hash: '1122334455667788', + shortHash: '11223344', + author: 'Bob', + date: 1736936400 * 1000, + subject: 'fix: handle pipes | safely', + filesChanged: 0, + }, + ]); + expect(execSync).toHaveBeenCalledWith( + 'git log --all --grep="Co-Authored-By:.*Claude" --format="%H|%an|%at|%s" --shortstat -100', + expect.objectContaining({ cwd: '/repo/git-project', encoding: 'utf-8', timeout: 5000 }), + ); + + vi.mocked(execSync).mockImplementation(() => { + throw new Error('git unavailable'); + }); + expect(store.getClaudeCommits('git-project')).toEqual([]); }); it('handles live events and debounced updates', async () => { @@ -186,7 +352,7 @@ describe('DashboardStore', () => { it('covers empty-state getters and prompt categorization branches', () => { const emptyStore = createPopulatedStore([], {}); - expect(emptyStore.getProjectConfig('missing')).toEqual({ claudeMd: null, mcpServers: {}, projectSettings: {}, commands: [] }); + expect(emptyStore.getProjectConfig('missing')).toEqual({ claudeMd: null, mcpServers: {}, projectSettings: {}, commands: [], plans: [], memory: { index: null, files: [] }, hooks: [] }); expect(emptyStore.getMonthlyTokens()).toBe(0); expect(emptyStore.getToolUsageStats()).toEqual([]); expect(emptyStore.getStreak()).toEqual({ currentStreak: 0, longestStreak: 0, totalActiveDays: 0 }); diff --git a/src/webviews/ProjectPanel.ts b/src/webviews/ProjectPanel.ts index 80179b8..be93722 100644 --- a/src/webviews/ProjectPanel.ts +++ b/src/webviews/ProjectPanel.ts @@ -77,6 +77,8 @@ export class ProjectPanel { config: store.getProjectConfig(projectId), projectStats: store.getProjectStats(projectId), projectFiles: store.getProjectFiles(projectId), + projectTodos: store.getProjectTodos(projectId), + claudeCommits: store.getClaudeCommits(projectId), }; } } diff --git a/src/webviews/__tests__/ProjectPanel.test.ts b/src/webviews/__tests__/ProjectPanel.test.ts index 8fadb90..9a6173c 100644 --- a/src/webviews/__tests__/ProjectPanel.test.ts +++ b/src/webviews/__tests__/ProjectPanel.test.ts @@ -14,6 +14,8 @@ type ProjectStoreMock = { getProjectConfig: ReturnType; getProjectStats: ReturnType; getProjectFiles: ReturnType; + getProjectTodos: ReturnType; + getClaudeCommits: ReturnType; }; type ProjectStatsLike = { toolUsage: unknown[]; @@ -55,7 +57,7 @@ describe('ProjectPanel', () => { getProject: vi.fn(() => ({ id: 'p1', name: 'Alpha' } as unknown as Project)), getSessions: vi.fn(() => sessions), getSubagentSessions: vi.fn(() => [{ id: 'sub1', turns: [{ id: 'st1' }], startTime: 1 }] as unknown as Session[]), - getProjectConfig: vi.fn(() => ({ claudeMd: null, mcpServers: {}, projectSettings: {}, commands: [] } as ProjectConfig)), + getProjectConfig: vi.fn(() => ({ claudeMd: null, mcpServers: {}, projectSettings: {}, commands: [], plans: [], memory: { index: null, files: [] }, hooks: [] } as ProjectConfig)), getProjectStats: vi.fn(() => ({ toolUsage: [], usageOverTime: [], @@ -71,6 +73,8 @@ describe('ProjectPanel', () => { weeklyStats: { sessions: 0, tokens: 0, costUsd: 0, dailyBreakdown: [] }, } as ProjectStatsLike)), getProjectFiles: vi.fn(() => []), + getProjectTodos: vi.fn(() => []), + getClaudeCommits: vi.fn(() => []), }; vi.mocked(vscode.window.createWebviewPanel).mockImplementation(() => ({ webview: { @@ -111,7 +115,7 @@ describe('ProjectPanel', () => { getProject: vi.fn(() => undefined), getSessions: vi.fn(() => []), getSubagentSessions: vi.fn(() => []), - getProjectConfig: vi.fn(() => ({ claudeMd: null, mcpServers: {}, projectSettings: {}, commands: [] } as ProjectConfig)), + getProjectConfig: vi.fn(() => ({ claudeMd: null, mcpServers: {}, projectSettings: {}, commands: [], plans: [], memory: { index: null, files: [] }, hooks: [] } as ProjectConfig)), getProjectStats: vi.fn(() => ({ toolUsage: [], usageOverTime: [], @@ -127,6 +131,8 @@ describe('ProjectPanel', () => { weeklyStats: { sessions: 0, tokens: 0, costUsd: 0, dailyBreakdown: [] }, } as ProjectStatsLike)), getProjectFiles: vi.fn(() => []), + getProjectTodos: vi.fn(() => []), + getClaudeCommits: vi.fn(() => []), }; vi.mocked(vscode.window.createWebviewPanel).mockImplementation(() => ({ diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index 48bb7a2..6c9f576 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -8,7 +8,7 @@ import { PatternCount, ToolUsageStat, HotFile, ProjectedCost, StreakData, EfficiencyStats, WeeklyRecap, RecentFileChange, ProductivityHour, BudgetStatus, - ProjectStats, ProjectFile, + ProjectStats, ProjectFile, SessionTodoSnapshot, ClaudeCommit, } from './types'; declare global { @@ -34,20 +34,22 @@ export default function App() { }, []); if (view === 'sidebar') { - const { projects, stats } = data as { projects: Project[]; stats: DashboardStats }; - return ; + const { projects, stats, selectedProjectId } = data as { projects: Project[]; stats: DashboardStats; selectedProjectId?: string | null }; + return ; } if (view === 'project') { - const { project, sessions, subagentSessions, config, projectStats, projectFiles } = data as { + const { project, sessions, subagentSessions, config, projectStats, projectFiles, projectTodos, claudeCommits } = data as { project: Project; sessions: Session[]; subagentSessions?: Session[]; config?: ProjectConfig; projectStats?: ProjectStats; projectFiles?: ProjectFile[]; + projectTodos?: SessionTodoSnapshot[]; + claudeCommits?: ClaudeCommit[]; }; - return ; + return ; } const { diff --git a/webview-ui/src/__tests__/fixtures/test-data.ts b/webview-ui/src/__tests__/fixtures/test-data.ts index 3dac9de..c74656a 100644 --- a/webview-ui/src/__tests__/fixtures/test-data.ts +++ b/webview-ui/src/__tests__/fixtures/test-data.ts @@ -32,6 +32,7 @@ export function makeSession(overrides: Partial = {}): Session { sessionSummary: 'Fix bug', hasThinking: false, thinkingTokens: 0, cacheHitRate: 76.9, subagentCostUsd: 0, idleTimeMs: null, activeTimeMs: null, activityRatio: null, + model: null, ...overrides, }; } diff --git a/webview-ui/src/components/MarkdownView.tsx b/webview-ui/src/components/MarkdownView.tsx index 637e12c..e16d30a 100644 --- a/webview-ui/src/components/MarkdownView.tsx +++ b/webview-ui/src/components/MarkdownView.tsx @@ -1,11 +1,42 @@ import React from 'react'; -function renderInline(text: string): React.ReactNode[] { - const parts = text.split(/(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*)/); +function renderInline( + text: string, + onLinkClick?: (href: string) => void, +): React.ReactNode[] { + const parts = text.split(/(`[^`]+`|\[[^\]]+\]\([^)]+\)|\*\*[^*]+\*\*|\*[^*]+\*)/); return parts.map((part, i) => { + if (!part) { + return null; + } if (part.startsWith('`') && part.endsWith('`') && part.length > 2) { return {part.slice(1, -1)}; } + const linkMatch = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/); + if (linkMatch) { + const [, label, href] = linkMatch; + if (onLinkClick) { + return ( + + ); + } + return ( + + {label} + + ); + } if (part.startsWith('**') && part.endsWith('**') && part.length > 4) { return {part.slice(2, -2)}; } @@ -13,10 +44,18 @@ function renderInline(text: string): React.ReactNode[] { return {part.slice(1, -1)}; } return part as unknown as React.ReactNode; - }); + }).filter(Boolean); } -export function MarkdownView({ content, compact = false }: { content: string; compact?: boolean }) { +export function MarkdownView({ + content, + compact = false, + onLinkClick, +}: { + content: string; + compact?: boolean; + onLinkClick?: (href: string) => void; +}) { const lines = content.split('\n'); const elements: React.ReactNode[] = []; let i = 0; @@ -42,15 +81,15 @@ export function MarkdownView({ content, compact = false }: { content: string; co const h3 = line.match(/^### (.+)/); const h2 = line.match(/^## (.+)/); const h1 = line.match(/^# (.+)/); - if (h1) { elements.push(

{renderInline(h1[1])}

); i++; continue; } - if (h2) { elements.push(

{renderInline(h2[1])}

); i++; continue; } - if (h3) { elements.push(

{renderInline(h3[1])}

); i++; continue; } + if (h1) { elements.push(

{renderInline(h1[1], onLinkClick)}

); i++; continue; } + if (h2) { elements.push(

{renderInline(h2[1], onLinkClick)}

); i++; continue; } + if (h3) { elements.push(

{renderInline(h3[1], onLinkClick)}

); i++; continue; } // Bullet list if (line.match(/^[\-\*] /)) { const items: React.ReactNode[] = []; while (i < lines.length && lines[i].match(/^[\-\*] /)) { - items.push(
  • {renderInline(lines[i].slice(2))}
  • ); + items.push(
  • {renderInline(lines[i].slice(2), onLinkClick)}
  • ); i++; } elements.push(
      {items}
    ); @@ -61,7 +100,7 @@ export function MarkdownView({ content, compact = false }: { content: string; co if (line.match(/^\d+\. /)) { const items: React.ReactNode[] = []; while (i < lines.length && lines[i].match(/^\d+\. /)) { - items.push(
  • {renderInline(lines[i].replace(/^\d+\. /, ''))}
  • ); + items.push(
  • {renderInline(lines[i].replace(/^\d+\. /, ''), onLinkClick)}
  • ); i++; } elements.push(
      {items}
    ); @@ -89,7 +128,7 @@ export function MarkdownView({ content, compact = false }: { content: string; co !lines[i].match(/^---+$/) ) { paraLines.push(lines[i]); i++; } if (paraLines.length) { - elements.push(

    {renderInline(paraLines.join(' '))}

    ); + elements.push(

    {renderInline(paraLines.join(' '), onLinkClick)}

    ); } } diff --git a/webview-ui/src/components/PatternChart.tsx b/webview-ui/src/components/PatternChart.tsx index 3b98f65..79b02f2 100644 --- a/webview-ui/src/components/PatternChart.tsx +++ b/webview-ui/src/components/PatternChart.tsx @@ -28,7 +28,11 @@ interface TooltipPayload { payload: PatternCount; } -function CustomTooltip({ active, payload }: { active?: boolean; payload?: TooltipPayload[] }) { +export function getPatternCategoryColor(category: string): string { + return CATEGORY_COLORS[category] ?? '#6366f1'; +} + +export function PatternChartTooltip({ active, payload }: { active?: boolean; payload?: TooltipPayload[] }) { if (!active || !payload || !payload.length) return null; const d = payload[0].payload; return ( @@ -67,10 +71,10 @@ export default function PatternChart({ data }: Props) { width={36} allowDecimals={false} /> - } /> + } /> {filtered.map((entry, index) => ( - + ))} diff --git a/webview-ui/src/components/ProductivityChart.tsx b/webview-ui/src/components/ProductivityChart.tsx index 311bb69..d1ce995 100644 --- a/webview-ui/src/components/ProductivityChart.tsx +++ b/webview-ui/src/components/ProductivityChart.tsx @@ -20,9 +20,17 @@ interface TooltipPayload { color: string; } -function CustomTooltip({ active, payload, label }: { active?: boolean; payload?: TooltipPayload[]; label?: number }) { +export function formatProductivityHour(label?: number): string { + return label !== undefined ? `${String(label).padStart(2, '0')}:00` : ''; +} + +export function formatProductivityTick(value: number): string { + return `${String(value).padStart(2, '0')}h`; +} + +export function ProductivityChartTooltip({ active, payload, label }: { active?: boolean; payload?: TooltipPayload[]; label?: number }) { if (!active || !payload || !payload.length) { return null; } - const hourStr = label !== undefined ? `${String(label).padStart(2, '0')}:00` : ''; + const hourStr = formatProductivityHour(label); return (
    `${String(v).padStart(2, '0')}h`} + tickFormatter={formatProductivityTick} interval={2} /> - } /> + } /> diff --git a/webview-ui/src/components/ProjectBarChart.tsx b/webview-ui/src/components/ProjectBarChart.tsx index 11c0d9b..c36a841 100644 --- a/webview-ui/src/components/ProjectBarChart.tsx +++ b/webview-ui/src/components/ProjectBarChart.tsx @@ -14,7 +14,7 @@ interface Props { data: ProjectUsage[]; } -function formatTokens(n: number): string { +export function formatProjectBarTokens(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`; return String(n); @@ -30,7 +30,7 @@ interface TooltipPayload { payload: ProjectUsage; } -function CustomTooltip({ active, payload }: { active?: boolean; payload?: TooltipPayload[] }) { +export function ProjectBarChartTooltip({ active, payload }: { active?: boolean; payload?: TooltipPayload[] }) { if (!active || !payload || !payload.length) return null; const d = payload[0].payload; return ( @@ -42,7 +42,7 @@ function CustomTooltip({ active, payload }: { active?: boolean; payload?: Toolti fontSize: 12, }}>
    {d.name}
    -
    {formatTokens(d.tokens)} tokens
    +
    {formatProjectBarTokens(d.tokens)} tokens
    ${d.costUsd.toFixed(4)}
    ); @@ -66,7 +66,7 @@ export default function ProjectBarChart({ data }: Props) { tick={{ fontSize: 10, opacity: 0.6 }} axisLine={false} tickLine={false} - tickFormatter={formatTokens} + tickFormatter={formatProjectBarTokens} /> - } /> + } /> {truncated.map((_, index) => ( diff --git a/webview-ui/src/components/ProjectCard.tsx b/webview-ui/src/components/ProjectCard.tsx index 3798a9c..6e021ad 100644 --- a/webview-ui/src/components/ProjectCard.tsx +++ b/webview-ui/src/components/ProjectCard.tsx @@ -6,13 +6,13 @@ interface Props { project: Project; } -function formatTokens(n: number): string { +export function formatProjectCardTokens(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; return String(n); } -function timeAgo(ts: number): string { +export function projectCardTimeAgo(ts: number): string { if (!ts) return 'never'; const diff = Date.now() - ts; const h = Math.floor(diff / 3_600_000); @@ -35,9 +35,9 @@ export default function ProjectCard({ project }: Props) {
    {project.path}
    - {formatTokens(project.totalTokens)} tokens + {formatProjectCardTokens(project.totalTokens)} tokens {project.sessionCount} sessions - {timeAgo(project.lastActive)} + {projectCardTimeAgo(project.lastActive)}
    ); diff --git a/webview-ui/src/components/SessionDetail.tsx b/webview-ui/src/components/SessionDetail.tsx index c2d9cd3..86115cb 100644 --- a/webview-ui/src/components/SessionDetail.tsx +++ b/webview-ui/src/components/SessionDetail.tsx @@ -12,9 +12,9 @@ type SystemEvent = // eslint-disable-next-line no-control-regex const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]/g; -function stripAnsi(s: string): string { return s.replace(ANSI_RE, ''); } +export function stripAnsi(s: string): string { return s.replace(ANSI_RE, ''); } -function parseSystemContent(content: string): SystemEvent | 'skip' | null { +export function parseSystemContent(content: string): SystemEvent | 'skip' | null { const t = content.trim(); if (//i.test(t) && !t.replace(/[\s\S]*?<\/local-command-caveat>/gi, '').trim()) { return 'skip'; @@ -246,12 +246,32 @@ function TurnBlock({ turn }: { turn: Turn }) { // ── SessionDetail ────────────────────────────────────────────────────────────── +export function modelLabel(model: string | null): string | null { + if (!model) return null; + if (model.includes('opus')) return 'Opus'; + if (model.includes('haiku')) return 'Haiku'; + if (model.includes('sonnet')) return 'Sonnet'; + return null; +} + +export function modelBadgeColor(model: string | null): string { + if (!model) return ''; + if (model.includes('opus')) return 'text-purple-400 bg-purple-500/15'; + if (model.includes('haiku')) return 'text-orange-400 bg-orange-500/15'; + return 'text-blue-400 bg-blue-500/15'; +} + export default function SessionDetail({ session, turns, loading }: { session: Session; turns: Turn[]; loading: boolean }) { const totalCost = session.costUsd + (session.subagentCostUsd ?? 0); return (
    {new Date(session.startTime).toLocaleString([], { hour12: true })} + {modelLabel(session.model) && ( + + {modelLabel(session.model)} + + )} · {formatDuration(session.durationMs)} · @@ -259,7 +279,7 @@ export default function SessionDetail({ session, turns, loading }: { session: Se {formatTokens(session.totalTokens)} tokens · - ${totalCost.toFixed(4)} + est. ${totalCost.toFixed(4)} {(session.cacheReadTokens ?? 0) > 0 && ( +{formatTokens(session.cacheReadTokens)} cached diff --git a/webview-ui/src/components/SessionList.tsx b/webview-ui/src/components/SessionList.tsx index 997af47..de1ca35 100644 --- a/webview-ui/src/components/SessionList.tsx +++ b/webview-ui/src/components/SessionList.tsx @@ -23,6 +23,21 @@ function formatTokens(n: number): string { return String(n); } +function modelLabel(model: string | null): string | null { + if (!model) return null; + if (model.includes('opus')) return 'Opus'; + if (model.includes('haiku')) return 'Haiku'; + if (model.includes('sonnet')) return 'Sonnet'; + return null; +} + +function modelBadgeColor(model: string | null): string { + if (!model) return ''; + if (model.includes('opus')) return 'text-purple-400 bg-purple-500/15'; + if (model.includes('haiku')) return 'text-orange-400 bg-orange-500/15'; + return 'text-blue-400 bg-blue-500/15'; +} + export default function SessionList({ sessions, selectedId, onSelect }: Props) { const sorted = [...sessions].sort((a, b) => b.startTime - a.startTime); @@ -38,7 +53,14 @@ export default function SessionList({ sessions, selectedId, onSelect }: Props) { : 'hover:bg-[var(--vscode-list-hoverBackground)]' }`} > -
    {new Date(s.startTime).toLocaleDateString()}
    +
    + {new Date(s.startTime).toLocaleDateString()} + {modelLabel(s.model) && ( + + {modelLabel(s.model)} + + )} +
    {formatDuration(s.durationMs)} · {formatTokens(s.totalTokens)} tokens · {s.promptCount} prompts {s.activityRatio !== null && s.activityRatio !== undefined && ( diff --git a/webview-ui/src/components/UsageLineChart.tsx b/webview-ui/src/components/UsageLineChart.tsx index 8e4302c..21ee565 100644 --- a/webview-ui/src/components/UsageLineChart.tsx +++ b/webview-ui/src/components/UsageLineChart.tsx @@ -14,7 +14,7 @@ interface Props { data: DailyUsage[]; } -function formatTokens(n: number): string { +export function formatUsageTokens(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`; return String(n); @@ -25,7 +25,12 @@ interface TooltipPayload { payload: DailyUsage; } -function CustomTooltip({ active, payload, label }: { active?: boolean; payload?: TooltipPayload[]; label?: string }) { +export function createUsageTickFormatter(data: DailyUsage[]): (_: string, index: number) => string { + const tickEvery = Math.ceil(data.length / 7); + return (_: string, index: number) => (index % tickEvery === 0 ? data[index]?.date ?? '' : ''); +} + +export function UsageLineChartTooltip({ active, payload, label }: { active?: boolean; payload?: TooltipPayload[]; label?: string }) { if (!active || !payload || !payload.length) return null; const d = payload[0].payload; return ( @@ -37,7 +42,7 @@ function CustomTooltip({ active, payload, label }: { active?: boolean; payload?: fontSize: 12, }}>
    {label}
    -
    {formatTokens(d.tokens)} tokens
    +
    {formatUsageTokens(d.tokens)} tokens
    ${d.costUsd.toFixed(4)}
    ); @@ -46,7 +51,7 @@ function CustomTooltip({ active, payload, label }: { active?: boolean; payload?: export default function UsageLineChart({ data }: Props) { // Show fewer x-axis ticks for readability const tickEvery = Math.ceil(data.length / 7); - const tickFormatter = (_: string, index: number) => (index % tickEvery === 0 ? data[index]?.date ?? '' : ''); + const tickFormatter = createUsageTickFormatter(data); return ( @@ -64,10 +69,10 @@ export default function UsageLineChart({ data }: Props) { tick={{ fontSize: 10, opacity: 0.6 }} axisLine={false} tickLine={false} - tickFormatter={formatTokens} + tickFormatter={formatUsageTokens} width={44} /> - } /> + } /> No data available.
    ; @@ -24,8 +28,11 @@ export default function WeeklyStatsTab({ projectStats }: { projectStats?: Projec
    - +
    +

    + Costs are estimated from local Claude session logs and detected model pricing. +

    Daily Breakdown

    @@ -39,7 +46,7 @@ export default function WeeklyStatsTab({ projectStats }: { projectStats?: Projec [name === 'tokens' ? formatTokens(v) : `$${v.toFixed(4)}`, name]} + formatter={formatWeeklyTooltipValue} /> diff --git a/webview-ui/src/components/__tests__/LiveSessionBanner.test.tsx b/webview-ui/src/components/__tests__/LiveSessionBanner.test.tsx index 94e2a9b..d7124fa 100644 --- a/webview-ui/src/components/__tests__/LiveSessionBanner.test.tsx +++ b/webview-ui/src/components/__tests__/LiveSessionBanner.test.tsx @@ -7,6 +7,8 @@ describe('LiveSessionBanner', () => { it('renders nothing for zero sessions and pluralizes otherwise', () => { const { rerender, container } = render(); expect(container).toBeEmptyDOMElement(); + rerender(); + expect(screen.getByText('1 active session running')).toBeInTheDocument(); rerender(); expect(screen.getByText('2 active sessions running')).toBeInTheDocument(); }); diff --git a/webview-ui/src/components/__tests__/MarkdownView.test.tsx b/webview-ui/src/components/__tests__/MarkdownView.test.tsx index 1078d21..65ab6a4 100644 --- a/webview-ui/src/components/__tests__/MarkdownView.test.tsx +++ b/webview-ui/src/components/__tests__/MarkdownView.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { fireEvent, render, screen } from '../../__tests__/helpers/render-helpers'; import { CommandBlock, MarkdownView } from '../MarkdownView'; @@ -22,4 +22,12 @@ describe('MarkdownView', () => { expect(screen.getByText('Deploy')).toBeInTheDocument(); expect(screen.getByText('now')).toBeInTheDocument(); }); + + it('renders markdown links and forwards link clicks', () => { + const onLinkClick = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Working Agreements' })); + expect(onLinkClick).toHaveBeenCalledWith('working-agreements.md'); + }); }); diff --git a/webview-ui/src/components/__tests__/PatternChart.test.tsx b/webview-ui/src/components/__tests__/PatternChart.test.tsx index c478afc..f0618c7 100644 --- a/webview-ui/src/components/__tests__/PatternChart.test.tsx +++ b/webview-ui/src/components/__tests__/PatternChart.test.tsx @@ -1,14 +1,31 @@ import React from 'react'; import { describe, expect, it } from 'vitest'; import { render, screen } from '../../__tests__/helpers/render-helpers'; -import PatternChart from '../PatternChart'; +import PatternChart, { PatternChartTooltip, getPatternCategoryColor } from '../PatternChart'; describe('PatternChart', () => { it('renders empty and populated states', () => { const { rerender } = render(); expect(screen.getByText('No prompt data yet.')).toBeInTheDocument(); - rerender(); + rerender(); expect(screen.getByTestId('bar-chart')).toBeInTheDocument(); + expect(getPatternCategoryColor('Fix/Bug')).toBe('#ef4444'); + expect(getPatternCategoryColor('Custom')).toBe('#6366f1'); + }); + + it('renders tooltip details with singular and plural prompt labels', () => { + const { rerender } = render( + + ); + + expect(screen.getByText('Explain')).toBeInTheDocument(); + expect(screen.getByText('1 prompt')).toBeInTheDocument(); + + rerender( + + ); + + expect(screen.getByText('2 prompts')).toBeInTheDocument(); }); }); diff --git a/webview-ui/src/components/__tests__/ProductivityChart.test.tsx b/webview-ui/src/components/__tests__/ProductivityChart.test.tsx index 10ba059..b3fc838 100644 --- a/webview-ui/src/components/__tests__/ProductivityChart.test.tsx +++ b/webview-ui/src/components/__tests__/ProductivityChart.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { describe, expect, it } from 'vitest'; import { render, screen } from '../../__tests__/helpers/render-helpers'; -import ProductivityChart from '../ProductivityChart'; +import ProductivityChart, { ProductivityChartTooltip, formatProductivityHour, formatProductivityTick } from '../ProductivityChart'; describe('ProductivityChart', () => { it('renders empty states and populated chart', () => { @@ -14,4 +14,25 @@ describe('ProductivityChart', () => { rerender(); expect(screen.getByTestId('bar-chart')).toBeInTheDocument(); }); + + it('formats tooltip content and hour labels', () => { + render( + + ); + + expect(screen.getByText('09:00')).toBeInTheDocument(); + expect(screen.getByText('Sessions: 2')).toBeInTheDocument(); + expect(screen.getByText('Avg Tool Calls: 3')).toBeInTheDocument(); + expect(formatProductivityHour(14)).toBe('14:00'); + expect(formatProductivityHour()).toBe(''); + expect(formatProductivityTick(7)).toBe('07h'); + expect(render().container).toBeEmptyDOMElement(); + }); }); diff --git a/webview-ui/src/components/__tests__/ProjectBarChart.test.tsx b/webview-ui/src/components/__tests__/ProjectBarChart.test.tsx index f3b1aa8..b0ef75c 100644 --- a/webview-ui/src/components/__tests__/ProjectBarChart.test.tsx +++ b/webview-ui/src/components/__tests__/ProjectBarChart.test.tsx @@ -1,11 +1,26 @@ import React from 'react'; import { describe, expect, it } from 'vitest'; import { render, screen } from '../../__tests__/helpers/render-helpers'; -import ProjectBarChart from '../ProjectBarChart'; +import ProjectBarChart, { ProjectBarChartTooltip, formatProjectBarTokens } from '../ProjectBarChart'; describe('ProjectBarChart', () => { it('renders project bar chart data', () => { render(); expect(screen.getByTestId('bar-chart')).toBeInTheDocument(); }); + + it('formats token totals and tooltip details', () => { + render( + + ); + + expect(screen.getByText('Large Project')).toBeInTheDocument(); + expect(screen.getByText('1.2M tokens')).toBeInTheDocument(); + expect(screen.getByText('$1.2345')).toBeInTheDocument(); + expect(formatProjectBarTokens(1500)).toBe('2k'); + expect(formatProjectBarTokens(999)).toBe('999'); + }); }); diff --git a/webview-ui/src/components/__tests__/ProjectCard.test.tsx b/webview-ui/src/components/__tests__/ProjectCard.test.tsx index 680e490..ccdcb93 100644 --- a/webview-ui/src/components/__tests__/ProjectCard.test.tsx +++ b/webview-ui/src/components/__tests__/ProjectCard.test.tsx @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest'; import { fireEvent, render, screen } from '../../__tests__/helpers/render-helpers'; import { makeProject } from '../../__tests__/fixtures/test-data'; import { mockPostMessage } from '../../__tests__/setup'; -import ProjectCard from '../ProjectCard'; +import ProjectCard, { formatProjectCardTokens, projectCardTimeAgo } from '../ProjectCard'; describe('ProjectCard', () => { it('renders active state and posts openProject', () => { @@ -26,4 +26,17 @@ describe('ProjectCard', () => { render(); expect(screen.getByText('never')).toBeInTheDocument(); }); + + it('formats token counts and relative times across branches', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-15T12:00:00Z')); + + expect(formatProjectCardTokens(2_500_000)).toBe('2.5M'); + expect(formatProjectCardTokens(2500)).toBe('2.5k'); + expect(formatProjectCardTokens(250)).toBe('250'); + expect(projectCardTimeAgo(Date.now() - 30 * 60_000)).toBe('just now'); + expect(projectCardTimeAgo(Date.now() - 26 * 3_600_000)).toBe('1d ago'); + + vi.useRealTimers(); + }); }); diff --git a/webview-ui/src/components/__tests__/SessionDetail.test.tsx b/webview-ui/src/components/__tests__/SessionDetail.test.tsx index 9593465..fc1a3dd 100644 --- a/webview-ui/src/components/__tests__/SessionDetail.test.tsx +++ b/webview-ui/src/components/__tests__/SessionDetail.test.tsx @@ -2,11 +2,12 @@ import React from 'react'; import { describe, expect, it, vi } from 'vitest'; import { act, fireEvent, render, screen, waitFor } from '../../__tests__/helpers/render-helpers'; import { makeSession, makeToolCall, makeTurn } from '../../__tests__/fixtures/test-data'; -import SessionDetail from '../SessionDetail'; +import SessionDetail, { modelBadgeColor, modelLabel, parseSystemContent, stripAnsi } from '../SessionDetail'; describe('SessionDetail', () => { it('renders loading, system events, tool calls, and clipboard copy', async () => { const session = makeSession({ + model: 'claude-sonnet-4', turns: [ makeTurn({ role: 'user', content: 'npmtest', timestamp: 1 }), makeTurn({ role: 'user', content: '\x1b[31moutput\x1b[0m', timestamp: 2 }), @@ -26,6 +27,7 @@ describe('SessionDetail', () => { expect(screen.getByText('output')).toBeInTheDocument(); expect(screen.getByText('Investigate bug')).toBeInTheDocument(); expect(screen.getByText('Edit')).toBeInTheDocument(); + expect(screen.getByText('Sonnet')).toBeInTheDocument(); const copyButtons = screen.getAllByRole('button'); await act(async () => { @@ -38,6 +40,7 @@ describe('SessionDetail', () => { it('renders no-turns state and created file badges', () => { const session = makeSession({ + model: 'claude-haiku-4', filesModified: ['/src/new.ts'], filesCreated: ['/src/new.ts'], turns: [], @@ -46,5 +49,71 @@ describe('SessionDetail', () => { render(); expect(screen.getByText('No turns recorded for this session.')).toBeInTheDocument(); expect(screen.getByText(/new.ts/)).toBeInTheDocument(); + expect(screen.getByText('Haiku')).toBeInTheDocument(); + }); + + it('covers parsing and model helper branches', () => { + expect(stripAnsi('\u001b[31mhello\u001b[0m')).toBe('hello'); + expect(parseSystemContent('note')).toBe('skip'); + expect(parseSystemContent('git')).toEqual({ kind: 'command', name: 'git', args: undefined }); + expect(parseSystemContent('\u001b[31mwarn\u001b[0m')).toEqual({ kind: 'stdout', text: 'warn' }); + expect(parseSystemContent(' ')).toBe('skip'); + expect(parseSystemContent('plain text')).toBeNull(); + + expect(modelLabel('claude-opus-4')).toBe('Opus'); + expect(modelLabel('claude-haiku-4')).toBe('Haiku'); + expect(modelLabel('claude-sonnet-4')).toBe('Sonnet'); + expect(modelLabel('claude-unknown')).toBeNull(); + expect(modelLabel(null)).toBeNull(); + + expect(modelBadgeColor('claude-opus-4')).toContain('text-purple-400'); + expect(modelBadgeColor('claude-haiku-4')).toContain('text-orange-400'); + expect(modelBadgeColor('claude-sonnet-4')).toContain('text-blue-400'); + expect(modelBadgeColor(null)).toBe(''); + }); + + it('renders cache, thinking, subagent cost, collapsed previews, and tool-only assistant turns', () => { + const longContent = 'A'.repeat(140); + const session = makeSession({ + model: 'claude-opus-4', + cacheReadTokens: 300, + cacheHitRate: 50, + hasThinking: true, + thinkingTokens: 1200, + subagentCostUsd: 0.125, + costUsd: 0.375, + turns: [ + makeTurn({ + role: 'assistant', + content: '', + inputTokens: 10, + outputTokens: 20, + toolCalls: [makeToolCall({ name: 'mcp__github__search', input: { query: 'repo:foo bug' } })], + timestamp: 1, + }), + makeTurn({ + role: 'assistant', + content: longContent, + inputTokens: 5, + outputTokens: 0, + toolCalls: [makeToolCall({ name: 'Read', input: { file_path: '/tmp/file.ts' } })], + timestamp: 2, + }), + ], + }); + + render(); + + expect(screen.getByText('Opus')).toBeInTheDocument(); + expect(screen.getByText('+300 cached')).toBeInTheDocument(); + expect(screen.getByText('50% cache')).toBeInTheDocument(); + expect(screen.getByText(/\+\$0.1250 subagents/)).toBeInTheDocument(); + expect(screen.getByText(/\u26a1 thinking/)).toBeInTheDocument(); + expect(screen.getByText('github/search')).toBeInTheDocument(); + expect(screen.getByText('repo:foo bug')).toBeInTheDocument(); + expect(screen.getByText('10↑ 20↓')).toBeInTheDocument(); + + fireEvent.click(screen.getAllByTitle('Collapse')[0]); + expect(screen.getByText(`${longContent.slice(0, 120)}…`)).toBeInTheDocument(); }); }); diff --git a/webview-ui/src/components/__tests__/SessionList.test.tsx b/webview-ui/src/components/__tests__/SessionList.test.tsx index 314a77a..efec77b 100644 --- a/webview-ui/src/components/__tests__/SessionList.test.tsx +++ b/webview-ui/src/components/__tests__/SessionList.test.tsx @@ -7,17 +7,22 @@ import SessionList from '../SessionList'; describe('SessionList', () => { it('sorts sessions, selects one, and renders activity states', () => { const onSelect = vi.fn(); - const older = makeSession({ id: 'older', startTime: 1000, durationMs: 61_000, totalTokens: 1200, promptCount: 2, activityRatio: 75 }); - const newer = makeSession({ id: 'newer', startTime: 2000, durationMs: 10_000, totalTokens: 500, promptCount: 1, activityRatio: 50 }); - const lowest = makeSession({ id: 'low', startTime: 1500, durationMs: null, totalTokens: 50, promptCount: 3, activityRatio: 20 }); + const older = makeSession({ id: 'older', startTime: 1000, durationMs: 61_000, totalTokens: 1200, promptCount: 2, activityRatio: 75, model: 'claude-opus-4' }); + const newer = makeSession({ id: 'newer', startTime: 2000, durationMs: 10_000, totalTokens: 500, promptCount: 1, activityRatio: 50, model: 'claude-sonnet-4' }); + const lowest = makeSession({ id: 'low', startTime: 1500, durationMs: null, totalTokens: 50, promptCount: 3, activityRatio: 20, model: 'claude-haiku-4' }); + const unknown = makeSession({ id: 'unknown', startTime: 500, durationMs: 5_000, totalTokens: 25, promptCount: 1, activityRatio: null, model: 'claude-custom-next' }); - render(); + render(); const buttons = screen.getAllByRole('button'); expect(buttons[0]).toHaveTextContent('500 tokens'); expect(screen.getByText('75% active')).toBeInTheDocument(); expect(screen.getByText('50% active')).toBeInTheDocument(); expect(screen.getByText('20% active')).toBeInTheDocument(); + expect(screen.getByText('Opus')).toBeInTheDocument(); + expect(screen.getByText('Sonnet')).toBeInTheDocument(); + expect(screen.getByText('Haiku')).toBeInTheDocument(); + expect(screen.queryByText('claude-custom-next')).not.toBeInTheDocument(); expect(buttons[1]).toHaveTextContent('— · 50 tokens · 3 prompts · 20% active'); fireEvent.click(buttons[1]); diff --git a/webview-ui/src/components/__tests__/UsageLineChart.test.tsx b/webview-ui/src/components/__tests__/UsageLineChart.test.tsx index d02f043..a2db82a 100644 --- a/webview-ui/src/components/__tests__/UsageLineChart.test.tsx +++ b/webview-ui/src/components/__tests__/UsageLineChart.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { describe, expect, it } from 'vitest'; import { render, screen } from '../../__tests__/helpers/render-helpers'; -import UsageLineChart from '../UsageLineChart'; +import UsageLineChart, { UsageLineChartTooltip, createUsageTickFormatter, formatUsageTokens } from '../UsageLineChart'; describe('UsageLineChart', () => { it('renders the line chart with daily usage data', () => { @@ -16,4 +16,36 @@ describe('UsageLineChart', () => { ]} />); expect(screen.getByTestId('line-chart')).toBeInTheDocument(); }); + + it('formats usage labels and tooltip output', () => { + render( + + ); + + expect(screen.getByText('1/20')).toBeInTheDocument(); + expect(screen.getByText('1k tokens')).toBeInTheDocument(); + expect(screen.getByText('$0.1250')).toBeInTheDocument(); + expect(formatUsageTokens(2_000_000)).toBe('2.0M'); + expect(formatUsageTokens(250)).toBe('250'); + expect(render().container).toBeEmptyDOMElement(); + + const tickFormatter = createUsageTickFormatter([ + { date: '1/10', tokens: 10, costUsd: 0.01 }, + { date: '1/11', tokens: 20, costUsd: 0.02 }, + { date: '1/12', tokens: 30, costUsd: 0.03 }, + { date: '1/13', tokens: 40, costUsd: 0.04 }, + { date: '1/14', tokens: 50, costUsd: 0.05 }, + { date: '1/15', tokens: 60, costUsd: 0.06 }, + { date: '1/16', tokens: 70, costUsd: 0.07 }, + { date: '1/17', tokens: 80, costUsd: 0.08 }, + ]); + + expect(tickFormatter('ignored', 0)).toBe('1/10'); + expect(tickFormatter('ignored', 1)).toBe(''); + expect(tickFormatter('ignored', 2)).toBe('1/12'); + }); }); diff --git a/webview-ui/src/components/__tests__/WeeklyStatsTab.test.tsx b/webview-ui/src/components/__tests__/WeeklyStatsTab.test.tsx index 5410c99..88f2720 100644 --- a/webview-ui/src/components/__tests__/WeeklyStatsTab.test.tsx +++ b/webview-ui/src/components/__tests__/WeeklyStatsTab.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { describe, expect, it } from 'vitest'; import { render, screen } from '../../__tests__/helpers/render-helpers'; -import WeeklyStatsTab from '../WeeklyStatsTab'; +import WeeklyStatsTab, { formatWeeklyTooltipValue } from '../WeeklyStatsTab'; describe('WeeklyStatsTab', () => { it('renders empty and populated weekly stats states', () => { @@ -15,12 +15,12 @@ describe('WeeklyStatsTab', () => { efficiency: { avgTokensPerPrompt: 0, avgToolCallsPerSession: 0, avgSessionDurationMin: 0, firstTurnResolutionRate: 0, avgActiveRatio: 0 }, recentToolCalls: [], weeklyStats: { - sessions: 2, + sessions: 3, tokens: 2500, costUsd: 0.125, dailyBreakdown: [ - { date: '1/14', tokens: 0, costUsd: 0, sessions: 0 }, - { date: '1/15', tokens: 2500, costUsd: 0.125, sessions: 2 }, + { date: '1/14', tokens: 500, costUsd: 0.025, sessions: 1 }, + { date: '1/15', tokens: 2000, costUsd: 0.1, sessions: 2 }, ], }, }} />); @@ -28,5 +28,29 @@ describe('WeeklyStatsTab', () => { expect(screen.getByText('Sessions this week')).toBeInTheDocument(); expect(screen.getByText('2.5k')).toBeInTheDocument(); expect(screen.getByText('$0.125')).toBeInTheDocument(); + expect(screen.getByText('1 session')).toBeInTheDocument(); + }); + + it('renders the no-activity state and formats tooltip values', () => { + render(); + + expect(screen.getByText('No activity in the last 7 days.')).toBeInTheDocument(); + expect(formatWeeklyTooltipValue(1250, 'tokens')).toEqual(['1.3k', 'tokens']); + expect(formatWeeklyTooltipValue(0.125, 'costUsd')).toEqual(['$0.1250', 'costUsd']); }); }); diff --git a/webview-ui/src/types.ts b/webview-ui/src/types.ts index f4f9ebf..1475c6c 100644 --- a/webview-ui/src/types.ts +++ b/webview-ui/src/types.ts @@ -38,6 +38,7 @@ export interface Session { idleTimeMs: number | null; activeTimeMs: number | null; activityRatio: number | null; + model: string | null; } export interface Turn { @@ -107,11 +108,40 @@ export interface McpServer { toolCallCount: number; // total calls across all project sessions (0 if unused) } +export interface MemoryFile { + fileName: string; + name: string; + description: string; + type: string; // 'user' | 'feedback' | 'project' | 'reference' | 'unknown' + content: string; +} + +export interface ProjectMemory { + index: string | null; // MEMORY.md contents + files: MemoryFile[]; +} + +export interface PlanFile { + fileName: string; + name: string; + description: string; + content: string; +} + +export interface HookConfig { + event: string; + matcher?: string; + command: string; +} + export interface ProjectConfig { claudeMd: string | null; mcpServers: Record; projectSettings: Record; commands: { name: string; content: string }[]; + plans: PlanFile[]; + memory: ProjectMemory; + hooks: HookConfig[]; } export interface ToolUsageStat { @@ -206,6 +236,23 @@ export interface WeeklyProjectStats { dailyBreakdown: { date: string; tokens: number; costUsd: number; sessions: number }[]; } +export interface ClaudeCommit { + hash: string; + shortHash: string; + author: string; + date: number; + subject: string; + filesChanged: number; +} + +export interface SessionTodoSnapshot { + sessionId: string; + sessionDate: number; + sessionSummary: string | null; + todos: { content: string; status: string }[]; + timestamp: number; +} + export interface ProjectStats { usageOverTime: DailyUsage[]; toolUsage: ToolUsageStat[]; diff --git a/webview-ui/src/views/Dashboard.tsx b/webview-ui/src/views/Dashboard.tsx index b7205d8..2eb9a04 100644 --- a/webview-ui/src/views/Dashboard.tsx +++ b/webview-ui/src/views/Dashboard.tsx @@ -63,10 +63,10 @@ function timeAgo(ts: number): string { return `${d}d ago`; } -const TAB_LABELS: { key: Tab; label: string }[] = [ - { key: 'overview', label: 'Overview' }, - { key: 'charts', label: 'Charts' }, - { key: 'insights', label: 'Insights' }, +const TAB_LABELS: Array<{ key: Tab; label: string; description: string }> = [ + { key: 'overview', label: 'Overview', description: 'Track active projects, weekly momentum, and recent portfolio activity.' }, + { key: 'charts', label: 'Charts', description: 'Review usage and cost trends across the month.' }, + { key: 'insights', label: 'Insights', description: 'Explore patterns, productivity, and hotspots across your work.' }, ]; type SortKey = 'lastActive' | 'cost' | 'sessions'; @@ -93,6 +93,7 @@ export default function Dashboard({ const [projectSort, setProjectSort] = useState('lastActive'); const active = projects.filter(p => p.isActive); + const activeTabMeta = TAB_LABELS.find(tab => tab.key === activeTab) ?? TAB_LABELS[0]; const filteredProjects = projects .filter(p => !p.isActive) @@ -105,29 +106,45 @@ export default function Dashboard({ .slice(0, 20); return ( -
    +
    {/* Header */} -
    -

    Claude Code Dashboard

    -

    {stats?.totalProjects ?? 0} projects · {stats?.activeSessionCount ?? 0} active sessions

    +
    +
    +
    +
    Workspace
    +

    Claude Code Dashboard

    +

    {stats?.totalProjects ?? 0} projects · {stats?.activeSessionCount ?? 0} active sessions

    +
    +
    + + {formatTokens(stats?.tokensTodayTotal ?? 0)} today + + + est. ${((stats?.costTodayUsd ?? 0)).toFixed(3)} + +
    +
    +

    + Token usage comes from local Claude session logs. Costs are estimated from detected model pricing and may differ from Anthropic's actual billing. +

    {/* Tab navigation */} -
    - {TAB_LABELS.map(({ key, label }) => ( - - ))} -
    + {/* Overview tab */} {activeTab === 'overview' && ( @@ -180,27 +197,33 @@ export default function Dashboard({ {/* Stats strip */}
    - + - +
    {/* Active sessions */} {active.length > 0 && ( -
    -

    Active Now

    +
    {active.map(p => ( ))}
    -
    + )} {/* All projects with filter/sort */} -
    -
    -

    Projects

    + +
    No projects match "{projectFilter}"
    }
    -
    +
    )} {/* Charts tab */} {activeTab === 'charts' && (
    -
    -

    Token Usage — Last 30 Days

    -
    + {usageOverTime && usageOverTime.length > 0 ? :
    No usage data available.
    } -
    -
    + -
    -

    Token Usage by Project

    -
    + {usageByProject && usageByProject.length > 0 ? :
    No project usage data available.
    } -
    -
    + {/* Projected Monthly Cost */} {projectedCost && ( -
    -

    Projected Monthly Cost

    -
    +
    This month so far
    @@ -294,15 +309,13 @@ export default function Dashboard({
    )} -
    -
    + )} {/* Cost by Project This Month */} {usageByProject && usageByProject.length > 0 && ( -
    -

    Cost by Project This Month

    -
    + +
    {(() => { const maxCost = Math.max(...usageByProject.map(p => p.costUsd), 0.0001); return usageByProject.slice(0, 8).map(p => ( @@ -323,7 +336,7 @@ export default function Dashboard({ )); })()}
    -
    + )}
    )} @@ -331,65 +344,46 @@ export default function Dashboard({ {/* Insights tab */} {activeTab === 'insights' && (
    -
    -

    Prompt Categories

    -
    + {promptPatterns ? :
    No prompt data yet.
    } -
    -
    + -
    -

    Usage Heatmap (by Hour & Day)

    -
    + {heatmapData && heatmapData.length > 0 ? :
    No heatmap data available.
    } -
    -
    + {/* Efficiency Stats */} {efficiency && ( -
    -

    Efficiency Stats

    + -
    + )} {/* Tool Usage */} -
    -

    Tool Usage

    -
    + -
    -
    + {/* Productivity by Hour */} -
    -

    Productivity by Hour

    -
    + -
    -
    + {/* Hot Files */} -
    -

    Hot Files

    -
    + -
    -
    + {/* Recent File Changes */} -
    -

    Recent File Changes (last 7 days)

    -
    + -
    -
    +
    )}
    @@ -398,9 +392,9 @@ export default function Dashboard({ function StatCard({ label, value }: { label: string; value: string }) { return ( -
    -
    {label}
    -
    {value}
    +
    +
    {label}
    +
    {value}
    ); } @@ -409,7 +403,7 @@ function ActiveProjectCard({ project }: { project: Project }) { return ( ); } + +function DashboardTabButton({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) { + return ( + + ); +} + +function DashboardSection({ + eyebrow, + title, + detail, + children, +}: { + eyebrow: string; + title: string; + detail?: string; + children: React.ReactNode; +}) { + return ( +
    +
    +
    {eyebrow}
    +

    {title}

    + {detail && ( +

    {detail}

    + )} +
    + {children} +
    + ); +} diff --git a/webview-ui/src/views/ProjectDetail.tsx b/webview-ui/src/views/ProjectDetail.tsx index 5d7ee8c..d86d36a 100644 --- a/webview-ui/src/views/ProjectDetail.tsx +++ b/webview-ui/src/views/ProjectDetail.tsx @@ -1,5 +1,5 @@ -import React, { useState, useEffect, useCallback } from 'react'; -import { Project, Session, Turn, ProjectConfig, McpServer, ProjectStats, ProjectFile, ProjectToolCall } from '../types'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { Project, Session, Turn, ProjectConfig, McpServer, ProjectStats, ProjectFile, ProjectToolCall, SessionTodoSnapshot, ClaudeCommit, HookConfig, MemoryFile, PlanFile } from '../types'; import { vscode } from '../vscode'; import { formatTokens, formatDuration, timeAgo } from '../utils/format'; import { toolColor } from '../utils/toolColor'; @@ -15,27 +15,64 @@ interface Props { config?: ProjectConfig; projectStats?: ProjectStats; projectFiles?: ProjectFile[]; + projectTodos?: SessionTodoSnapshot[]; + claudeCommits?: ClaudeCommit[]; } -type Tab = 'sessions' | 'claudemd' | 'tools' | 'mcp' | 'subagents' | 'files' | 'commands' | 'weekly'; - -const TAB_LABELS: { key: Tab; label: string }[] = [ - { key: 'sessions', label: 'Sessions' }, - { key: 'weekly', label: 'Weekly' }, - { key: 'claudemd', label: 'CLAUDE.md' }, - { key: 'commands', label: 'Commands' }, - { key: 'tools', label: 'Tools' }, - { key: 'mcp', label: 'MCP Servers' }, - { key: 'subagents', label: 'Subagents' }, - { key: 'files', label: 'Files' }, +type TabGroup = 'activity' | 'knowledge' | 'workflow'; +type Tab = 'sessions' | 'claudemd' | 'tools' | 'mcp' | 'subagents' | 'files' | 'commands' | 'weekly' | 'memory' | 'plans' | 'todos' | 'commits' | 'settings'; + +const TAB_META: Record = { + sessions: { label: 'Sessions', description: 'Browse runs and inspect full turn history.', group: 'activity' }, + weekly: { label: 'Trends', description: 'See the recent usage rhythm for this project.', group: 'activity' }, + tools: { label: 'Tool Usage', description: 'Review tool usage, recent calls, and patterns.', group: 'activity' }, + subagents: { label: 'Subagents', description: 'Inspect Claude subagent definitions and delegated runs.', group: 'knowledge' }, + files: { label: 'Files', description: 'Track which files were touched and when.', group: 'activity' }, + memory: { label: 'Memory', description: 'Read saved memory notes and working agreements.', group: 'knowledge' }, + claudemd: { label: 'Claude Guide', description: 'Reference the project guidance Claude sees.', group: 'knowledge' }, + commands: { label: 'Commands', description: 'Open reusable custom slash commands for this project.', group: 'knowledge' }, + mcp: { label: 'MCP', description: 'Inspect configured MCP integrations and usage.', group: 'knowledge' }, + plans: { label: 'Plans', description: 'Review saved project plans and roadmap documents.', group: 'workflow' }, + todos: { label: 'Todos', description: 'Review final todo snapshots captured in sessions.', group: 'workflow' }, + commits: { label: 'Commits', description: 'Scan Claude co-authored commit history.', group: 'activity' }, + settings: { label: 'Automation', description: 'Check hooks and project-level configuration.', group: 'knowledge' }, +}; + +const TAB_GROUPS: Array<{ key: TabGroup; label: string; description: string; tabs: Tab[] }> = [ + { + key: 'activity', + label: 'History', + description: 'Sessions, usage, files, and commits.', + tabs: ['sessions', 'weekly', 'files', 'tools', 'commits'], + }, + { + key: 'knowledge', + label: 'Setup', + description: 'Claude guidance, memory, tools, and automation.', + tabs: ['claudemd', 'memory', 'commands', 'mcp', 'subagents', 'settings'], + }, + { + key: 'workflow', + label: 'Workflow', + description: 'Plans and todos for active work.', + tabs: ['plans', 'todos'], + }, ]; -export default function ProjectDetail({ project, sessions, subagentSessions, config, projectStats, projectFiles }: Props) { +export default function ProjectDetail({ project, sessions, subagentSessions, config, projectStats, projectFiles, projectTodos, claudeCommits }: Props) { const [selectedSession, setSelectedSession] = useState(null); const [turns, setTurns] = useState([]); const [turnsLoading, setTurnsLoading] = useState(false); const [activeTab, setActiveTab] = useState('sessions'); + const [groupSelection, setGroupSelection] = useState>({ + activity: 'sessions', + knowledge: 'claudemd', + workflow: 'plans', + }); const [fileSort, setFileSort] = useState<'edits' | 'recent'>('edits'); + const [selectedMemoryFileName, setSelectedMemoryFileName] = useState(null); + const [selectedPlanFileName, setSelectedPlanFileName] = useState(null); + const memoryPreviewRef = useRef(null); const sorted = [...(sessions ?? [])].sort((a, b) => b.startTime - a.startTime); useEffect(() => { @@ -61,19 +98,113 @@ export default function ProjectDetail({ project, sessions, subagentSessions, con return
    Project not found.
    ; } + const projectSettingsEntries = Object.entries(config?.projectSettings ?? {}).filter( + ([k]) => k !== 'mcpServers' && k !== 'hooks' + ); + const plans = config?.plans ?? []; + const selectedPlan = plans.find(plan => plan.fileName === selectedPlanFileName) ?? plans[0] ?? null; + const memoryFiles = config?.memory.files ?? []; + const memoryReferenceNames = extractMemoryReferences(config?.memory.index ?? ''); + const memoryReferenceNameSet = new Set(memoryReferenceNames); + const referencedMemoryFiles = memoryReferenceNames + .map(referenceName => memoryFiles.find(file => normalizeMemoryFileName(file.fileName) === referenceName)) + .filter((file): file is MemoryFile => Boolean(file)); + const unlinkedMemoryFiles = memoryFiles.filter(file => !memoryReferenceNameSet.has(normalizeMemoryFileName(file.fileName))); + const selectedMemory = memoryFiles.find(file => file.fileName === selectedMemoryFileName) + ?? referencedMemoryFiles[0] + ?? memoryFiles[0] + ?? null; + const memoryTypeCount = new Set(memoryFiles.map(f => f.type)).size; + const hasMemoryIndex = Boolean(config?.memory.index); + const totalTodos = (projectTodos ?? []).reduce((sum, snapshot) => sum + snapshot.todos.length, 0); + const completedTodos = (projectTodos ?? []).reduce( + (sum, snapshot) => sum + snapshot.todos.filter(todo => todo.status === 'completed').length, + 0 + ); + const activeTodos = Math.max(0, totalTodos - completedTodos); + const hooksCount = config?.hooks.length ?? 0; + const commitFilesChanged = (claudeCommits ?? []).reduce((sum, commit) => sum + commit.filesChanged, 0); const sortedFiles = [...(projectFiles ?? [])].sort((a, b) => fileSort === 'recent' ? b.lastTouched - a.lastTouched : b.editCount - a.editCount ); + const tabBadges: Record = { + sessions: sorted.length > 0 ? String(sorted.length) : null, + weekly: projectStats?.weeklyStats.sessions ? `${projectStats.weeklyStats.sessions} wk` : null, + tools: projectStats?.toolUsage.length ? `${projectStats.toolUsage.length}` : null, + subagents: subagentSessions?.length ? String(subagentSessions.length) : null, + files: sortedFiles.length > 0 ? String(sortedFiles.length) : null, + memory: config?.memory.files.length ? String(config.memory.files.length) : (config?.memory.index ? 'index' : null), + claudemd: config?.claudeMd ? 'guide' : null, + commands: config?.commands.length ? String(config.commands.length) : null, + mcp: Object.keys(config?.mcpServers ?? {}).length ? String(Object.keys(config?.mcpServers ?? {}).length) : null, + plans: plans.length ? String(plans.length) : null, + todos: projectTodos?.length ? String(projectTodos.length) : null, + commits: claudeCommits?.length ? String(claudeCommits.length) : null, + settings: (config?.hooks.length ?? 0) + projectSettingsEntries.length > 0 + ? String((config?.hooks.length ?? 0) + projectSettingsEntries.length) + : null, + }; + const activeMeta = TAB_META[activeTab]; + const activeGroup = activeMeta.group; + const activeGroupMeta = TAB_GROUPS.find(group => group.key === activeGroup) ?? TAB_GROUPS[0]; + + const selectTab = useCallback((tab: Tab) => { + setActiveTab(tab); + setGroupSelection(prev => ({ ...prev, [TAB_META[tab].group]: tab })); + }, []); + + const selectGroup = useCallback((group: TabGroup) => { + const nextTab = groupSelection[group] ?? TAB_GROUPS.find(candidate => candidate.key === group)?.tabs[0] ?? 'sessions'; + setActiveTab(nextTab); + }, [groupSelection]); + + const focusMemoryFile = useCallback((fileName: string) => { + setSelectedMemoryFileName(fileName); + requestAnimationFrame(() => { + memoryPreviewRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }); + }, []); + + const handleMemoryLinkClick = useCallback((href: string) => { + const normalizedHref = normalizeMemoryFileName(href); + const linkedFile = memoryFiles.find(file => normalizeMemoryFileName(file.fileName) === normalizedHref); + if (linkedFile) { + focusMemoryFile(linkedFile.fileName); + } + }, [focusMemoryFile, memoryFiles]); + + useEffect(() => { + if (plans.length === 0) { + setSelectedPlanFileName(null); + return; + } + + if (!selectedPlanFileName || !plans.some(plan => plan.fileName === selectedPlanFileName)) { + setSelectedPlanFileName(plans[0].fileName); + } + }, [plans, selectedPlanFileName]); + + useEffect(() => { + if (memoryFiles.length === 0) { + setSelectedMemoryFileName(null); + return; + } + + if (!selectedMemoryFileName || !memoryFiles.some(file => file.fileName === selectedMemoryFileName)) { + const nextMemory = referencedMemoryFiles[0] ?? memoryFiles[0]; + setSelectedMemoryFileName(nextMemory?.fileName ?? null); + } + }, [memoryFiles, referencedMemoryFiles, selectedMemoryFileName]); return ( -
    +
    {/* Header */} -
    -
    +
    +
    {project.isActive && }

    {project.name}

    {project.isActive && live} -
    +

    {project.path}

    -
    +
    {project.techStack?.map(t => ( {t} ))} @@ -97,227 +228,474 @@ export default function ProjectDetail({ project, sessions, subagentSessions, con
    {/* Stats strip */} -
    +
    +

    + Token usage comes from local Claude session logs. Costs are estimated from detected model pricing and may differ from Anthropic's actual billing. +

    {/* Tab navigation */} -
    - {TAB_LABELS.map(({ key, label }) => ( - - ))} -
    + + +
    +
    +
    +
    + {TAB_GROUPS.find(group => group.key === activeMeta.group)?.label} +
    +

    {activeMeta.label}

    +

    {activeMeta.description}

    +
    + {tabBadges[activeTab] && ( + + {tabBadges[activeTab]} + + )} +
    + + {/* ── Sessions tab ── */} + {activeTab === 'sessions' && ( +
    +
    +

    Sessions

    +
    + {sorted.map(s => ( + + ))} +
    +
    + +
    + {selectedSession ? ( + + ) : ( +
    + Select a session to view details +
    + )} +
    +
    + )} + + {/* ── Subagents tab ── */} + {activeTab === 'subagents' && ( +
    + {(!subagentSessions || subagentSessions.length === 0) ? ( +
    No subagent sessions found for this project.
    Subagents are spawned when Claude uses the Task or Agent tool.
    + ) : ( + <> +

    {subagentSessions.length} subagent session{subagentSessions.length !== 1 ? 's' : ''} spawned by this project

    +
    + {[...subagentSessions].sort((a, b) => b.startTime - a.startTime).map(s => ( + + ))} +
    + + )} +
    + )} - {/* ── Sessions tab ── */} - {activeTab === 'sessions' && ( -
    -
    -

    Sessions

    -
    - {sorted.map(s => ( + {/* ── Files tab ── */} + {activeTab === 'files' && ( +
    +
    + {sortedFiles.length} file{sortedFiles.length !== 1 ? 's' : ''} touched across all sessions +
    + - ))} +
    -
    -
    - {selectedSession ? ( - + {sortedFiles.length === 0 ? ( +
    No file edits recorded yet.
    ) : ( -
    Select a session to view details
    +
    + {sortedFiles.map((f, i) => ( + + ))} +
    )} -
    -
    - )} +
    + )} - {/* ── Subagents tab ── */} - {activeTab === 'subagents' && ( -
    - {(!subagentSessions || subagentSessions.length === 0) ? ( -
    No subagent sessions found for this project.
    Subagents are spawned when Claude uses the Task or Agent tool.
    - ) : ( - <> -

    {subagentSessions.length} subagent session{subagentSessions.length !== 1 ? 's' : ''} spawned by this project

    -
    - {[...subagentSessions].sort((a, b) => b.startTime - a.startTime).map(s => ( - + {/* ── Tools tab ── */} + {activeTab === 'tools' && ( +
    + {projectStats && (() => { + const total = projectStats.toolUsage.reduce((s, t) => s + t.count, 0); + const unique = projectStats.toolUsage.length; + const avgPerSession = sessions.length > 0 ? (total / sessions.length).toFixed(1) : '0'; + return ( +
    + + + +
    + ); + })()} + +
    +

    Breakdown

    +
    + {(projectStats?.toolUsage ?? []).length === 0 ? ( +
    No tool calls recorded yet.
    + ) : (projectStats?.toolUsage ?? []).map(item => ( +
    +
    + {item.tool} +
    +
    +
    0 ? (item.count / projectStats!.toolUsage[0].count) * 100 : 0}%`, + background: toolColor(item.tool), + opacity: 0.75, + }} + /> +
    +
    + {item.count} ({item.percentage}%) +
    +
    ))}
    - - )} -
    - )} +
    - {/* ── Files tab ── */} - {activeTab === 'files' && ( -
    -
    - {sortedFiles.length} file{sortedFiles.length !== 1 ? 's' : ''} touched across all sessions -
    - - -
    +
    +

    Recent Calls

    +
    + {(projectStats?.recentToolCalls ?? []).length === 0 ? ( +
    No tool calls recorded yet.
    + ) : (projectStats?.recentToolCalls ?? []).map((tc, i) => ( + + ))} +
    +
    + )} - {sortedFiles.length === 0 ? ( -
    No file edits recorded yet.
    - ) : ( -
    - {sortedFiles.map((f, i) => ( - - ))} -
    - )} -
    - )} + {/* ── CLAUDE.md tab ── */} + {activeTab === 'claudemd' && ( +
    + {config?.claudeMd ? ( + + ) : ( +
    No CLAUDE.md found in this project.
    + )} +
    + )} + + {/* ── Commands tab ── */} + {activeTab === 'commands' && ( +
    + {(!config?.commands || config.commands.length === 0) ? ( +
    No custom commands found in .claude/commands/.
    + ) : ( + <> +

    {config.commands.length} command{config.commands.length !== 1 ? 's' : ''} in .claude/commands/

    + {config.commands.map(cmd => ( + + ))} + + )} +
    + )} - {/* ── Tools tab ── */} - {activeTab === 'tools' && ( -
    - {projectStats && (() => { - const total = projectStats.toolUsage.reduce((s, t) => s + t.count, 0); - const unique = projectStats.toolUsage.length; - const avgPerSession = sessions.length > 0 ? (total / sessions.length).toFixed(1) : '0'; - return ( -
    - - - + {/* ── Weekly tab ── */} + {activeTab === 'weekly' && ( + + )} + + {/* ── MCP Servers tab ── */} + {activeTab === 'mcp' && ( +
    + {config && Object.keys(config.mcpServers).length > 0 ? ( +
    + {Object.values(config.mcpServers).map(server => ( + + ))}
    - ); - })()} - -
    -

    Breakdown

    -
    - {(projectStats?.toolUsage ?? []).length === 0 ? ( -
    No tool calls recorded yet.
    - ) : (projectStats?.toolUsage ?? []).map(item => ( -
    -
    - {item.tool} -
    -
    -
    0 ? (item.count / projectStats!.toolUsage[0].count) * 100 : 0}%`, - background: toolColor(item.tool), - opacity: 0.75, - }} + ) : ( +
    No MCP servers configured for this project.
    + )} +
    + )} + + {/* ── Memory tab ── */} + {activeTab === 'memory' && ( +
    + {(!config?.memory || (memoryFiles.length === 0 && !hasMemoryIndex)) ? ( + + ) : ( + <> + {hasMemoryIndex && ( +
    +
    +
    + + MEMORY.md + + Source of Truth +
    +

    Project Memory Index

    +

    + This is the main memory document. Click any markdown reference below to open its supporting memory file. +

    +
    + +
    + )} + {referencedMemoryFiles.length > 0 && ( + + )} + {!hasMemoryIndex && memoryFiles.length > 0 && ( + + )} + {selectedMemory ? ( +
    +
    -
    - {item.count} ({item.percentage}%) -
    -
    - ))} -
    -
    - -
    -

    Recent Calls

    -
    - {(projectStats?.recentToolCalls ?? []).length === 0 ? ( -
    No tool calls recorded yet.
    - ) : (projectStats?.recentToolCalls ?? []).map((tc, i) => ( - - ))} -
    -
    -
    - )} + ) : ( + + )} + {hasMemoryIndex && unlinkedMemoryFiles.length > 0 && ( + + )} + + )} +
    + )} - {/* ── CLAUDE.md tab ── */} - {activeTab === 'claudemd' && ( -
    - {config?.claudeMd ? ( - - ) : ( -
    No CLAUDE.md found in this project.
    - )} -
    - )} + {/* ── Plans tab ── */} + {activeTab === 'plans' && ( +
    + {plans.length === 0 ? ( + + ) : ( + <> + {plans.length > 1 && ( + + )} + {selectedPlan && ( + + )} + + )} +
    + )} - {/* ── Commands tab ── */} - {activeTab === 'commands' && ( -
    - {(!config?.commands || config.commands.length === 0) ? ( -
    No custom commands found in .claude/commands/.
    - ) : ( - <> -

    {config.commands.length} command{config.commands.length !== 1 ? 's' : ''} in .claude/commands/

    - {config.commands.map(cmd => ( - - ))} - - )} -
    - )} + {/* ── Todos tab ── */} + {activeTab === 'todos' && ( +
    + {(!projectTodos || projectTodos.length === 0) ? ( + + ) : ( + <> +
    + + + 0 ? 'warning' : 'neutral'} /> +
    + {projectTodos.length} session{projectTodos.length !== 1 ? 's' : ''} with saved todo state + {projectTodos.map((snapshot, index) => ( + + ))} + + )} +
    + )} - {/* ── Weekly tab ── */} - {activeTab === 'weekly' && ( - - )} + {/* ── Commits tab ── */} + {activeTab === 'commits' && ( +
    + {(!claudeCommits || claudeCommits.length === 0) ? ( + + ) : ( + <> +
    + + + +
    +
    +
    +
    +
    Commit History
    +

    + {claudeCommits.length} commit{claudeCommits.length !== 1 ? 's' : ''} co-authored by Claude +

    +
    + + Ordered by most recent activity + +
    +
    +
    + {claudeCommits.map(commit => ( + + ))} +
    + + )} +
    + )} - {/* ── MCP Servers tab ── */} - {activeTab === 'mcp' && ( -
    - {config && Object.keys(config.mcpServers).length > 0 ? ( -
    - {Object.values(config.mcpServers).map(server => ( - - ))} + {/* ── Settings tab ── */} + {activeTab === 'settings' && ( +
    +
    + + + 0 ? 'Configured' : 'Manual'} tone={hooksCount > 0 ? 'success' : 'neutral'} />
    - ) : ( -
    No MCP servers configured for this project.
    - )} -
    - )} +
    +

    Hooks

    + {(!config?.hooks || config.hooks.length === 0) ? ( + + ) : ( +
    + {config.hooks.map((hook, i) => ( + + ))} +
    + )} +
    + +
    +

    Project Settings

    + {projectSettingsEntries.length === 0 ? ( + + ) : ( +
    + {projectSettingsEntries.map(([key, value]) => ( +
    + {key} + {typeof value === 'object' ? JSON.stringify(value) : String(value)} +
    + ))} +
    + )} +
    +
    + )} +
    ); } @@ -326,19 +704,90 @@ export default function ProjectDetail({ project, sessions, subagentSessions, con function StatCard({ label, value }: { label: string; value: string }) { return ( -
    +
    {label}
    {value}
    ); } +function MiniStatCard({ label, value, tone = 'neutral' }: { label: string; value: string; tone?: 'neutral' | 'success' | 'warning' }) { + const toneClass = tone === 'success' + ? 'text-green-300' + : tone === 'warning' + ? 'text-yellow-300' + : 'text-[var(--vscode-editor-foreground)]'; + + return ( +
    +
    {label}
    +
    {value}
    +
    + ); +} + +function EmptyPanel({ title, detail, compact = false }: { title: string; detail: string; compact?: boolean }) { + return ( +
    +
    {title}
    +
    {detail}
    +
    + ); +} + +function TabButton({ label, badge, active, onClick }: { label: string; badge: string | null; active: boolean; onClick: () => void }) { + return ( + + ); +} + +function TabGroupButton({ + label, + description, + active, + onClick, +}: { + label: string; + description: string; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} + function FileRow({ file }: { file: ProjectFile }) { const typeColor = file.type === 'created' ? 'text-green-400' : file.type === 'both' ? 'text-blue-400' : 'text-yellow-400'; const typeLabel = file.type === 'created' ? 'created' : file.type === 'both' ? 'created+edited' : 'edited'; return ( -
    +
    {file.file} @@ -358,7 +807,7 @@ function FileRow({ file }: { file: ProjectFile }) { function McpServerRow({ server }: { server: McpServer }) { return ( -
    +
    {server.name} {server.type && ( @@ -397,7 +846,7 @@ function ToolCallRow({ tc }: { tc: ProjectToolCall }) { const hasFullInput = Object.keys(tc.input ?? {}).length > 0; return ( -
    +
    +
    subagent {new Date(session.startTime).toLocaleString([], { hour12: true })} @@ -464,3 +913,316 @@ function SubagentRow({ session, parentSessions }: { session: Session; parentSess
    ); } + +function memoryTypeColor(type: string): string { + switch (type) { + case 'index': return 'text-cyan-300 bg-cyan-500/15'; + case 'user': return 'text-blue-400 bg-blue-500/15'; + case 'feedback': return 'text-yellow-400 bg-yellow-500/15'; + case 'project': return 'text-green-400 bg-green-500/15'; + case 'reference': return 'text-purple-400 bg-purple-500/15'; + default: return 'opacity-60 bg-[var(--vscode-badge-background)] text-[var(--vscode-badge-foreground)]'; + } +} + +function normalizeMemoryFileName(fileName: string): string { + return fileName.trim().replace(/\\/g, '/').split('/').pop()?.toLowerCase() ?? fileName.toLowerCase(); +} + +function extractMemoryReferences(indexContent: string): string[] { + const references = new Set(); + if (!indexContent.trim()) { + return []; + } + + const markdownLinks = indexContent.matchAll(/\[[^\]]+\]\(([^)#?]+\.md)(?:#[^)]+)?\)/gi); + for (const match of markdownLinks) { + references.add(normalizeMemoryFileName(match[1])); + } + + const bareFileMentions = indexContent.matchAll(/\b([A-Za-z0-9._/-]+\.md)\b/gi); + for (const match of bareFileMentions) { + references.add(normalizeMemoryFileName(match[1])); + } + + references.delete('memory.md'); + return Array.from(references); +} + +function getMemoryExcerpt(content: string): string { + const flattened = content.replace(/\s+/g, ' ').trim(); + if (!flattened) { + return 'No preview available.'; + } + return flattened.length > 110 ? `${flattened.slice(0, 107)}...` : flattened; +} + +function TodoSnapshotCard({ snapshot, defaultExpanded = false }: { snapshot: SessionTodoSnapshot; defaultExpanded?: boolean }) { + const [collapsed, setCollapsed] = useState(!defaultExpanded); + const completed = snapshot.todos.filter(t => t.status === 'completed').length; + const total = snapshot.todos.length; + const allDone = completed === total; + const inProgress = snapshot.todos.filter(t => t.status === 'in_progress').length; + const pending = Math.max(0, total - completed - inProgress); + const sessionLabel = snapshot.sessionSummary || `Session ${snapshot.sessionId.slice(0, 8)}`; + const startedAt = new Date(snapshot.sessionDate).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }); + const updatedAt = new Date(snapshot.timestamp).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }); + + return ( +
    + + {!collapsed && ( +
    + {snapshot.todos.map((todo, i) => ( +
    + {todo.status === 'completed' ? ( + + + + + ) : todo.status === 'in_progress' ? ( + + + + + ) : ( + + + + )} + {todo.content} +
    + ))} +
    + )} +
    + ); +} + +function HookRow({ hook }: { hook: HookConfig }) { + return ( +
    +
    + {hook.event} + {hook.matcher && ( + matcher: {hook.matcher} + )} +
    +
    Command
    +
    {hook.command}
    +
    + ); +} + +function CommitRow({ commit }: { commit: ClaudeCommit }) { + const [copied, setCopied] = useState(false); + const copyHash = () => { + navigator.clipboard.writeText(commit.hash).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }); + }; + + return ( +
    + +
    +
    {commit.subject}
    +
    {commit.author} {commit.filesChanged > 0 && · {commit.filesChanged} file{commit.filesChanged !== 1 ? 's' : ''}}
    +
    + {timeAgo(commit.date)} +
    + ); +} + +function MemoryReferenceList({ + title, + detail, + files, + selectedFileName, + onSelect, + subdued = false, +}: { + title: string; + detail: string; + files: MemoryFile[]; + selectedFileName: string | null; + onSelect: (fileName: string) => void; + subdued?: boolean; +}) { + return ( +
    +
    +
    {title}
    +

    {detail}

    +
    +
    + {files.map(file => ( + onSelect(file.fileName)} + /> + ))} +
    +
    + ); +} + +function MemoryEntryButton({ memory, selected, onSelect }: { memory: MemoryFile; selected: boolean; onSelect: () => void }) { + return ( + + ); +} + +function MemoryPreviewPanel({ memory, referenced }: { memory: MemoryFile; referenced: boolean }) { + return ( +
    +
    +
    + + {memory.type} + + + {referenced ? 'Referenced In MEMORY.md' : 'Not Linked From MEMORY.md'} + +
    +

    {memory.name}

    + {memory.description && ( +

    {memory.description}

    + )} +
    {memory.fileName}
    +
    +
    +
    Markdown Preview
    +
    + +
    +
    +
    + ); +} + +function PlanReferenceList({ + files, + selectedFileName, + onSelect, +}: { + files: PlanFile[]; + selectedFileName: string | null; + onSelect: (fileName: string) => void; +}) { + return ( +
    +
    +
    Plans
    +

    Choose a saved plan to preview its roadmap or execution notes.

    +
    +
    + {files.map(file => ( + + ))} +
    +
    + ); +} + +function PlanPreviewPanel({ plan, totalPlans }: { plan: PlanFile; totalPlans: number }) { + return ( +
    +
    +
    + + Plan File + + + {totalPlans} saved plan{totalPlans !== 1 ? 's' : ''} + +
    +

    {plan.name}

    + {plan.description && ( +

    {plan.description}

    + )} +
    {plan.fileName}
    +
    +
    +
    Markdown Preview
    +
    + +
    +
    +
    + ); +} diff --git a/webview-ui/src/views/Sidebar.tsx b/webview-ui/src/views/Sidebar.tsx index 0be7c29..de96862 100644 --- a/webview-ui/src/views/Sidebar.tsx +++ b/webview-ui/src/views/Sidebar.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { vscode } from '../vscode'; import { Project, DashboardStats } from '../types'; -function timeAgo(ts: number): string { +export function sidebarTimeAgo(ts: number): string { if (!ts) { return 'never'; } const diff = Date.now() - ts; const h = Math.floor(diff / 3_600_000); @@ -12,15 +12,15 @@ function timeAgo(ts: number): string { return `${d}d ago`; } -function formatTokens(n: number): string { +export function formatSidebarTokens(n: number): string { if (n >= 1_000_000) { return `${(n / 1_000_000).toFixed(1)}M`; } if (n >= 1_000) { return `${(n / 1_000).toFixed(1)}k`; } return String(n); } -interface Props { projects: Project[]; stats: DashboardStats; } +interface Props { projects: Project[]; stats: DashboardStats; selectedProjectId?: string | null; } -export default function Sidebar({ projects, stats }: Props) { +export default function Sidebar({ projects, stats, selectedProjectId = null }: Props) { const unique = Array.from(new Map(projects.map(p => [p.id, p])).values()); const active = unique.filter(p => p.isActive); const recent = unique.filter(p => !p.isActive && Date.now() - p.lastActive < 7 * 86_400_000); @@ -42,7 +42,7 @@ export default function Sidebar({ projects, stats }: Props) { animation: 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite', flexShrink: 0, }} /> - {stats?.activeSessionCount ?? 0} active · {formatTokens(stats?.tokensTodayTotal ?? 0)} tokens today + {stats?.activeSessionCount ?? 0} active · {formatSidebarTokens(stats?.tokensTodayTotal ?? 0)} tokens today
    {/* Open Dashboard button */} @@ -70,13 +70,13 @@ export default function Sidebar({ projects, stats }: Props) { {/* Project list */}
    0}> - {active.map(p => )} + {active.map(p => )}
    0}> - {recent.map(p => )} + {recent.map(p => )}
    0}> - {rest.map(p => )} + {rest.map(p => )}
    @@ -130,19 +130,35 @@ function Section({ label, count, accent, show, children }: { ); } -function ProjectRow({ project: p }: { project: Project }) { +function ProjectRow({ project: p, selected }: { project: Project; selected: boolean }) { const [hovered, setHovered] = React.useState(false); return ( -
    vscode.postMessage({ type: 'openProject', projectId: p.id })} + aria-current={selected ? 'page' : undefined} onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '5px 12px', cursor: 'pointer', - background: hovered ? 'var(--vscode-list-hoverBackground)' : 'transparent', - transition: 'background 0.1s', + background: selected + ? 'var(--vscode-list-activeSelectionBackground)' + : hovered + ? 'var(--vscode-list-hoverBackground)' + : 'transparent', + color: selected + ? 'var(--vscode-list-activeSelectionForeground)' + : 'var(--vscode-foreground)', + borderLeft: selected ? '2px solid var(--vscode-textLink-foreground)' : '2px solid transparent', + paddingLeft: '10px', + transition: 'background 0.1s, border-color 0.1s', + width: '100%', + borderTop: 'none', + borderRight: 'none', + borderBottom: 'none', + textAlign: 'left', }} > {p.isActive ? ( @@ -160,9 +176,24 @@ function ProjectRow({ project: p }: { project: Project }) { {p.name} - - {p.isActive ? 'live' : timeAgo(p.lastActive)} - -
    + {selected ? ( + + open + + ) : ( + + {p.isActive ? 'live' : sidebarTimeAgo(p.lastActive)} + + )} + ); } diff --git a/webview-ui/src/views/__tests__/ProjectDetail.test.tsx b/webview-ui/src/views/__tests__/ProjectDetail.test.tsx index 7d192da..3cf9f8c 100644 --- a/webview-ui/src/views/__tests__/ProjectDetail.test.tsx +++ b/webview-ui/src/views/__tests__/ProjectDetail.test.tsx @@ -28,6 +28,9 @@ describe('ProjectDetail view', () => { mcpServers: { github: { name: 'github', command: 'npx', type: 'stdio', toolCallCount: 2 } }, projectSettings: {}, commands: [{ name: 'deploy', content: 'Ship it' }], + plans: [], + memory: { index: null, files: [] }, + hooks: [], }} projectStats={{ usageOverTime: [], @@ -57,21 +60,142 @@ describe('ProjectDetail view', () => { }); expect(screen.getByText('Loaded')).toBeInTheDocument(); - fireEvent.click(screen.getByText('Weekly')); + fireEvent.click(screen.getByText('Trends')); expect(screen.getByText('Sessions this week')).toBeInTheDocument(); - fireEvent.click(screen.getByText('CLAUDE.md')); + fireEvent.click(screen.getByText('Files')); + expect(screen.getByText('index.ts')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /Setup/ })); expect(screen.getByText('Rules')).toBeInTheDocument(); fireEvent.click(screen.getByText('Commands')); fireEvent.click(screen.getByText('/deploy')); expect(screen.getByText('Ship it')).toBeInTheDocument(); - fireEvent.click(screen.getByText('Tools')); + fireEvent.click(screen.getByRole('button', { name: /History/ })); + fireEvent.click(screen.getByText('Tool Usage')); expect(screen.getByText('Recent Calls')).toBeInTheDocument(); - fireEvent.click(screen.getByText('MCP Servers')); + fireEvent.click(screen.getByRole('button', { name: /Setup/ })); + fireEvent.click(screen.getByText('MCP')); expect(screen.getByText('github')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /Setup/ })); fireEvent.click(screen.getByText('Subagents')); expect(screen.getByText('Sub task')).toBeInTheDocument(); - fireEvent.click(screen.getByText('Files')); - expect(screen.getByText('index.ts')).toBeInTheDocument(); + }); + + it('renders memory, todos, commits, and settings tabs for project metadata', async () => { + const now = Date.now(); + + render(); + + fireEvent.click(screen.getByRole('button', { name: /Setup/ })); + fireEvent.click(screen.getByRole('button', { name: /Memory/ })); + expect(screen.getByText('Project Memory Index')).toBeInTheDocument(); + expect(screen.getByText('Source of Truth')).toBeInTheDocument(); + expect(screen.getByText('Referenced Files')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Working Set' })).toBeInTheDocument(); + expect(screen.getByText('Keep project context current.')).toBeInTheDocument(); + fireEvent.click(screen.getAllByRole('button', { name: /Project Bento/ })[0]); + expect(screen.getByRole('heading', { name: 'Product Snapshot' })).toBeInTheDocument(); + expect(screen.getByText('Focus on composable screenshots.')).toBeInTheDocument(); + fireEvent.click(screen.getAllByRole('button', { name: /Working Agreements/ })[0]); + expect(screen.getByRole('heading', { name: 'Agreements' })).toBeInTheDocument(); + expect(screen.getByText('Always add tests with new features.')).toBeInTheDocument(); + expect(screen.getAllByText('working-agreements.md').length).toBeGreaterThan(0); + expect(screen.getByText('Referenced In MEMORY.md')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /Workflow/ })); + expect(screen.getByText('Execution Plan')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Milestones' })).toBeInTheDocument(); + expect(screen.getByText('Validate UX')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /Todos/ })); + expect(screen.getByText('1 session with saved todo state')).toBeInTheDocument(); + expect(screen.getByText('Session')).toBeInTheDocument(); + expect(screen.getByText('Ship feature')).toBeInTheDocument(); + expect(screen.getByText('Verify docs')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /History/ })); + fireEvent.click(screen.getByRole('button', { name: /Commits/ })); + expect(screen.getByText('1 commit co-authored by Claude')).toBeInTheDocument(); + expect(screen.getByText('feat: add metadata views')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'abcdef12' })); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('abcdef1234567890'); + + fireEvent.click(screen.getByRole('button', { name: /Setup/ })); + fireEvent.click(screen.getByRole('button', { name: /Automation/ })); + expect(screen.getByText('echo stop')).toBeInTheDocument(); + expect(screen.getByText('matcher: Write')).toBeInTheDocument(); + expect(screen.getByText('theme')).toBeInTheDocument(); + expect(screen.getByText('dark')).toBeInTheDocument(); + expect(screen.getByText(JSON.stringify({ enabled: true }))).toBeInTheDocument(); + expect(screen.queryByText('echo hidden')).not.toBeInTheDocument(); }); it('renders empty states across project tabs', () => { @@ -79,7 +203,15 @@ describe('ProjectDetail view', () => { project={makeProject({ name: 'Empty Project', isActive: false, techStack: [] })} sessions={[]} subagentSessions={[]} - config={{ claudeMd: null, mcpServers: {}, projectSettings: {}, commands: [] }} + config={{ + claudeMd: null, + mcpServers: {}, + projectSettings: { hooks: {}, mcpServers: {} }, + commands: [], + plans: [], + memory: { index: null, files: [] }, + hooks: [], + }} projectStats={{ usageOverTime: [], toolUsage: [], @@ -89,21 +221,40 @@ describe('ProjectDetail view', () => { weeklyStats: { sessions: 0, tokens: 0, costUsd: 0, dailyBreakdown: [] }, }} projectFiles={[]} + projectTodos={[]} + claudeCommits={[]} />); expect(screen.getByText('Select a session to view details')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /Setup/ })); fireEvent.click(screen.getByText('Subagents')); expect(screen.getByText(/No subagent sessions found/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /History/ })); fireEvent.click(screen.getByText('Files')); expect(screen.getByText('No file edits recorded yet.')).toBeInTheDocument(); - fireEvent.click(screen.getByText('Tools')); + fireEvent.click(screen.getByText('Tool Usage')); expect(screen.getAllByText('No tool calls recorded yet.').length).toBe(2); - fireEvent.click(screen.getByText('CLAUDE.md')); + fireEvent.click(screen.getByRole('button', { name: /Setup/ })); + fireEvent.click(screen.getByRole('button', { name: /Claude Guide/ })); expect(screen.getByText('No CLAUDE.md found in this project.')).toBeInTheDocument(); fireEvent.click(screen.getByText('Commands')); expect(screen.getByText('No custom commands found in .claude/commands/.')).toBeInTheDocument(); - fireEvent.click(screen.getByText('MCP Servers')); + fireEvent.click(screen.getByText('MCP')); expect(screen.getByText('No MCP servers configured for this project.')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /Memory/ })); + expect(screen.getByText('No memory files found for this project.')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /Workflow/ })); + fireEvent.click(screen.getAllByRole('button', { name: /Plans/ })[1]); + expect(screen.getByText('No plan files found in this project.')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /Todos/ })); + expect(screen.getByText('No todo lists found in session history.')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /History/ })); + fireEvent.click(screen.getByRole('button', { name: /Commits/ })); + expect(screen.getByText('No Claude co-authored commits found in this project.')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /Setup/ })); + fireEvent.click(screen.getByRole('button', { name: /Automation/ })); + expect(screen.getByText('No hooks configured.')).toBeInTheDocument(); + expect(screen.getByText('No project-specific settings found.')).toBeInTheDocument(); }); }); diff --git a/webview-ui/src/views/__tests__/Sidebar.test.tsx b/webview-ui/src/views/__tests__/Sidebar.test.tsx index d68d8aa..2e99acc 100644 --- a/webview-ui/src/views/__tests__/Sidebar.test.tsx +++ b/webview-ui/src/views/__tests__/Sidebar.test.tsx @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest'; import { fireEvent, render, screen } from '../../__tests__/helpers/render-helpers'; import { makeProject, makeStats } from '../../__tests__/fixtures/test-data'; import { mockPostMessage } from '../../__tests__/setup'; -import Sidebar from '../Sidebar'; +import Sidebar, { formatSidebarTokens, sidebarTimeAgo } from '../Sidebar'; describe('Sidebar', () => { it('renders grouped sections and posts dashboard/project messages', () => { @@ -13,6 +13,7 @@ describe('Sidebar', () => { render( { ); expect(screen.getByText('1 active · 1.2k tokens today')).toBeInTheDocument(); + expect(screen.getByText('open')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Recent/ })).toHaveAttribute('aria-current', 'page'); expect(screen.getAllByText('Active').length).toBeGreaterThan(0); expect(screen.getAllByText('Recent').length).toBeGreaterThan(0); expect(screen.getAllByText('Older').length).toBeGreaterThan(0); @@ -40,4 +43,44 @@ describe('Sidebar', () => { vi.useRealTimers(); }); + + it('formats helper values and toggles sections closed/open', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-15T12:00:00Z')); + + render( + + ); + + expect(screen.getByText('0 active · 2.5M tokens today')).toBeInTheDocument(); + expect(screen.getByText('just now')).toBeInTheDocument(); + expect(screen.getByText('never')).toBeInTheDocument(); + expect(screen.getAllByText(/Duplicate/)).toHaveLength(1); + + fireEvent.mouseEnter(screen.getByText('View Dashboard')); + fireEvent.mouseLeave(screen.getByText('View Dashboard')); + fireEvent.click(screen.getByText('Recent')); + expect(screen.queryByRole('button', { name: /Recent Project/ })).not.toBeInTheDocument(); + fireEvent.click(screen.getByText('Recent')); + expect(screen.getByRole('button', { name: /Recent Project/ })).toBeInTheDocument(); + fireEvent.mouseEnter(screen.getByText('Recent')); + fireEvent.mouseLeave(screen.getByText('Recent')); + fireEvent.mouseEnter(screen.getByRole('button', { name: /Recent Project/ })); + fireEvent.mouseLeave(screen.getByRole('button', { name: /Recent Project/ })); + + expect(formatSidebarTokens(1500)).toBe('1.5k'); + expect(formatSidebarTokens(250)).toBe('250'); + expect(sidebarTimeAgo(Date.now() - 2 * 3_600_000)).toBe('2h ago'); + expect(sidebarTimeAgo(Date.now() - 9 * 86_400_000)).toBe('9d ago'); + + vi.useRealTimers(); + }); });