Skip to content

Commit 66b421c

Browse files
rajbosCopilot
andauthored
feat(chart): persist period selection and compact period picker UI (#595)
* feat(chart): persist period selection and move toggles inline with section header - Add lastChartPeriod field to extension; defaults to 'day' - Handle 'setPeriodPreference' message in showChart() to store chosen period - Pass initialPeriod in getChartHtml() so chart reopens on the last-used period - In bootstrap(), set currentPeriod from initialData.initialPeriod before render - In switchPeriod(), post setPeriodPreference message to extension - Move period toggle buttons from separate row into chart section header (right-aligned) - Period pills are smaller (11px font, 4px/9px padding) and sit alongside the heading - Saves one full row of vertical space; compact pills save screen real estate - Add .chart-section-header flex layout and .period-controls compact overrides Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * perf(cache): raise session file cache limit from 1000 to 3000 entries With ~1100 session files, the old 1000-entry limit caused constant evictions on every analysis cycle, keeping cache hit rates around 50-60%. 3000 entries gives comfortable headroom (3x current file count). Memory cost is ~1-2 MB which is well within VS Code extension and globalState limits. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(chart): group minor models into 'Other' and exclude Unknown from repo view By Model chart: - Rank all models by total tokens for the active period - Show top 5 models individually (largest usage first) - Collapse remaining models into a single 'Other models' dataset (gray) - Eliminates the wall of 20+ legend entries visible in the screenshot By Repository chart: - Exclude 'Unknown' entries from repository datasets and summary cards - 'Unknown' represents sessions with no repo context (empty window chats, global CLI sessions, etc.) which add noise to a per-repo breakdown - Same filter applied to repositoryTotalsMap used for the detail panel Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(repo-detection): extract repo from CLI tool args and handle git worktrees Two bugs that caused CLI sessions in worktrees to show as 'Unknown' in the By Repository chart: 1. CLI JSONL path extraction was dead code: allContentReferences was defined but never populated for non-delta (Copilot CLI) JSONL files. The loop only read rename_session calls. Now also reads tool.execution_start argument values that look like file paths and pushes them into allContentReferences, which is then fed to extractRepositoryFromContentReferences as before. 2. Git worktree detection missing in workspaceHelpers.ts: extractRepositoryFromContentReferences only checked for .git/config (standard git repo). Worktrees have .git as a FILE containing 'gitdir: <path>/.git/worktrees/<name>'. This file was unreadable as a directory, silently caught, and the walk continued upward past the repo root without finding the remote URL. Now also reads .git as a file, follows the gitdir pointer, resolves the main .git dir (2 levels up from the worktree-specific dir), and reads the remote URL from its config. Bump CACHE_VERSION 37 → 38 to invalidate stale 'no repo' cache entries so all existing CLI sessions are re-scanned on next reload. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: open Usage Analysis panel immediately, load stats in background Previously showUsageAnalysis() awaited calculateUsageAnalysisStats() before creating the panel, causing a ~20s blank delay when the cache was cold (e.g. while the chart was computing its 365-day background load). Apply the same two-phase pattern used by the chart view: - Create the webview panel immediately - If lastUsageAnalysisStats is cached, render it instantly - Otherwise show a loading spinner; fire calculateUsageAnalysisStats() in the background and push 'updateStats' to the webview when it completes - getUsageAnalysisHtml() now accepts UsageAnalysisStats | null - bootstrap() shows a loading message instead of 'No data available.' when null, since the updateStats message handler already calls renderLayout() Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 28d8945 commit 66b421c

6 files changed

Lines changed: 148 additions & 30 deletions

File tree

vscode-extension/src/cacheManager.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,10 @@ export class CacheManager {
6060
}
6161
this.sessionFileCache.set(filePath, data);
6262

63-
// Limit cache size to prevent memory issues (keep last 1000 files)
63+
// Limit cache size to prevent memory issues (keep last 3000 files)
6464
// Only trigger cleanup when size exceeds limit by 100 to avoid frequent operations
65-
if (this.sessionFileCache.size > 1100) {
66-
// Remove 100 oldest entries to bring size back to 1000
65+
if (this.sessionFileCache.size > 3100) {
66+
// Remove 100 oldest entries to bring size back to 3000
6767
// Maps maintain insertion order, so the first entries are the oldest
6868
const keysToDelete: string[] = [];
6969
let count = 0;

vscode-extension/src/extension.ts

Lines changed: 83 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ type RepoPrStatsResult = {
168168

169169
class CopilotTokenTracker implements vscode.Disposable {
170170
// Cache version - increment this when making changes that require cache invalidation
171-
private static readonly CACHE_VERSION = 37; // Add thinking effort (reasoning effort) tracking
171+
private static readonly CACHE_VERSION = 38; // Fix repo detection for CLI worktree sessions
172172
// Maximum length for displaying workspace IDs in diagnostics/customization matrix
173173
private static readonly WORKSPACE_ID_DISPLAY_LENGTH = 8;
174174

@@ -241,6 +241,8 @@ class CopilotTokenTracker implements vscode.Disposable {
241241
private lastDailyStats: DailyTokenStats[] | undefined;
242242
/** Full-year daily stats (up to 365 days) for the chart Week/Month period views. */
243243
private lastFullDailyStats: DailyTokenStats[] | undefined;
244+
/** Last period selected by the user in the chart view; restored on next open. */
245+
private lastChartPeriod: 'day' | 'week' | 'month' = 'day';
244246
private lastUsageAnalysisStats: UsageAnalysisStats | undefined;
245247
private lastDashboardData: any | undefined;
246248
private tokenEstimators: { [key: string]: number } = tokenEstimatorsData.estimators;
@@ -3473,6 +3475,16 @@ class CopilotTokenTracker implements vscode.Disposable {
34733475
if (event.type === 'tool.execution_start' && event.data?.toolName === 'rename_session') {
34743476
if (event.data?.arguments?.title) { details.title = event.data.arguments.title; }
34753477
}
3478+
3479+
// Collect file paths from tool arguments for repository detection
3480+
if (event.type === 'tool.execution_start' && event.data?.arguments) {
3481+
const args = event.data.arguments as Record<string, unknown>;
3482+
for (const val of Object.values(args)) {
3483+
if (typeof val === 'string' && val.length > 3 && (val.includes('/') || val.includes('\\'))) {
3484+
allContentReferences.push({ kind: 'reference', reference: { fsPath: val } });
3485+
}
3486+
}
3487+
}
34763488
} catch {
34773489
// Skip malformed lines
34783490
}
@@ -4768,6 +4780,12 @@ class CopilotTokenTracker implements vscode.Disposable {
47684780
if (message.command === 'refresh') {
47694781
await this.dispatch('refresh:chart', () => this.refreshChartPanel());
47704782
}
4783+
if (message.command === 'setPeriodPreference') {
4784+
const p = message.period;
4785+
if (p === 'day' || p === 'week' || p === 'month') {
4786+
this.lastChartPeriod = p;
4787+
}
4788+
}
47714789
});
47724790

47734791
// Render immediately; Week/Month buttons are shown as loading if full-year data isn't ready
@@ -4801,10 +4819,7 @@ class CopilotTokenTracker implements vscode.Disposable {
48014819
this.analysisPanel = undefined;
48024820
}
48034821

4804-
// Get usage analysis stats (use cached version for fast loading)
4805-
const analysisStats = await this.calculateUsageAnalysisStats(true);
4806-
4807-
// Create webview panel
4822+
// Create webview panel immediately so the user sees something right away
48084823
this.analysisPanel = vscode.window.createWebviewPanel(
48094824
'copilotUsageAnalysis',
48104825
'AI Usage Analysis',
@@ -4859,8 +4874,31 @@ class CopilotTokenTracker implements vscode.Disposable {
48594874
}
48604875
});
48614876

4862-
// Set the HTML content
4863-
this.analysisPanel.webview.html = this.getUsageAnalysisHtml(this.analysisPanel.webview, analysisStats);
4877+
// Set HTML immediately — use cached stats if available, else show loading spinner
4878+
this.analysisPanel.webview.html = this.getUsageAnalysisHtml(this.analysisPanel.webview, this.lastUsageAnalysisStats ?? null);
4879+
4880+
// If no cached stats, compute in the background and push via updateStats
4881+
if (!this.lastUsageAnalysisStats) {
4882+
this.calculateUsageAnalysisStats(true).then(analysisStats => {
4883+
if (!this.analysisPanel) { return; }
4884+
void this.analysisPanel.webview.postMessage({
4885+
command: 'updateStats',
4886+
data: {
4887+
today: analysisStats.today,
4888+
last30Days: analysisStats.last30Days,
4889+
month: analysisStats.month,
4890+
locale: analysisStats.locale,
4891+
customizationMatrix: analysisStats.customizationMatrix || null,
4892+
missedPotential: analysisStats.missedPotential || [],
4893+
lastUpdated: analysisStats.lastUpdated.toISOString(),
4894+
backendConfigured: this.isBackendConfigured(),
4895+
currentWorkspacePaths: vscode.workspace.workspaceFolders?.map(f => f.uri.fsPath) ?? [],
4896+
},
4897+
});
4898+
}).catch(err => {
4899+
this.error(`Failed to load usage analysis stats: ${err}`);
4900+
});
4901+
}
48644902

48654903
// Handle panel disposal
48664904
this.analysisPanel.onDidDispose(() => {
@@ -7997,14 +8035,40 @@ ${hashtag}`;
79978035

79988036
const allModels = new Set<string>();
79998037
entries.forEach(e => Object.keys(e.modelUsage).forEach(m => allModels.add(m)));
8000-
const modelDatasets = Array.from(allModels).map((model, idx) => {
8038+
8039+
// Rank models by total tokens across the period; keep top 5, group the rest
8040+
const modelTotals = new Map<string, number>();
8041+
for (const model of allModels) {
8042+
const total = entries.reduce((sum, e) => {
8043+
const u = e.modelUsage[model];
8044+
return sum + (u ? u.inputTokens + u.outputTokens : 0);
8045+
}, 0);
8046+
modelTotals.set(model, total);
8047+
}
8048+
const sortedModels = Array.from(allModels).sort((a, b) => (modelTotals.get(b) || 0) - (modelTotals.get(a) || 0));
8049+
const topModels = sortedModels.slice(0, 5);
8050+
const otherModels = sortedModels.slice(5);
8051+
8052+
const modelDatasets = topModels.map((model, idx) => {
80018053
const color = modelColors[idx % modelColors.length];
80028054
return {
80038055
label: getModelDisplayName(model),
80048056
data: entries.map(e => { const u = e.modelUsage[model]; return u ? u.inputTokens + u.outputTokens : 0; }),
80058057
backgroundColor: color.bg, borderColor: color.border, borderWidth: 1,
80068058
};
80078059
});
8060+
if (otherModels.length > 0) {
8061+
modelDatasets.push({
8062+
label: 'Other models',
8063+
data: entries.map(e => otherModels.reduce((sum, m) => {
8064+
const u = e.modelUsage[m];
8065+
return sum + (u ? u.inputTokens + u.outputTokens : 0);
8066+
}, 0)),
8067+
backgroundColor: 'rgba(150, 150, 150, 0.5)',
8068+
borderColor: 'rgba(150, 150, 150, 0.8)',
8069+
borderWidth: 1,
8070+
});
8071+
}
80088072

80098073
const allEditors = new Set<string>();
80108074
entries.forEach(e => Object.keys(e.editorUsage).forEach(ed => allEditors.add(ed)));
@@ -8018,7 +8082,9 @@ ${hashtag}`;
80188082
});
80198083

80208084
const allRepos = new Set<string>();
8021-
entries.forEach(e => Object.keys(e.repositoryUsage).forEach(r => allRepos.add(r)));
8085+
entries.forEach(e => Object.keys(e.repositoryUsage)
8086+
.filter(r => r !== 'Unknown')
8087+
.forEach(r => allRepos.add(r)));
80228088
const repositoryDatasets = Array.from(allRepos).map((repo, idx) => {
80238089
const color = modelColors[idx % modelColors.length];
80248090
return {
@@ -8111,7 +8177,9 @@ ${hashtag}`;
81118177
});
81128178
const repositoryTotalsMap: Record<string, number> = {};
81138179
dailyBuckets.forEach(b => {
8114-
Object.entries(b.stats.repositoryUsage).forEach(([repo, usage]) => {
8180+
Object.entries(b.stats.repositoryUsage)
8181+
.filter(([repo]) => repo !== 'Unknown')
8182+
.forEach(([repo, usage]) => {
81158183
const displayName = this.getRepoDisplayName(repo);
81168184
repositoryTotalsMap[displayName] = (repositoryTotalsMap[displayName] || 0) + usage.tokens;
81178185
});
@@ -8160,7 +8228,7 @@ ${hashtag}`;
81608228
`script-src 'nonce-${nonce}'`,
81618229
].join("; ");
81628230

8163-
const chartData = { ...this.buildChartData(dailyStats), periodsReady };
8231+
const chartData = { ...this.buildChartData(dailyStats), periodsReady, initialPeriod: this.lastChartPeriod };
81648232

81658233
const initialData = JSON.stringify(chartData).replace(/</g, "\\u003c");
81668234

@@ -8183,7 +8251,7 @@ ${hashtag}`;
81838251

81848252
private getUsageAnalysisHtml(
81858253
webview: vscode.Webview,
8186-
stats: UsageAnalysisStats,
8254+
stats: UsageAnalysisStats | null,
81878255
): string {
81888256
const nonce = this.getNonce();
81898257
const scriptUri = webview.asWebviewUri(
@@ -8210,7 +8278,7 @@ ${hashtag}`;
82108278
);
82118279
this.log(`[Locale Detection] Intl default: ${intlLocale}`);
82128280

8213-
const detectedLocale = stats.locale || localeFromEnv || intlLocale;
8281+
const detectedLocale = (stats?.locale) || localeFromEnv || intlLocale;
82148282
this.log(`[Usage Analysis] Extension detected locale: ${detectedLocale}`);
82158283
this.log(
82168284
`[Usage Analysis] Test format 1234567.89: ${new Intl.NumberFormat(detectedLocale).format(1234567.89)}`,
@@ -8220,7 +8288,7 @@ ${hashtag}`;
82208288
.getConfiguration('copilotTokenTracker')
82218289
.get<string[]>('suppressedUnknownTools', []);
82228290

8223-
const initialData = JSON.stringify({
8291+
const initialData = stats ? JSON.stringify({
82248292
today: stats.today,
82258293
last30Days: stats.last30Days,
82268294
month: stats.month,
@@ -8231,7 +8299,7 @@ ${hashtag}`;
82318299
backendConfigured: this.isBackendConfigured(),
82328300
currentWorkspacePaths: vscode.workspace.workspaceFolders?.map(f => f.uri.fsPath) ?? [],
82338301
suppressedUnknownTools,
8234-
}).replace(/</g, "\\u003c");
8302+
}).replace(/</g, "\\u003c") : 'null';
82358303

82368304
return `<!DOCTYPE html>
82378305
<html lang="en">

vscode-extension/src/webview/chart/main.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ type InitialChartData = {
4747
backendConfigured?: boolean;
4848
compactNumbers?: boolean;
4949
periodsReady?: boolean;
50+
initialPeriod?: ChartPeriod;
5051
periods?: {
5152
day: ChartPeriodData;
5253
week: ChartPeriodData;
@@ -168,12 +169,12 @@ function renderLayout(data: InitialChartData): void {
168169
}
169170

170171
const chartSection = el('div', 'section');
171-
chartSection.append(el('h3', '', '📊 Charts'));
172+
// Chart section header: title left, period toggles right
173+
const chartSectionHeader = el('div', 'chart-section-header');
174+
chartSectionHeader.append(el('h3', '', '📊 Charts'));
172175

173-
const chartShell = el('div', 'chart-shell');
174-
175-
// Period toggle row
176-
const periodToggles = el('div', 'chart-controls period-controls');
176+
// Period toggles (compact, inline with section heading)
177+
const periodToggles = el('div', 'period-controls');
177178
const periodsReady = data.periodsReady !== false;
178179
const dayBtn = el('button', `toggle${currentPeriod === 'day' ? ' active' : ''}`, '📅 Day');
179180
dayBtn.id = 'period-day';
@@ -190,6 +191,10 @@ function renderLayout(data: InitialChartData): void {
190191
monthBtn.title = 'Loading historical data…';
191192
}
192193
periodToggles.append(dayBtn, weekBtn, monthBtn);
194+
chartSectionHeader.append(periodToggles);
195+
chartSection.append(chartSectionHeader);
196+
197+
const chartShell = el('div', 'chart-shell');
193198

194199
// Chart view toggle row
195200
const toggles = el('div', 'chart-controls');
@@ -208,7 +213,7 @@ function renderLayout(data: InitialChartData): void {
208213
canvas.id = 'token-chart';
209214
canvasWrap.append(canvas);
210215

211-
chartShell.append(periodToggles, toggles, canvasWrap);
216+
chartShell.append(toggles, canvasWrap);
212217
chartSection.append(chartShell);
213218

214219
const footer = el('div', 'footer',
@@ -346,6 +351,7 @@ async function switchPeriod(period: ChartPeriod, data: InitialChartData): Promis
346351
return;
347352
}
348353
currentPeriod = period;
354+
vscode.postMessage({ command: 'setPeriodPreference', period });
349355
setActivePeriod(period);
350356
updateSummaryCards(data);
351357
if (!chart) {
@@ -551,6 +557,9 @@ async function bootstrap(): Promise<void> {
551557
}
552558
return;
553559
}
560+
if (initialData.initialPeriod) {
561+
currentPeriod = initialData.initialPeriod;
562+
}
554563
renderLayout(initialData);
555564
}
556565

vscode-extension/src/webview/chart/styles.css

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,18 @@ body {
103103
margin-top: 2px;
104104
}
105105

106-
.chart-shell {
107-
background: var(--bg-tertiary);
106+
.chart-section-header {
107+
display: flex;
108+
justify-content: space-between;
109+
align-items: center;
110+
margin-bottom: 10px;
111+
}
112+
113+
.chart-section-header h3 {
114+
margin: 0;
115+
}
116+
117+
.chart-shell { background: var(--bg-tertiary);
108118
border: 1px solid var(--border-subtle);
109119
border-radius: 10px;
110120
padding: 12px;
@@ -121,9 +131,14 @@ body {
121131
}
122132

123133
.period-controls {
124-
margin-bottom: 4px;
125-
padding-bottom: 8px;
126-
border-bottom: 1px solid var(--border-subtle);
134+
display: flex;
135+
gap: 4px;
136+
align-items: center;
137+
}
138+
139+
.period-controls .toggle {
140+
font-size: 11px;
141+
padding: 4px 9px;
127142
}
128143

129144
.toggle {

vscode-extension/src/webview/usage/main.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1997,8 +1997,9 @@ async function bootstrap(): Promise<void> {
19971997
if (!initialData) {
19981998
const root = document.getElementById('root');
19991999
if (root) {
2000-
root.textContent = 'No data available.';
2000+
root.innerHTML = '<div style="padding: 32px; text-align: center; color: var(--vscode-foreground); opacity: 0.7; font-size: 14px;">⏳ Loading usage analysis…</div>';
20012001
}
2002+
// Stats will arrive via the updateStats message; the module-level listener will call renderLayout then.
20022003
return;
20032004
}
20042005
console.log('[Usage Analysis] Browser default locale:', Intl.DateTimeFormat().resolvedOptions().locale);

vscode-extension/src/workspaceHelpers.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,31 @@ export async function extractRepositoryFromContentReferences(contentReferences:
586586
} catch {
587587
// No .git/config at this level, continue up the tree
588588
}
589+
590+
// Also check if .git is a file (git worktree) — contains "gitdir: <path>"
591+
const gitFilePath = path.join(potentialRoot, '.git');
592+
try {
593+
const gitFileContent = await fs.promises.readFile(gitFilePath, 'utf8');
594+
const match = gitFileContent.match(/^gitdir:\s*(.+)$/m);
595+
if (match) {
596+
const gitdirPath = match[1].trim();
597+
const basePath = potentialRoot.replace(/\//g, path.sep);
598+
const resolvedGitdir = path.isAbsolute(gitdirPath)
599+
? gitdirPath
600+
: path.resolve(basePath, gitdirPath);
601+
// Standard worktree: gitdir = <main>/.git/worktrees/<name>
602+
// Main .git dir is 2 levels up; its config holds the remote URL
603+
const mainGitDir = path.resolve(resolvedGitdir, '..', '..');
604+
const mainConfigPath = path.join(mainGitDir, 'config');
605+
const gitConfig = await fs.promises.readFile(mainConfigPath, 'utf8');
606+
const remoteUrl = parseGitRemoteUrl(gitConfig);
607+
if (remoteUrl) {
608+
return remoteUrl;
609+
}
610+
}
611+
} catch {
612+
// Not a worktree or can't read gitdir, continue
613+
}
589614
}
590615
}
591616

0 commit comments

Comments
 (0)