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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ version with its date and start a fresh empty `[Unreleased]` above it.

### Added

- In-app sign-in when the Qoder CLI is not authenticated: the model selector
now shows a sign-in panel that runs the CLI device flow inside Obsidian —
the authorization page opens once per attempt, with open-link, copy-link and
cancel controls, localized status and error messages in all ten locales, and
an automatic model-catalog refresh once sign-in succeeds — so signing in no
longer requires a terminal.

- Message timestamps, matching New Qoder: each user bubble shows when it was
sent, and each assistant reply shows one time under the whole response next
to the completion line. Both reveal on hover so idle conversations stay
Expand Down
1 change: 1 addition & 0 deletions src/features/chat/tabs/tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,7 @@ function initializeInputToolbar(
retryRuntimeCatalog: async () => {
await plugin.qoderServices.agentCatalog.refresh();
},
loginService: plugin.qoderServices.loginService,
onModelChange: async (model: string) => {
// Blank tabs keep their model choice until the first message binds them.
if (tab.lifecycleState === 'blank') {
Expand Down
122 changes: 117 additions & 5 deletions src/features/chat/ui/toolbar/toolbar-selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import type { TranslationKey } from '../../../../i18n/types';
import { getActiveQoderCliEdition, getQoderCliLoginCommand } from '../../../../qoder/config/cli-edition';
import { getQoderModelOverride } from '../../../../qoder/config/settings';
import type { QoderModelConfig } from '../../../../qoder/models/qoder-model-config';
import type {
QoderLoginController,
QoderLoginFailure,
} from '../../../../qoder/services/qoder-login-service';
import {
CHECK_ICON,
CHEVRON_LEFT_ICON,
Expand Down Expand Up @@ -43,6 +47,7 @@ export interface ToolbarCallbacks {
getRuntimeStatus?: () => QoderRuntimeStatus;
retryRuntimeCatalog?: () => Promise<void>;
subscribeRuntimeStatus?: (listener: (status: QoderRuntimeStatus) => void) => () => void;
loginService?: QoderLoginController;
}

const DEFAULT_RUNTIME_STATUS: QoderRuntimeStatus = {
Expand All @@ -68,6 +73,15 @@ function canUseCachedModels(status: QoderRuntimeStatus): boolean {
return status.kind === 'checking' || status.kind === 'offline' || status.kind === 'failed';
}

function getSignInFailureMessage(failure: QoderLoginFailure): string {
switch (failure.kind) {
case 'cliMissing': return t('chat.signIn.errorCliMissing');
case 'nodeMissing': return t('chat.signIn.errorNodeMissing');
case 'spawn': return t('chat.signIn.errorStartFailed');
case 'process': return t('chat.signIn.errorProcessFailed');
}
}

export class ModelSelector {
private readonly container: HTMLElement;
private buttonEl: HTMLElement | null = null;
Expand All @@ -77,6 +91,7 @@ export class ModelSelector {
private popover: ClickPopover | null = null;
private editingModel: string | null = null;
private unsubscribeRuntimeStatus: (() => void) | null = null;
private unsubscribeLoginState: (() => void) | null = null;

constructor(parentEl: HTMLElement, private readonly callbacks: ToolbarCallbacks) {
this.container = parentEl.createDiv({ cls: 'qoderian-model-selector' });
Expand All @@ -85,13 +100,18 @@ export class ModelSelector {
this.updateDisplay();
this.renderOptions();
}) ?? null;
this.unsubscribeLoginState = callbacks.loginService?.subscribe(() => {
this.renderOptions();
}) ?? null;
}

destroy(): void {
this.popover?.destroy();
this.popover = null;
this.unsubscribeRuntimeStatus?.();
this.unsubscribeRuntimeStatus = null;
this.unsubscribeLoginState?.();
this.unsubscribeLoginState = null;
}

private getAvailableModels() {
Expand Down Expand Up @@ -177,15 +197,19 @@ export class ModelSelector {
statusEl.createDiv({ cls: 'qoderian-model-runtime-title', text: getRuntimeStatusLabel(status) });
statusEl.createDiv({ cls: 'qoderian-model-runtime-message', text: status.message });
if (status.kind === 'authRequired') {
statusEl.createEl('code', {
cls: 'qoderian-model-runtime-command',
text: getQoderCliLoginCommand(getActiveQoderCliEdition()),
});
this.renderSignInFlow(statusEl);
}
if (status.details) {
statusEl.setAttribute('title', status.details);
}
if (this.callbacks.retryRuntimeCatalog) {
const loginRunning = status.kind === 'authRequired'
&& (this.callbacks.loginService?.isRunning() ?? false);
// When the in-app sign-in flow is available it owns authRequired
// recovery (a successful sign-in refreshes the catalog), so the generic
// Retry button would only add a redundant, misaligned second action.
const signInOwnsAuth = status.kind === 'authRequired'
&& this.callbacks.loginService !== undefined;
if (this.callbacks.retryRuntimeCatalog && !loginRunning && !signInOwnsAuth) {
const retryButton = statusEl.createEl('button', {
cls: 'qoderian-model-runtime-retry',
text: status.kind === 'checking' ? 'Checking…' : 'Retry',
Expand Down Expand Up @@ -295,6 +319,94 @@ export class ModelSelector {
this.updateDropdownPlacement();
}

private renderSignInFlow(statusEl: HTMLElement): void {
const loginService = this.callbacks.loginService;
if (!loginService) {
statusEl.createEl('code', {
cls: 'qoderian-model-runtime-command',
text: getQoderCliLoginCommand(getActiveQoderCliEdition()),
});
return;
}

const state = loginService.getState();

if (state.phase === 'waiting') {
statusEl.createDiv({
cls: 'qoderian-signin-waiting',
text: t('chat.signIn.waiting'),
});
} else if (state.phase === 'succeeded') {
statusEl.createDiv({
cls: 'qoderian-signin-waiting',
text: t('chat.signIn.verifying'),
});
return;
} else if (state.phase === 'failed' && state.failure) {
const errorEl = statusEl.createDiv({
cls: 'qoderian-signin-error',
text: getSignInFailureMessage(state.failure),
});
if (state.failure.details) {
errorEl.setAttribute('title', state.failure.details);
}
}

const actionsEl = statusEl.createDiv({ cls: 'qoderian-signin-actions' });

if (state.phase === 'waiting') {
if (state.authUrl) {
const openButton = actionsEl.createEl('button', {
cls: 'qoderian-signin-open mod-cta',
text: t('chat.signIn.openLink'),
});
openButton.addEventListener('click', (event) => {
event.stopPropagation();
loginService.openAuthUrl();
});
const copyButton = actionsEl.createEl('button', {
cls: 'qoderian-signin-copy',
text: t('chat.signIn.copyLink'),
});
copyButton.addEventListener('click', (event) => {
event.stopPropagation();
void this.copyAuthUrl(state.authUrl as string);
});
}
const cancelButton = actionsEl.createEl('button', {
cls: 'qoderian-signin-cancel',
text: t('common.cancel'),
});
cancelButton.addEventListener('click', (event) => {
event.stopPropagation();
loginService.cancel();
});
return;
}

const signInButton = actionsEl.createEl('button', {
cls: 'qoderian-signin-button mod-cta',
text: state.phase === 'failed' ? t('chat.signIn.retry') : t('chat.signIn.button'),
});
if (state.phase === 'starting') {
signInButton.setAttribute('disabled', 'true');
signInButton.setText(t('chat.signIn.starting'));
}
signInButton.addEventListener('click', (event) => {
event.stopPropagation();
loginService.start();
});
}

private async copyAuthUrl(authUrl: string): Promise<void> {
try {
await navigator.clipboard.writeText(authUrl);
new Notice(t('chat.signIn.copied'));
} catch {
new Notice(t('chat.signIn.copyFailed'));
}
}

/**
* Places the dropdown against the real viewport: stay flush with the
* trigger (inline-start) and flip only when the panel would overflow,
Expand Down
15 changes: 15 additions & 0 deletions src/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,21 @@
"running": "Wird ausgeführt...",
"copyFailed": "Kopieren in die Zwischenablage fehlgeschlagen"
},
"signIn": {
"button": "Anmelden",
"retry": "Erneut anmelden",
"starting": "Anmeldung wird gestartet…",
"waiting": "Warten auf Browser-Autorisierung…",
"verifying": "Angemeldet. Wird überprüft…",
"openLink": "Link öffnen",
"copyLink": "Link kopieren",
"copied": "Link kopiert",
"copyFailed": "Link konnte nicht kopiert werden",
"errorCliMissing": "Qoder CLI wurde nicht gefunden. Installieren Sie es oder legen Sie den Pfad in den Einstellungen fest.",
"errorNodeMissing": "Qoder CLI erfordert Node.js, aber Node.js wurde nicht gefunden.",
"errorStartFailed": "Der Anmeldevorgang konnte nicht gestartet werden.",
"errorProcessFailed": "Anmeldung fehlgeschlagen. Überprüfen Sie die Details und versuchen Sie es erneut."
},
"view": {
"openQoderian": "Qoderian öffnen",
"moveBlockedStreaming": "Warte, bis die aktuelle Antwort abgeschlossen ist, bevor du die Qoderian-Ansicht verschiebst.",
Expand Down
15 changes: 15 additions & 0 deletions src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,21 @@
"running": "Running...",
"copyFailed": "Failed to copy to clipboard"
},
"signIn": {
"button": "Sign in",
"retry": "Sign in again",
"starting": "Starting sign-in…",
"waiting": "Waiting for browser authorization…",
"verifying": "Signed in. Checking status…",
"openLink": "Open link",
"copyLink": "Copy link",
"copied": "Link copied",
"copyFailed": "Could not copy the link",
"errorCliMissing": "Qoder CLI was not found. Install it or set its path in settings.",
"errorNodeMissing": "Qoder CLI requires Node.js, but Node.js was not found.",
"errorStartFailed": "Could not start the sign-in process.",
"errorProcessFailed": "Sign-in failed. Check the details and try again."
},
"view": {
"openQoderian": "Open Qoderian",
"moveBlockedStreaming": "Wait for the current response to finish before moving the Qoderian view.",
Expand Down
15 changes: 15 additions & 0 deletions src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,21 @@
"running": "Ejecutando...",
"copyFailed": "No se pudo copiar al portapapeles"
},
"signIn": {
"button": "Iniciar sesión",
"retry": "Iniciar sesión de nuevo",
"starting": "Iniciando sesión…",
"waiting": "Esperando autorización del navegador…",
"verifying": "Sesión iniciada. Verificando…",
"openLink": "Abrir enlace",
"copyLink": "Copiar enlace",
"copied": "Enlace copiado",
"copyFailed": "No se pudo copiar el enlace",
"errorCliMissing": "No se encontró Qoder CLI. Instálalo o configura su ruta en los ajustes.",
"errorNodeMissing": "Qoder CLI requiere Node.js, pero no se encontró.",
"errorStartFailed": "No se pudo iniciar el proceso de inicio de sesión.",
"errorProcessFailed": "Error al iniciar sesión. Revisa los detalles e inténtalo de nuevo."
},
"view": {
"openQoderian": "Abrir Qoderian",
"moveBlockedStreaming": "Espera a que termine la respuesta actual antes de mover la vista de Qoderian.",
Expand Down
15 changes: 15 additions & 0 deletions src/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,21 @@
"running": "Exécution...",
"copyFailed": "Échec de la copie dans le presse-papiers"
},
"signIn": {
"button": "Se connecter",
"retry": "Se connecter à nouveau",
"starting": "Démarrage de la connexion…",
"waiting": "En attente d'autorisation du navigateur…",
"verifying": "Connecté. Vérification…",
"openLink": "Ouvrir le lien",
"copyLink": "Copier le lien",
"copied": "Lien copié",
"copyFailed": "Impossible de copier le lien",
"errorCliMissing": "Qoder CLI introuvable. Installez-le ou configurez son chemin dans les paramètres.",
"errorNodeMissing": "Qoder CLI nécessite Node.js, mais Node.js est introuvable.",
"errorStartFailed": "Impossible de démarrer le processus de connexion.",
"errorProcessFailed": "Échec de la connexion. Vérifiez les détails et réessayez."
},
"view": {
"openQoderian": "Ouvrir Qoderian",
"moveBlockedStreaming": "Attendez la fin de la réponse actuelle avant de déplacer la vue Qoderian.",
Expand Down
15 changes: 15 additions & 0 deletions src/i18n/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,21 @@
"running": "実行中...",
"copyFailed": "クリップボードへのコピーに失敗しました"
},
"signIn": {
"button": "サインイン",
"retry": "もう一度サインイン",
"starting": "サインインを開始しています…",
"waiting": "ブラウザでの認証を待っています…",
"verifying": "サインインしました。確認中…",
"openLink": "リンクを開く",
"copyLink": "リンクをコピー",
"copied": "リンクをコピーしました",
"copyFailed": "リンクをコピーできませんでした",
"errorCliMissing": "Qoder CLI が見つかりません。インストールするか、設定でパスを指定してください。",
"errorNodeMissing": "Qoder CLI には Node.js が必要ですが、見つかりませんでした。",
"errorStartFailed": "サインイン プロセスを開始できませんでした。",
"errorProcessFailed": "サインインに失敗しました。詳細を確認して再試行してください。"
},
"view": {
"openQoderian": "Qoderian を開く",
"moveBlockedStreaming": "現在の応答が完了するまで Qoderian ビューを移動しないでください。",
Expand Down
15 changes: 15 additions & 0 deletions src/i18n/locales/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,21 @@
"running": "실행 중...",
"copyFailed": "클립보드에 복사하지 못했습니다"
},
"signIn": {
"button": "로그인",
"retry": "다시 로그인",
"starting": "로그인 시작 중…",
"waiting": "브라우저 승인 대기 중…",
"verifying": "로그인됨. 확인 중…",
"openLink": "링크 열기",
"copyLink": "링크 복사",
"copied": "링크가 복사되었습니다",
"copyFailed": "링크를 복사할 수 없습니다",
"errorCliMissing": "Qoder CLI를 찾을 수 없습니다. 설치하거나 설정에서 경로를 지정하세요.",
"errorNodeMissing": "Qoder CLI에는 Node.js가 필요하지만 찾을 수 없습니다.",
"errorStartFailed": "로그인 프로세스를 시작할 수 없습니다.",
"errorProcessFailed": "로그인에 실패했습니다. 세부 정보를 확인하고 다시 시도하세요."
},
"view": {
"openQoderian": "Qoderian 열기",
"moveBlockedStreaming": "현재 응답이 완료될 때까지 기다린 후 Qoderian 뷰를 이동하세요.",
Expand Down
15 changes: 15 additions & 0 deletions src/i18n/locales/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,21 @@
"running": "Executando...",
"copyFailed": "Falha ao copiar para a área de transferência"
},
"signIn": {
"button": "Entrar",
"retry": "Entrar novamente",
"starting": "Iniciando login…",
"waiting": "Aguardando autorização do navegador…",
"verifying": "Conectado. Verificando…",
"openLink": "Abrir link",
"copyLink": "Copiar link",
"copied": "Link copiado",
"copyFailed": "Não foi possível copiar o link",
"errorCliMissing": "Qoder CLI não encontrado. Instale-o ou configure o caminho nas configurações.",
"errorNodeMissing": "Qoder CLI requer Node.js, mas o Node.js não foi encontrado.",
"errorStartFailed": "Não foi possível iniciar o processo de login.",
"errorProcessFailed": "Falha no login. Verifique os detalhes e tente novamente."
},
"view": {
"openQoderian": "Abrir Qoderian",
"moveBlockedStreaming": "Aguarde a resposta atual terminar antes de mover a visualização do Qoderian.",
Expand Down
15 changes: 15 additions & 0 deletions src/i18n/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,21 @@
"running": "Выполняется...",
"copyFailed": "Не удалось скопировать в буфер обмена"
},
"signIn": {
"button": "Войти",
"retry": "Войти снова",
"starting": "Запуск входа…",
"waiting": "Ожидание авторизации в браузере…",
"verifying": "Вход выполнен. Проверка…",
"openLink": "Открыть ссылку",
"copyLink": "Копировать ссылку",
"copied": "Ссылка скопирована",
"copyFailed": "Не удалось скопировать ссылку",
"errorCliMissing": "Qoder CLI не найден. Установите его или укажите путь в настройках.",
"errorNodeMissing": "Qoder CLI требует Node.js, но Node.js не найден.",
"errorStartFailed": "Не удалось запустить процесс входа.",
"errorProcessFailed": "Ошибка входа. Проверьте детали и повторите попытку."
},
"view": {
"openQoderian": "Открыть Qoderian",
"moveBlockedStreaming": "Дождитесь завершения текущего ответа, прежде чем перемещать представление Qoderian.",
Expand Down
Loading
Loading