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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ version with its date and start a fresh empty `[Unreleased]` above it.
- The agent now learns which external context directories are attached: the
message you send right after adding or removing one carries the current
list, so the agent can use those folders without you referencing every file.
- An update icon appears in the view header when GitHub has a newer stable
release; click it to open Qoderian's plugin page, where Obsidian's update
button lives.

## [1.0.11] - 2026-09-16

Expand Down
29 changes: 29 additions & 0 deletions src/features/chat/chat-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { setButtonTooltip } from '../../shared/dom/tooltip';
import { createIconSvg, QODER_ICON,QODERIAN_ICON_ID } from '../../shared/icons';
import { openFeedbackModal } from '../feedback/ui/feedback-modal';
import { QoderianSettingsModal } from '../settings/settings-modal';
import { openQoderianUpdatePage } from '../update/plugin-update-checker';
import type { HistoryConversationStatus } from './controllers/conversation-controller';
import {
sendTabInputMessageFromExplicitEnterShortcut,
Expand Down Expand Up @@ -46,6 +47,7 @@ export class QoderianView extends ItemView {
// DOM Elements
private viewContainerEl: HTMLElement | null = null;
private logoEl: HTMLElement | null = null;
private updateBadgeEl: HTMLElement | null = null;
private newTabButtonEl: HTMLElement | null = null;
private newConversationButtonEl: HTMLElement | null = null;
private historyButtonEl: HTMLElement | null = null;
Expand Down Expand Up @@ -227,6 +229,13 @@ export class QoderianView extends ItemView {
titleEl.createEl('h4', { text: 'Qoder', cls: 'qoderian-title-text' });

const headerActions = header.createDiv({ cls: 'qoderian-header-actions' });
this.updateBadgeEl = headerActions.createEl('button', {
cls: 'qoderian-header-btn qoderian-update-badge qoderian-hidden',
attr: { type: 'button' },
});
setIcon(this.updateBadgeEl, 'download');
void this.refreshUpdateBadge();

const feedbackBtn = headerActions.createDiv({ cls: 'qoderian-header-btn' });
setIcon(feedbackBtn, 'message-circle-question');
setButtonTooltip(feedbackBtn, t('commands.submitFeedback'));
Expand Down Expand Up @@ -317,6 +326,20 @@ export class QoderianView extends ItemView {
new QoderianSettingsModal(this.plugin).open();
}

/** Shows the badge once GitHub reports a newer stable release. */
private async refreshUpdateBadge(): Promise<void> {
const badge = this.updateBadgeEl;
if (!badge) return;

const update = await this.plugin.getAvailableUpdate();
if (!update || this.updateBadgeEl !== badge) return;

badge.dataset.version = update.version;
setButtonTooltip(badge, t('updates.available', { version: update.version }));
badge.removeClass('qoderian-hidden');
badge.addEventListener('click', () => openQoderianUpdatePage(this.plugin.manifest.id));
}

private buildInputFooter(): void {
if (!this.viewContainerEl) return;

Expand Down Expand Up @@ -387,6 +410,12 @@ export class QoderianView extends ItemView {
setButtonTooltip(this.newConversationButtonEl, t('nav.newConversation'));
}
if (this.historyButtonEl) setButtonTooltip(this.historyButtonEl, t('nav.chatHistory'));
if (this.updateBadgeEl?.dataset.version) {
setButtonTooltip(
this.updateBadgeEl,
t('updates.available', { version: this.updateBadgeEl.dataset.version }),
);
}
this.creditsUsageButton?.refreshLocale();
for (const tab of this.tabManager?.getAllTabs() ?? []) {
tab.ui.composerResize?.refreshLocale();
Expand Down
69 changes: 69 additions & 0 deletions src/features/update/plugin-update-checker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { requestUrl } from 'obsidian';

import { openExternalBrowserUrl } from '../../qoder/services/qoder-login-service';

const LATEST_RELEASE_API_URL = 'https://api.github.com/repos/QoderAI/Qoderian/releases/latest';

export interface QoderianUpdate {
version: string;
url: string;
}

interface GitHubReleaseResponse {
html_url?: unknown;
tag_name?: unknown;
}

function parseVersion(value: string): number[] | null {
const match = value.trim().replace(/^v/i, '').match(/^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/);
if (!match) return null;
return match.slice(1).map(Number);
}

/** Semver comparison for the three-part release tags used by Qoderian. */
export function isNewerQoderianVersion(currentVersion: string, latestVersion: string): boolean {
const current = parseVersion(currentVersion);
const latest = parseVersion(latestVersion);
if (!current || !latest) return false;

for (let index = 0; index < latest.length; index += 1) {
if (latest[index] !== current[index]) return latest[index] > current[index];
}
return false;
}

/** Obsidian's own URI for a plugin's entry in the community-plugins list. */
export function qoderianPluginPageUri(pluginId: string): string {
return `obsidian://show-plugin?id=${encodeURIComponent(pluginId)}`;
}

/**
* Opens Obsidian's plugin page, where the update button lives. Obsidian
* handles its own URI scheme, so this stays inside the app.
*/
export function openQoderianUpdatePage(pluginId: string): void {
openExternalBrowserUrl(qoderianPluginPageUri(pluginId));
}

/**
* Checks the latest stable GitHub release. Network and malformed-response
* failures are intentionally silent so update discovery never blocks chat.
*/
export async function fetchAvailableQoderianUpdate(
currentVersion: string,
): Promise<QoderianUpdate | null> {
try {
const response = await requestUrl({
url: LATEST_RELEASE_API_URL,
headers: { Accept: 'application/vnd.github+json' },
});
const release = response.json as GitHubReleaseResponse;
if (typeof release.tag_name !== 'string' || typeof release.html_url !== 'string') return null;

const version = release.tag_name.replace(/^v/i, '');
if (!isNewerQoderianVersion(currentVersion, version)) return null;
return { version, url: release.html_url };
} catch {
return null;
}
}
3 changes: 3 additions & 0 deletions src/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -400,5 +400,8 @@
"thinkingEffort": "Thinking Effort",
"default": "Standard",
"backToList": "Zurück zur Modellliste"
},
"updates": {
"available": "Update {version}"
}
}
3 changes: 3 additions & 0 deletions src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -400,5 +400,8 @@
"thinkingEffort": "Thinking Effort",
"default": "Default",
"backToList": "Back to model list"
},
"updates": {
"available": "Update {version}"
}
}
3 changes: 3 additions & 0 deletions src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -400,5 +400,8 @@
"thinkingEffort": "Thinking Effort",
"default": "Predeterminado",
"backToList": "Volver a la lista de modelos"
},
"updates": {
"available": "Actualizar {version}"
}
}
3 changes: 3 additions & 0 deletions src/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -400,5 +400,8 @@
"thinkingEffort": "Thinking Effort",
"default": "Par défaut",
"backToList": "Retour à la liste des modèles"
},
"updates": {
"available": "Mise à jour {version}"
}
}
3 changes: 3 additions & 0 deletions src/i18n/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -400,5 +400,8 @@
"thinkingEffort": "Thinking Effort",
"default": "デフォルト",
"backToList": "モデルリストに戻る"
},
"updates": {
"available": "更新 {version}"
}
}
3 changes: 3 additions & 0 deletions src/i18n/locales/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -400,5 +400,8 @@
"thinkingEffort": "Thinking Effort",
"default": "기본",
"backToList": "모델 목록으로 돌아가기"
},
"updates": {
"available": "업데이트 {version}"
}
}
3 changes: 3 additions & 0 deletions src/i18n/locales/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -400,5 +400,8 @@
"thinkingEffort": "Thinking Effort",
"default": "Padrão",
"backToList": "Voltar à lista de modelos"
},
"updates": {
"available": "Atualizar {version}"
}
}
3 changes: 3 additions & 0 deletions src/i18n/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -400,5 +400,8 @@
"thinkingEffort": "Thinking Effort",
"default": "По умолчанию",
"backToList": "Назад к списку моделей"
},
"updates": {
"available": "Обновить {version}"
}
}
3 changes: 3 additions & 0 deletions src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -400,5 +400,8 @@
"thinkingEffort": "Thinking Effort",
"default": "默认",
"backToList": "返回模型列表"
},
"updates": {
"available": "更新 {version}"
}
}
3 changes: 3 additions & 0 deletions src/i18n/locales/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -400,5 +400,8 @@
"thinkingEffort": "Thinking Effort",
"default": "預設",
"backToList": "返回模型列表"
},
"updates": {
"available": "更新 {version}"
}
}
5 changes: 4 additions & 1 deletion src/i18n/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,4 +344,7 @@ export type TranslationKey =

