Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ version with its date and start a fresh empty `[Unreleased]` above it.
collapse), the toolbar wraps instead of clipping, and the permission
mode and model dropdowns shrink to stay inside the sidebar, with
long model names ellipsized.
- Startup session restore no longer fails silently: when the tab
layout, an individual tab, session metadata, or conversation history
cannot be read, Qoderian now shows a single notice with the issue
count and logs per-stage details to the developer console.

## [1.0.4] - 2026-08-12

Expand Down
35 changes: 33 additions & 2 deletions src/app/storage/app-storage.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Plugin } from 'obsidian';
import { Notice } from 'obsidian';

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

function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

export class QoderianStorage {
readonly qoderianSettings: QoderianSettingsStorage;
readonly sessions: SessionStorage;
Expand Down Expand Up @@ -62,11 +67,17 @@ export class QoderianStorage {
try {
const data: unknown = await this.plugin.loadData();
if (!isRecord(data) || !data.tabManagerState) {
await this.reportUnreadablePluginData();
return null;
}

return this.validateTabManagerState(data.tabManagerState);
} catch {
const state = this.validateTabManagerState(data.tabManagerState);
if (!state) {
reportRestoreIssue('layout', 'Persisted tab layout failed validation.');
}
return state;
} catch (error) {
reportRestoreIssue('layout', `Failed to read persisted tab layout: ${errorMessage(error)}`);
return null;
}
}
Expand All @@ -75,6 +86,26 @@ export class QoderianStorage {
return this.adapter;
}

/**
* Obsidian may return null from loadData for corrupt JSON instead of
* throwing; a non-empty raw data file therefore means unreadable content.
*/
private async reportUnreadablePluginData(): Promise<void> {
const pluginId = this.plugin.manifest?.id ?? 'qoderian';
const dataPath = `${this.plugin.app.vault.configDir}/plugins/${pluginId}/data.json`;
try {
const raw = await this.adapter.read(dataPath);
if (raw.trim().length > 0) {
reportRestoreIssue(
'layout',
`Plugin data file "${dataPath}" could not be read; loaded an empty layout instead.`,
);
}
} catch {
// Missing file: first run, nothing to report.
}
}

private async ensureDirectories(): Promise<void> {
await this.adapter.ensureFolder(QODERIAN_STORAGE_PATH);
await this.adapter.ensureFolder(SESSIONS_PATH);
Expand Down
16 changes: 12 additions & 4 deletions src/app/storage/session-storage.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { reportRestoreIssue } from '../../core/diagnostics/restore-report';
import type { VaultFileAdapter } from '../../core/storage/vault-file-adapter';
import type { SessionMetadata } from '../../core/types';
import { SESSIONS_PATH } from './storage-paths';
Expand All @@ -21,7 +22,8 @@ export class SessionStorage {
try {
const content = await this.adapter.read(this.getMetadataPath(id));
return JSON.parse(content) as SessionMetadata;
} catch {
} catch (error) {
reportRestoreIssue('metadata', `Failed to read session metadata "${id}": ${errorMessage(error)}`);
return null;
}
}
Expand All @@ -37,8 +39,9 @@ export class SessionStorage {
try {
const content = await this.adapter.read(filePath);
metas.push(JSON.parse(content) as SessionMetadata);
} catch {
// Skip files that fail to load.
} catch (error) {
// Skip files that fail to load, but surface the skip.
reportRestoreIssue('metadata', `Failed to read session metadata file "${filePath}": ${errorMessage(error)}`);
}
}

Expand All @@ -49,8 +52,13 @@ export class SessionStorage {
try {
const files = await this.adapter.listFiles(SESSIONS_PATH);
return files.filter((filePath) => filePath.endsWith('.meta.json'));
} catch {
} catch (error) {
reportRestoreIssue('metadata', `Failed to list session metadata files: ${errorMessage(error)}`);
return [];
}
}
}

function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
52 changes: 52 additions & 0 deletions src/core/diagnostics/restore-report.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* Startup restore diagnostics.
*
* The restore pipeline (tab layout read, per-tab rebuild, session metadata,
* conversation history hydration) used to swallow failures silently, leaving
* users with missing tabs or empty conversations and no explanation. Each
* stage reports issues here; the chat view drains the collected issues once
* restore finishes and surfaces a single aggregated notice.
*/

export type RestoreStage = 'layout' | 'tab' | 'metadata' | 'history';

export interface RestoreIssue {
stage: RestoreStage;
detail: string;
}

let activeIssues: RestoreIssue[] | null = null;

/** Opens the collection window (called once on plugin load). */
export function beginRestoreReport(): void {
activeIssues = [];
}

/**
* Records a restore issue. Always logged for debugging; only collected into
* the user-facing report while the window is open.
*/
export function reportRestoreIssue(stage: RestoreStage, detail: string): void {
console.error(`[qoderian-restore:${stage}] ${detail}`);
activeIssues?.push({ stage, detail });
}

/**
* Closes the window (restore finished) and returns the collected issues.
* Duplicates are dropped: some stages run twice during startup (e.g. the
* tab layout is read by both loadSettings and the chat view), and counting
* the same root cause twice would inflate the aggregated notice.
*/
export function finishRestoreReport(): RestoreIssue[] {
const issues = activeIssues ?? [];
activeIssues = null;
const seen = new Set<string>();
return issues.filter((issue) => {
const key = `${issue.stage}:${issue.detail}`;
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
29 changes: 19 additions & 10 deletions src/features/chat/chat-view.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { EventRef, WorkspaceLeaf } from 'obsidian';
import { ItemView, Notice, Scope, setIcon } from 'obsidian';

import { finishRestoreReport } from '../../core/diagnostics/restore-report';
import { VIEW_TYPE_QODERIAN } from '../../core/types';
import { t } from '../../i18n/i18n';
import type QoderianPlugin from '../../main';
import { fetchCreditsUsage } from '../../qoder/services/credits-usage';
import {
Expand Down Expand Up @@ -607,17 +609,24 @@ export class QoderianView extends ItemView {
// ============================================

private async restoreOrCreateTabs(): Promise<void> {
if (!this.tabManager) return;

// Try to restore from persisted state
const persistedState = await this.plugin.storage.getTabManagerState();
if (persistedState && persistedState.openTabs.length > 0) {
await this.tabManager.restoreState(persistedState);
return;
try {
if (!this.tabManager) return;

// Try to restore from persisted state
const persistedState = await this.plugin.storage.getTabManagerState();
if (persistedState && persistedState.openTabs.length > 0) {
await this.tabManager.restoreState(persistedState);
} else {
// Fallback: create a new empty tab
await this.tabManager.createTab();
}
} finally {
// Drain startup restore diagnostics and surface them once, aggregated.
const issues = finishRestoreReport();
if (issues.length > 0) {
new Notice(t('restore.failed', { count: issues.length }), 10000);
}
}

// Fallback: create a new empty tab
await this.tabManager.createTab();
}

/**
Expand Down
14 changes: 10 additions & 4 deletions src/features/chat/tabs/tab-manager.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Notice } from 'obsidian';

import { reportRestoreIssue } from '../../../core/diagnostics/restore-report';
import type { ChatRuntime } from '../../../core/runtime/chat-runtime';
import { t } from '../../../i18n/i18n';
import type QoderianPlugin from '../../../main';
Expand Down Expand Up @@ -537,8 +538,9 @@ export class TabManager implements TabManagerInterface {
activate: false,
...(typeof tabState.draftModel === 'string' ? { draftModel: tabState.draftModel } : {}),
});
} catch {
// Continue restoring other tabs
} catch (error) {
// Continue restoring other tabs, but surface the skipped one.
reportRestoreIssue('tab', `Failed to restore tab "${tabState.tabId}": ${errorMessage(error)}`);
}
}
} finally {
Expand All @@ -557,8 +559,8 @@ export class TabManager implements TabManagerInterface {
if (targetTabId) {
try {
await this.switchToTab(targetTabId);
} catch {
// Ignore switch errors
} catch (error) {
reportRestoreIssue('tab', `Failed to activate restored tab "${targetTabId}": ${errorMessage(error)}`);
}
}

Expand Down Expand Up @@ -634,3 +636,7 @@ export class TabManager implements TabManagerInterface {
this.activeTabId = null;
}
}

function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
3 changes: 3 additions & 0 deletions src/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
"send": "Nachricht senden",
"stop": "Generierung stoppen"
},
"restore": {
"failed": "Einige Tabs oder Unterhaltungen konnten nicht wiederhergestellt werden ({count} Problem(e)). Details in der Entwicklerkonsole."
},
"commands": {
"openView": "Chat-Ansicht öffnen",
"inlineEdit": "Inline-Bearbeitung",
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
"send": "Send message",
"stop": "Stop generation"
},
"restore": {
"failed": "Some of your previous tabs or conversations could not be restored ({count} issue(s)). Details are in the developer console."
},
"commands": {
"openView": "Open chat view",
"inlineEdit": "Inline edit",
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
"send": "Enviar mensaje",
"stop": "Detener generación"
},
"restore": {
"failed": "No se pudieron restaurar algunas pestañas o conversaciones ({count} problema(s)). Detalles en la consola de desarrollador."
},
"commands": {
"openView": "Abrir vista de chat",
"inlineEdit": "Edición en línea",
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
"send": "Envoyer le message",
"stop": "Arrêter la génération"
},
"restore": {
"failed": "Certains onglets ou conversations n'ont pas pu être restaurés ({count} problème(s)). Détails dans la console développeur."
},
"commands": {
"openView": "Ouvrir la vue de discussion",
"inlineEdit": "Édition en ligne",
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
"send": "メッセージを送信",
"stop": "生成を停止"
},
"restore": {
"failed": "一部のタブまたは会話を復元できませんでした({count} 件の問題)。詳細は開発者コンソールを確認してください。"
},
"commands": {
"openView": "チャットビューを開く",
"inlineEdit": "インライン編集",
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/locales/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
"send": "메시지 보내기",
"stop": "생성 중지"
},
"restore": {
"failed": "이전 탭 또는 대화를 완전히 복원하지 못했습니다(문제 {count}건). 자세한 내용은 개발자 콘솔을 확인하세요."
},
"commands": {
"openView": "채팅 뷰 열기",
"inlineEdit": "인라인 편집",
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/locales/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
"send": "Enviar mensagem",
"stop": "Parar geração"
},
"restore": {
"failed": "Algumas abas ou conversas não puderam ser restauradas ({count} problema(s)). Detalhes no console do desenvolvedor."
},
"commands": {
"openView": "Abrir visualização de chat",
"inlineEdit": "Edição inline",
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
"send": "Отправить сообщение",
"stop": "Остановить генерацию"
},
"restore": {
"failed": "Не удалось восстановить часть вкладок или бесед (проблем: {count}). Подробности — в консоли разработчика."
},
"commands": {
"openView": "Открыть представление чата",
"inlineEdit": "Встроенное редактирование",
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
"send": "发送消息",
"stop": "停止生成"
},
"restore": {
"failed": "部分标签或会话未能恢复({count} 个问题)。详情已输出到开发者控制台。"
},
"commands": {
"openView": "打开聊天视图",
"inlineEdit": "内联编辑",
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/locales/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
"send": "傳送訊息",
"stop": "停止生成"
},
"restore": {
"failed": "部分分頁或工作階段未能恢復({count} 個問題)。詳情已輸出到開發者主控台。"
},
"commands": {
"openView": "開啟聊天視圖",
"inlineEdit": "行內編輯",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export type TranslationKey =
// Composer - send/stop action button
| 'composer.send'
| 'composer.stop'
| 'restore.failed'

// Chat - Rewind
| 'chat.rewind.confirmMessage'
Expand Down
3 changes: 3 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { Editor, WorkspaceLeaf } from 'obsidian';
import { addIcon, MarkdownView, Notice, Plugin } from 'obsidian';

import { QoderianStorage } from './app/storage/app-storage';
import { beginRestoreReport } from './core/diagnostics/restore-report';
import { buildCursorContext } from './core/editor/editor-context';
import { getVaultPath } from './core/fs/path';
import type {
Expand Down Expand Up @@ -398,6 +399,8 @@ export default class QoderianPlugin extends Plugin {
}

async loadSettings() {
// Open the restore diagnostics window before any persisted state is read.
beginRestoreReport();
this.storage = new QoderianStorage(this);
const { qoderian } = await this.storage.initialize();
this.lastKnownTabManagerState = await this.storage.getTabManagerState();
Expand Down
Loading
Loading