Skip to content

Commit b6a70fc

Browse files
luoxuanzaoQoder-AI
andauthored
feat: surface startup restore failures with an aggregated notice (#24)
Add restore diagnostics: a windowed collector gathers issues from the four restore stages (layout, tab, metadata, history) during startup and the chat view drains them into a single aggregated notice. Each issue is also logged to the developer console with a stage prefix. - Report layout read failures, per-tab rebuild failures, session metadata read failures, and history hydration gaps instead of failing silently; first-run vaults with no data never report. - Deduplicate repeated issues so the notice count reflects real root causes, and always close the report window from the restore path. - Add localized notice copy in all ten languages and CHANGELOG entry. Co-authored-by: QoderAI (Qwen 3.8 Max) <qoder_ai@qoder.com>
1 parent 71340ea commit b6a70fc

24 files changed

Lines changed: 554 additions & 20 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ version with its date and start a fresh empty `[Unreleased]` above it.
4545
collapse), the toolbar wraps instead of clipping, and the permission
4646
mode and model dropdowns shrink to stay inside the sidebar, with
4747
long model names ellipsized.
48+
- Startup session restore no longer fails silently: when the tab
49+
layout, an individual tab, session metadata, or conversation history
50+
cannot be read, Qoderian now shows a single notice with the issue
51+
count and logs per-stage details to the developer console.
4852

4953
## [1.0.4] - 2026-08-12
5054

src/app/storage/app-storage.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Plugin } from 'obsidian';
22
import { Notice } from 'obsidian';
33