// Settings - Language
| 'settings.language.name'
| 'settings.language.desc';
| 'settings.language.desc'

// Plugin updates
| 'updates.available'
11 changes: 11 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ import { QoderianView } from './features/chat/chat-view';
import { openFeedbackModal } from './features/feedback/ui/feedback-modal';
import { type InlineEditContext, InlineEditModal } from './features/inline-edit/ui/modal';
import { QoderianSettingTab } from './features/settings/settings-tab';
import {
fetchAvailableQoderianUpdate,
type QoderianUpdate,
} from './features/update/plugin-update-checker';
import { setLocale, t } from './i18n/i18n';
import type { Locale } from './i18n/types';
import { getActiveQoderCliEdition, setActiveQoderCliEdition } from './qoder/config/cli-edition';
Expand Down Expand Up @@ -51,6 +55,13 @@ export default class QoderianPlugin extends Plugin {
qoderServices!: QoderServices;
private conversations: Conversation[] = [];
private lastKnownTabManagerState: AppTabManagerState | null = null;
private updateCheckPromise: Promise<QoderianUpdate | null> | null = null;

/** Checks once per plugin session so reopening the view does not hit GitHub repeatedly. */
getAvailableUpdate(): Promise<QoderianUpdate | null> {
this.updateCheckPromise ??= fetchAvailableQoderianUpdate(this.manifest.version);
return this.updateCheckPromise;
}

async onload() {
await this.loadSettings();
Expand Down
22 changes: 22 additions & 0 deletions src/style/components/header.css
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,28 @@
color: var(--qoderian-brand);
}

/* Update badge: an icon button in the header actions, shown only while a newer
release exists. The doubled class keeps it clear of the theme's default
button chrome without !important. */
.qoderian-header-actions .qoderian-update-badge.qoderian-update-badge {
padding: 0;
height: auto;
min-height: 0;
border: none;
border-radius: 3px;
background: transparent;
box-shadow: none;
color: var(--qoderian-brand);
}

.qoderian-header-actions .qoderian-update-badge.qoderian-update-badge:hover,
.qoderian-header-actions .qoderian-update-badge.qoderian-update-badge:focus-visible {
color: var(--qoderian-brand);
background: rgba(var(--qoderian-brand-rgb), 0.14);
box-shadow: none;
outline: none;
}

.qoderian-header-actions {
display: flex;
align-items: center;
Expand Down
2 changes: 2 additions & 0 deletions tests/__mocks__/obsidian.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,3 +465,5 @@ export class TFolder {
this.name = path.split('/').pop() || '';
}
}

export const requestUrl = jest.fn();
51 changes: 51 additions & 0 deletions tests/unit/features/update/plugin-update-checker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { requestUrl } from 'obsidian';

import {
fetchAvailableQoderianUpdate,
isNewerQoderianVersion,
qoderianPluginPageUri,
} from '@/features/update/plugin-update-checker';

const mockRequestUrl = requestUrl as jest.Mock;

describe('plugin update checker', () => {
beforeEach(() => {
mockRequestUrl.mockReset();
});

it.each([
['1.0.7', '1.0.8', true],
['1.0.7', '1.1.0', true],
['1.9.9', '2.0.0', true],
['1.0.7', '1.0.7', false],
['1.0.8', '1.0.7', false],
['invalid', '1.0.8', false],
])('compares %s with %s', (current, latest, expected) => {
expect(isNewerQoderianVersion(current, latest)).toBe(expected);
});

it('returns the newer stable GitHub release', async () => {
mockRequestUrl.mockResolvedValue({
json: {
tag_name: 'v1.1.0',
html_url: 'https://github.com/QoderAI/Qoderian/releases/tag/v1.1.0',
},
});

await expect(fetchAvailableQoderianUpdate('1.0.7')).resolves.toEqual({
version: '1.1.0',
url: 'https://github.com/QoderAI/Qoderian/releases/tag/v1.1.0',
});
});

it('builds the plugin page URI Obsidian itself handles', () => {
expect(qoderianPluginPageUri('qoderian')).toBe('obsidian://show-plugin?id=qoderian');
expect(qoderianPluginPageUri('weird id/name')).toBe('obsidian://show-plugin?id=weird%20id%2Fname');
});

it('fails silently when offline', async () => {
mockRequestUrl.mockRejectedValue(new Error('offline'));

await expect(fetchAvailableQoderianUpdate('1.0.7')).resolves.toBeNull();
});
});
Loading