Skip to content

Commit 2d007b1

Browse files
rajbosCopilot
andauthored
fix(usage-analysis): prevent blank page on cold start and improve error handling (#661)
* fix(usage-analysis): prevent blank page on cold start and improve error handling Fixes #658 — Usage Analysis tab could show nothing (stuck on loading spinner or blank page) when: - Background stats calculation failed with no feedback to the webview - sanitizeStats dropped fields needed for rendering (locale, matrix, missed potential, workspace paths, suppressed tools) - bootstrap() had no error handling (blank page if toolkit import fails) - No timeout for the loading state (spinner forever) Changes: - extension.ts: send updateStatsError to webview on calculation failure; guard against stale async results posting into a recreated panel - webview/usage/main.ts: sanitizeStats now validates and passes through all fields; handle updateStatsError and null sanitization with error message + refresh button; bootstrap wraps in .catch() with fallback; 30s non-fatal loading timeout with refresh hint Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(discovery): scan GitHub.copilot storage path for VS Code 1.117+ sessions Starting with Copilot 1.117+, chat sessions are stored under workspaceStorage/<hash>/GitHub.copilot/chatSessions/ instead of the older GitHub.copilot-chat/chatSessions/ path. Users on the unified Copilot extension (e.g. v0.45.x) had zero session data discovered. Add GitHub.copilot and github.copilot (case-sensitive variant) to: - workspaceStorage candidate list in discover() - globalStorage recursive scan - isCopilotChatSessionPath() path predicate - Module doc comment discovery scope list Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent ebe5a5d commit 2d007b1

3 files changed

Lines changed: 112 additions & 8 deletions

File tree

vscode-extension/src/adapters/copilotChatAdapter.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,11 @@
99
* - workspaceStorage/<hash>/chatSessions/ (legacy layout)
1010
* - workspaceStorage/<hash>/GitHub.copilot-chat/chatSessions/ (newer layout)
1111
* - workspaceStorage/<hash>/github.copilot-chat/chatSessions/ (Linux case-sensitive variant)
12+
* - workspaceStorage/<hash>/GitHub.copilot/chatSessions/ (unified extension, VS Code 1.117+)
13+
* - workspaceStorage/<hash>/github.copilot/chatSessions/ (Linux case-sensitive variant)
1214
* - globalStorage/emptyWindowChatSessions/ (legacy)
1315
* - globalStorage/{GitHub,github}.copilot-chat/** (both casings, recursive)
16+
* - globalStorage/{GitHub,github}.copilot/** (unified extension, both casings)
1417
*
1518
* NOTE on `handles()`: this adapter currently returns `false` so that the
1619
* existing fallback parsing code in `extension.ts` continues to own the
@@ -247,16 +250,16 @@ export function isCopilotChatSessionPath(filePath: string): boolean {
247250
if (!/\.jsonl?$/.test(norm)) { return false; }
248251

249252
// workspaceStorage/<hash>/chatSessions/<file>
250-
if (/\/workspaceStorage\/[^/]+\/(?:GitHub\.copilot-chat|github\.copilot-chat)\/chatSessions\/[^/]+$/.test(norm)) {
253+
if (/\/workspaceStorage\/[^/]+\/(?:GitHub\.copilot-chat|github\.copilot-chat|GitHub\.copilot|github\.copilot)\/chatSessions\/[^/]+$/.test(norm)) {
251254
return true;
252255
}
253256
if (/\/workspaceStorage\/[^/]+\/chatSessions\/[^/]+$/.test(norm)) {
254257
return true;
255258
}
256259
// globalStorage/emptyWindowChatSessions/<file>
257260
if (/\/globalStorage\/emptyWindowChatSessions\/[^/]+$/.test(norm)) { return true; }
258-
// globalStorage/{GitHub,github}.copilot-chat/**
259-
if (/\/globalStorage\/(?:GitHub|github)\.copilot-chat\/.+$/.test(norm)) {
261+
// globalStorage/{GitHub,github}.copilot-chat/** and {GitHub,github}.copilot/**
262+
if (/\/globalStorage\/(?:GitHub|github)\.copilot(?:-chat)?\/.+$/.test(norm)) {
260263
return !isNonSessionFile(path.basename(norm));
261264
}
262265
return false;
@@ -373,7 +376,7 @@ export class CopilotChatAdapter implements IEcosystemAdapter, IDiscoverableEcosy
373376
await runWithConcurrency(foundPaths, async (codeUserPath) => {
374377
const pathName = path.basename(path.dirname(codeUserPath));
375378

376-
// workspaceStorage/<hash>/{,GitHub.copilot-chat/,github.copilot-chat/}chatSessions/
379+
// workspaceStorage/<hash>/{,GitHub.copilot-chat/,github.copilot-chat/,GitHub.copilot/,github.copilot/}chatSessions/
377380
const workspaceStoragePath = path.join(codeUserPath, 'workspaceStorage');
378381
try {
379382
if (await pathExists(workspaceStoragePath)) {
@@ -383,6 +386,8 @@ export class CopilotChatAdapter implements IEcosystemAdapter, IDiscoverableEcosy
383386
path.join(workspaceStoragePath, workspaceDir, 'chatSessions'),
384387
path.join(workspaceStoragePath, workspaceDir, 'GitHub.copilot-chat', 'chatSessions'),
385388
path.join(workspaceStoragePath, workspaceDir, 'github.copilot-chat', 'chatSessions'),
389+
path.join(workspaceStoragePath, workspaceDir, 'GitHub.copilot', 'chatSessions'),
390+
path.join(workspaceStoragePath, workspaceDir, 'github.copilot', 'chatSessions'),
386391
];
387392
for (const chatSessionsPath of candidates) {
388393
try {
@@ -418,8 +423,8 @@ export class CopilotChatAdapter implements IEcosystemAdapter, IDiscoverableEcosy
418423
log(`Could not check global storage path ${globalStoragePath}: ${e}`);
419424
}
420425

421-
// globalStorage/{GitHub,github}.copilot-chat/** (recursive)
422-
for (const extFolderName of ['GitHub.copilot-chat', 'github.copilot-chat']) {
426+
// globalStorage/{GitHub,github}.copilot-chat/** and {GitHub,github}.copilot/** (recursive)
427+
for (const extFolderName of ['GitHub.copilot-chat', 'github.copilot-chat', 'GitHub.copilot', 'github.copilot']) {
423428
const copilotChatGlobalPath = path.join(codeUserPath, 'globalStorage', extFolderName);
424429
try {
425430
if (await pathExists(copilotChatGlobalPath)) {

vscode-extension/src/extension.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4137,8 +4137,11 @@ usageAnalysis: undefined
41374137

41384138
// If no cached stats, compute in the background and push via updateStats
41394139
if (!this.lastUsageAnalysisStats) {
4140+
// Capture panel reference to guard against stale async results
4141+
// (user could close and reopen the panel while calculation is in flight)
4142+
const panel = this.analysisPanel;
41404143
this.calculateUsageAnalysisStats(true).then(analysisStats => {
4141-
if (!this.analysisPanel) { return; }
4144+
if (!this.analysisPanel || this.analysisPanel !== panel) { return; }
41424145
void this.analysisPanel.webview.postMessage({
41434146
command: 'updateStats',
41444147
data: {
@@ -4155,6 +4158,12 @@ usageAnalysis: undefined
41554158
});
41564159
}).catch(err => {
41574160
this.error(`Failed to load usage analysis stats: ${err}`);
4161+
if (this.analysisPanel && this.analysisPanel === panel) {
4162+
void this.analysisPanel.webview.postMessage({
4163+
command: 'updateStatsError',
4164+
error: String(err),
4165+
});
4166+
}
41584167
});
41594168
}
41604169

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

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,34 @@ let isSwitchingRepository = false;
121121
let isBatchAnalysisInProgress = false;
122122
let currentWorkspacePaths: string[] = [];
123123
let activeTab = 'activity';
124+
let loadingTimeoutId: ReturnType<typeof setTimeout> | null = null;
125+
126+
function clearLoadingTimeout(): void {
127+
if (loadingTimeoutId !== null) {
128+
clearTimeout(loadingTimeoutId);
129+
loadingTimeoutId = null;
130+
}
131+
}
132+
133+
function showLoadError(message: string): void {
134+
const root = document.getElementById('root');
135+
if (!root) { return; }
136+
const container = document.createElement('div');
137+
container.style.cssText = 'padding: 32px; text-align: center; font-size: 14px;';
138+
const icon = document.createElement('div');
139+
icon.style.cssText = 'font-size: 24px; margin-bottom: 12px;';
140+
icon.textContent = '❌';
141+
const msg = document.createElement('div');
142+
msg.style.cssText = 'color: var(--vscode-errorForeground, #f48771); margin-bottom: 16px;';
143+
msg.textContent = message;
144+
const btn = document.createElement('button');
145+
btn.textContent = '🔄 Refresh';
146+
btn.style.cssText = 'padding: 6px 16px; cursor: pointer; border: 1px solid var(--vscode-button-border, transparent); background: var(--vscode-button-background, #0e639c); color: var(--vscode-button-foreground, #fff); border-radius: 2px; font-size: 13px;';
147+
btn.addEventListener('click', () => vscode.postMessage({ command: 'refresh' }));
148+
container.append(icon, msg, btn);
149+
root.textContent = '';
150+
root.append(container);
151+
}
124152

125153
// State for the Repository PRs tab
126154
let repoPrStatsLoaded = false;
@@ -421,8 +449,28 @@ function sanitizeStats(raw: any): UsageAnalysisStats | null {
421449
month: sanitizePeriod(raw.month),
422450
lastUpdated: typeof raw.lastUpdated === 'string' ? raw.lastUpdated : '',
423451
backendConfigured: !!raw.backendConfigured,
452+
locale: typeof raw.locale === 'string' ? raw.locale : undefined,
453+
currentWorkspacePaths: Array.isArray(raw.currentWorkspacePaths)
454+
? raw.currentWorkspacePaths.filter((p: unknown) => typeof p === 'string') as string[]
455+
: undefined,
456+
suppressedUnknownTools: Array.isArray(raw.suppressedUnknownTools)
457+
? raw.suppressedUnknownTools.filter((t: unknown) => typeof t === 'string') as string[]
458+
: undefined,
424459
};
425460

461+
// Validated pass-through for customizationMatrix (nested shape check)
462+
if (raw.customizationMatrix && typeof raw.customizationMatrix === 'object'
463+
&& Array.isArray(raw.customizationMatrix.workspaces)) {
464+
sanitized.customizationMatrix = raw.customizationMatrix as WorkspaceCustomizationMatrix;
465+
}
466+
467+
// Validated pass-through for missedPotential (array of objects)
468+
if (Array.isArray(raw.missedPotential)) {
469+
sanitized.missedPotential = raw.missedPotential.filter(
470+
(w: any) => w && typeof w === 'object' && typeof w.workspacePath === 'string'
471+
) as MissedPotentialWorkspace[];
472+
}
473+
426474
return sanitized;
427475
} catch {
428476
return null;
@@ -1446,6 +1494,7 @@ window.addEventListener('message', (event) => {
14461494
break;
14471495
case 'updateStats':
14481496
// Re-render the layout with fresh stats, then restore repo analysis results
1497+
clearLoadingTimeout();
14491498
if (message.data?.locale) {
14501499
setFormatLocale(message.data.locale);
14511500
}
@@ -1458,9 +1507,15 @@ window.addEventListener('message', (event) => {
14581507
if (repoPrStatsData) {
14591508
updateReposPrPanel(repoPrStatsData);
14601509
}
1510+
} else {
1511+
showLoadError('Received invalid data from the extension. Try refreshing.');
14611512
}
14621513
}
14631514
break;
1515+
case 'updateStatsError':
1516+
clearLoadingTimeout();
1517+
showLoadError('Failed to calculate usage analysis. Check the Output panel for details.');
1518+
break;
14641519
case 'highlightUnknownTools': {
14651520
// Switch to tools tab
14661521
activeTab = 'tools';
@@ -2008,6 +2063,24 @@ async function bootstrap(): Promise<void> {
20082063
if (root) {
20092064
root.innerHTML = '<div style="padding: 32px; text-align: center; color: var(--vscode-foreground); opacity: 0.7; font-size: 14px;">⏳ Loading usage analysis…</div>';
20102065
}
2066+
// If data doesn't arrive within 30s, show a helpful hint (non-fatal)
2067+
loadingTimeoutId = setTimeout(() => {
2068+
const r = document.getElementById('root');
2069+
if (r && r.innerHTML.includes('Loading usage analysis')) {
2070+
const hint = document.createElement('div');
2071+
hint.style.cssText = 'padding: 32px; text-align: center; font-size: 14px;';
2072+
const msg = document.createElement('div');
2073+
msg.style.cssText = 'color: var(--vscode-foreground); opacity: 0.7; margin-bottom: 12px;';
2074+
msg.textContent = '⏳ Taking longer than expected… Session files may be large or the scan is still in progress.';
2075+
const btn = document.createElement('button');
2076+
btn.textContent = '🔄 Refresh';
2077+
btn.style.cssText = 'padding: 6px 16px; cursor: pointer; border: 1px solid var(--vscode-button-border, transparent); background: var(--vscode-button-background, #0e639c); color: var(--vscode-button-foreground, #fff); border-radius: 2px; font-size: 13px;';
2078+
btn.addEventListener('click', () => vscode.postMessage({ command: 'refresh' }));
2079+
hint.append(msg, btn);
2080+
r.textContent = '';
2081+
r.append(hint);
2082+
}
2083+
}, 30_000);
20112084
// Stats will arrive via the updateStats message; the module-level listener will call renderLayout then.
20122085
return;
20132086
}
@@ -2027,4 +2100,21 @@ async function bootstrap(): Promise<void> {
20272100
});
20282101
}
20292102

2030-
void bootstrap();
2103+
void bootstrap().catch(err => {
2104+
console.error('[Usage Analysis] Bootstrap failed:', err);
2105+
const root = document.getElementById('root');
2106+
if (root) {
2107+
const container = document.createElement('div');
2108+
container.style.cssText = 'padding: 32px; text-align: center; font-size: 14px;';
2109+
const msg = document.createElement('div');
2110+
msg.style.cssText = 'color: var(--vscode-errorForeground, #f48771); margin-bottom: 16px;';
2111+
msg.textContent = 'Failed to initialize usage analysis. Please try refreshing.';
2112+
const btn = document.createElement('button');
2113+
btn.textContent = '🔄 Refresh';
2114+
btn.style.cssText = 'padding: 6px 16px; cursor: pointer; border: 1px solid var(--vscode-button-border, transparent); background: var(--vscode-button-background, #0e639c); color: var(--vscode-button-foreground, #fff); border-radius: 2px; font-size: 13px;';
2115+
btn.addEventListener('click', () => vscode.postMessage({ command: 'refresh' }));
2116+
container.append(msg, btn);
2117+
root.textContent = '';
2118+
root.append(container);
2119+
}
2120+
});

0 commit comments

Comments
 (0)