4+
import { reportRestoreIssue } from '../../core/diagnostics/restore-report';
45
import { VaultFileAdapter } from '../../core/storage/vault-file-adapter';
56
import type { AppTabManagerState } from '../../core/types/services';
67
import {
@@ -14,6 +15,10 @@ function isRecord(value: unknown): value is Record<string, unknown> {
1415
return !!value && typeof value === 'object' && !Array.isArray(value);
1516
}
1617

18+
function errorMessage(error: unknown): string {
19+
return error instanceof Error ? error.message : String(error);
20+
}
21+
1722
export class QoderianStorage {
1823
readonly qoderianSettings: QoderianSettingsStorage;
1924
readonly sessions: SessionStorage;
@@ -62,11 +67,17 @@ export class QoderianStorage {
6267
try {
6368
const data: unknown = await this.plugin.loadData();
6469
if (!isRecord(data) || !data.tabManagerState) {
70+
await this.reportUnreadablePluginData();
6571
return null;
6672
}
6773

68-
return this.validateTabManagerState(data.tabManagerState);
69-
} catch {
74+
const state = this.validateTabManagerState(data.tabManagerState);
75+
if (!state) {
76+
reportRestoreIssue('layout', 'Persisted tab layout failed validation.');
77+
}
78+
return state;
79+
} catch (error) {
80+
reportRestoreIssue('layout', `Failed to read persisted tab layout: ${errorMessage(error)}`);
7081
return null;
7182
}
7283
}
@@ -75,6 +86,26 @@ export class QoderianStorage {
7586
return this.adapter;
7687
}
7788

89+
/**
90+
* Obsidian may return null from loadData for corrupt JSON instead of
91+
* throwing; a non-empty raw data file therefore means unreadable content.
92+
*/
93+
private async reportUnreadablePluginData(): Promise<void> {
94+
const pluginId = this.plugin.manifest?.id ?? 'qoderian';
95+
const dataPath = `${this.plugin.app.vault.configDir}/plugins/${pluginId}/data.json`;
96+
try {
97+
const raw = await this.adapter.read(dataPath);
98+
if (raw.trim().length > 0) {
99+
reportRestoreIssue(
100+
'layout',
101+
`Plugin data file "${dataPath}" could not be read; loaded an empty layout instead.`,
102+
);
103+
}
104+
} catch {
105+
// Missing file: first run, nothing to report.
106+
}
107+
}
108+
78109
private async ensureDirectories(): Promise<void> {
79110
await this.adapter.ensureFolder(QODERIAN_STORAGE_PATH);
80111
await this.adapter.ensureFolder(SESSIONS_PATH);

src/app/storage/session-storage.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { reportRestoreIssue } from '../../core/diagnostics/restore-report';
12
import type { VaultFileAdapter } from '../../core/storage/vault-file-adapter';
23
import type { SessionMetadata } from '../../core/types';
34
import { SESSIONS_PATH } from './storage-paths';
@@ -21,7 +22,8 @@ export class SessionStorage {
2122
try {
2223
const content = await this.adapter.read(this.getMetadataPath(id));
2324
return JSON.parse(content) as SessionMetadata;
24-
} catch {
25+
} catch (error) {
26+
reportRestoreIssue('metadata', `Failed to read session metadata "${id}": ${errorMessage(error)}`);
2527
return null;
2628
}
2729
}
@@ -37,8 +39,9 @@ export class SessionStorage {
3739
try {
3840
const content = await this.adapter.read(filePath);
3941
metas.push(JSON.parse(content) as SessionMetadata);
40-
} catch {
41-
// Skip files that fail to load.
42+
} catch (error) {
43+
// Skip files that fail to load, but surface the skip.
44+
reportRestoreIssue('metadata', `Failed to read session metadata file "${filePath}": ${errorMessage(error)}`);
4245
}
4346
}
4447

@@ -49,8 +52,13 @@ export class SessionStorage {
4952
try {
5053
const files = await this.adapter.listFiles(SESSIONS_PATH);
5154
return files.filter((filePath) => filePath.endsWith('.meta.json'));
52-
} catch {
55+
} catch (error) {
56+
reportRestoreIssue('metadata', `Failed to list session metadata files: ${errorMessage(error)}`);
5357
return [];
5458
}
5559
}
5660
}
61+
62+
function errorMessage(error: unknown): string {
63+
return error instanceof Error ? error.message : String(error);
64+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* Startup restore diagnostics.
3+
*
4+
* The restore pipeline (tab layout read, per-tab rebuild, session metadata,
5+
* conversation history hydration) used to swallow failures silently, leaving
6+
* users with missing tabs or empty conversations and no explanation. Each
7+
* stage reports issues here; the chat view drains the collected issues once
8+
* restore finishes and surfaces a single aggregated notice.
9+
*/
10+
11+
export type RestoreStage = 'layout' | 'tab' | 'metadata' | 'history';
12+
13+
export interface RestoreIssue {
14+
stage: RestoreStage;
15+
detail: string;
16+
}
17+
18+
let activeIssues: RestoreIssue[] | null = null;
19+
20+
/** Opens the collection window (called once on plugin load). */
21+
export function beginRestoreReport(): void {
22+
activeIssues = [];
23+
}
24+
25+
/**
26+
* Records a restore issue. Always logged for debugging; only collected into
27+
* the user-facing report while the window is open.
28+
*/
29+
export function reportRestoreIssue(stage: RestoreStage, detail: string): void {
30+
console.error(`[qoderian-restore:${stage}] ${detail}`);
31+
activeIssues?.push({ stage, detail });
32+
}
33+
34+
/**
35+
* Closes the window (restore finished) and returns the collected issues.
36+
* Duplicates are dropped: some stages run twice during startup (e.g. the
37+
* tab layout is read by both loadSettings and the chat view), and counting
38+
* the same root cause twice would inflate the aggregated notice.
39+
*/
40+
export function finishRestoreReport(): RestoreIssue[] {
41+
const issues = activeIssues ?? [];
42+
activeIssues = null;
43+
const seen = new Set<string>();
44+
return issues.filter((issue) => {
45+
const key = `${issue.stage}:${issue.detail}`;
46+
if (seen.has(key)) {
47+
return false;
48+
}
49+
seen.add(key);
50+
return true;
51+
});
52+
}

src/features/chat/chat-view.ts

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import type { EventRef, WorkspaceLeaf } from 'obsidian';
22
import { ItemView, Notice, Scope, setIcon } from 'obsidian';
33

4+
import { finishRestoreReport } from '../../core/diagnostics/restore-report';
45
import { VIEW_TYPE_QODERIAN } from '../../core/types';
6+
import { t } from '../../i18n/i18n';
57
import type QoderianPlugin from '../../main';
68
import { fetchCreditsUsage } from '../../qoder/services/credits-usage';
79
import {
@@ -607,17 +609,24 @@ export class QoderianView extends ItemView {
607609
// ============================================
608610

609611
private async restoreOrCreateTabs(): Promise<void> {
610-
if (!this.tabManager) return;
611-
612-
// Try to restore from persisted state
613-
const persistedState = await this.plugin.storage.getTabManagerState();
614-
if (persistedState && persistedState.openTabs.length > 0) {
615-
await this.tabManager.restoreState(persistedState);
616-
return;
612+
try {
613+
if (!this.tabManager) return;
614+
615+
// Try to restore from persisted state
616+
const persistedState = await this.plugin.storage.getTabManagerState();
617+
if (persistedState && persistedState.openTabs.length > 0) {
618+
await this.tabManager.restoreState(persistedState);
619+
} else {
620+
// Fallback: create a new empty tab
621+
await this.tabManager.createTab();
622+
}
623+
} finally {
624+
// Drain startup restore diagnostics and surface them once, aggregated.
625+
const issues = finishRestoreReport();
626+
if (issues.length > 0) {
627+
new Notice(t('restore.failed', { count: issues.length }), 10000);
628+
}
617629
}
618-
619-
// Fallback: create a new empty tab
620-
await this.tabManager.createTab();
621630
}
622631

623632
/**

src/features/chat/tabs/tab-manager.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Notice } from 'obsidian';
22

3+
import { reportRestoreIssue } from '../../../core/diagnostics/restore-report';
34
import type { ChatRuntime } from '../../../core/runtime/chat-runtime';
45
import { t } from '../../../i18n/i18n';
56
import type QoderianPlugin from '../../../main';
@@ -537,8 +538,9 @@ export class TabManager implements TabManagerInterface {
537538
activate: false,
538539
...(typeof tabState.draftModel === 'string' ? { draftModel: tabState.draftModel } : {}),
539540
});
540-
} catch {
541-
// Continue restoring other tabs
541+
} catch (error) {
542+
// Continue restoring other tabs, but surface the skipped one.
543+
reportRestoreIssue('tab', `Failed to restore tab "${tabState.tabId}": ${errorMessage(error)}`);
542544
}
543545
}
544546
} finally {
@@ -557,8 +559,8 @@ export class TabManager implements TabManagerInterface {
557559
if (targetTabId) {
558560
try {
559561
await this.switchToTab(targetTabId);
560-
} catch {
561-
// Ignore switch errors
562+
} catch (error) {
563+
reportRestoreIssue('tab', `Failed to activate restored tab "${targetTabId}": ${errorMessage(error)}`);
562564
}
563565
}
564566

@@ -634,3 +636,7 @@ export class TabManager implements TabManagerInterface {
634636
this.activeTabId = null;
635637
}
636638
}
639+
640+
function errorMessage(error: unknown): string {
641+
return error instanceof Error ? error.message : String(error);
642+
}

src/i18n/locales/de.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@
2525
"send": "Nachricht senden",
2626
"stop": "Generierung stoppen"
2727
},
28+
"restore": {
29+
"failed": "Einige Tabs oder Unterhaltungen konnten nicht wiederhergestellt werden ({count} Problem(e)). Details in der Entwicklerkonsole."
30+
},
2831
"commands": {
2932
"openView": "Chat-Ansicht öffnen",
3033
"inlineEdit": "Inline-Bearbeitung",

src/i18n/locales/en.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@
2525
"send": "Send message",
2626
"stop": "Stop generation"
2727
},
28+
"restore": {
29+
"failed": "Some of your previous tabs or conversations could not be restored ({count} issue(s)). Details are in the developer console."
30+
},
2831
"commands": {
2932
"openView": "Open chat view",
3033
"inlineEdit": "Inline edit",

src/i18n/locales/es.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@
2525
"send": "Enviar mensaje",
2626
"stop": "Detener generación"
2727
},
28+
"restore": {
29+
"failed": "No se pudieron restaurar algunas pestañas o conversaciones ({count} problema(s)). Detalles en la consola de desarrollador."
30+
},
2831
"commands": {
2932
"openView": "Abrir vista de chat",
3033
"inlineEdit": "Edición en línea",

src/i18n/locales/fr.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@
2525
"send": "Envoyer le message",
2626
"stop": "Arrêter la génération"
2727
},
28+
"restore": {
29+
"failed": "Certains onglets ou conversations n'ont pas pu être restaurés ({count} problème(s)). Détails dans la console développeur."
30+
},
2831
"commands": {
2932
"openView": "Ouvrir la vue de discussion",
3033
"inlineEdit": "Édition en ligne",

0 commit comments

Comments
 (0)