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 @@ -18,6 +18,13 @@ version with its date and start a fresh empty `[Unreleased]` above it.
grip also answers ArrowUp/ArrowDown for keyboard users, and double-click
or Home returns to the automatic content-driven height.

- Feedback submission from inside Obsidian: a question-bubble button in the
view header (and a "Submit feedback" command) opens a dialog that sends
your note to the Qoder team through `qodercli feedback`, optionally
attaching the current vault's runtime diagnostics and the active session
id. Submissions are always explicit — nothing is reported automatically —
and a successful one copies the server request id to your clipboard.

## [1.0.8] - 2026-09-12

### Fixed
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,8 @@ Open **Settings → Qoderian**:
- **Environment variables**: The qodercli subprocess inherits Obsidian's process environment. Qoderian does not persist environment-variable overrides in the vault.
- **File and shell access**: Depending on your permission mode and confirmations, qodercli may read, create, modify, or delete files and run shell commands. Understand the risks before enabling `YOLO`, and keep backups or version control for an important vault.
- **Reach beyond the vault**: External context and MCP servers may access files outside your vault or third-party network services, under those services' own rules.
- **Background activity**: Qoderian runs no telemetry of its own. Network activity is limited to qodercli and the MCP endpoints you configure.
- **Feedback reports**: Submitting feedback (the megaphone button in the composer bar, or the "Submit feedback" command) only ever happens when you choose it. It sends your note together with the plugin version and, when a chat is open, the active session id. By default it also lets `qodercli feedback` collect the current vault's runtime state to help locate the problem; uncheck "Attach workspace diagnostics" in the dialog to send only your note.
- **Background activity**: Qoderian runs no telemetry of its own — nothing is uploaded automatically or in the background. Network activity is limited to qodercli, the MCP endpoints you configure, and feedback reports you submit yourself.

Redact settings, logs, and screenshots before filing an issue.

Expand Down
3 changes: 2 additions & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,8 @@ QODER_CLI_PATH=/absolute/path/to/qodercli npm run smoke:qoder
- **环境变量**:qodercli 子进程继承 Obsidian 的进程环境。Qoderian 不会在仓库中持久化环境变量覆盖。
- **文件与 shell 访问**:取决于权限模式与确认,qodercli 可能读取、创建、修改、删除文件并运行 shell 命令。启用 `YOLO` 前请了解风险,并为重要仓库保留备份或版本控制。
- **仓库之外的触达**:外部上下文与 MCP 服务器可能按各自服务的规则访问仓库之外的文件或第三方网络服务。
- **后台活动**:Qoderian 自身不跑任何遥测。网络活动仅限于 qodercli 和你配置的 MCP 端点。
- **反馈上报**:提交反馈(输入框工具栏的喇叭按钮,或"提交反馈"命令)只在你主动选择时发生。它会发送你的描述、插件版本,以及在聊天打开时的当前会话 id;默认还会让 `qodercli feedback` 收集当前 vault 的运行状态以协助定位问题,在弹窗中取消勾选"附带工作区诊断信息"即可只发送你的描述。
- **后台活动**:Qoderian 自身不跑任何遥测——不会自动或在后台上传任何内容。网络活动仅限于 qodercli、你配置的 MCP 端点,以及你主动提交的反馈。

提交 issue 前请打码设置、日志与截图。

Expand Down
19 changes: 19 additions & 0 deletions src/features/chat/chat-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from '../../shared/dom/animation-frame';
import { setButtonTooltip } from '../../shared/dom/tooltip';
import { createIconSvg, QODER_ICON,QODERIAN_ICON_ID } from '../../shared/icons';
import { openFeedbackModal } from '../feedback/ui/feedback-modal';
import type { HistoryConversationStatus } from './controllers/conversation-controller';
import {
sendTabInputMessageFromExplicitEnterShortcut,
Expand Down Expand Up @@ -223,6 +224,12 @@ export class QoderianView extends ItemView {
this.syncHeaderLogo();

titleEl.createEl('h4', { text: 'Qoder', cls: 'qoderian-title-text' });

const headerActions = header.createDiv({ cls: 'qoderian-header-actions' });
const feedbackBtn = headerActions.createDiv({ cls: 'qoderian-header-btn' });
setIcon(feedbackBtn, 'message-circle-question');
setButtonTooltip(feedbackBtn, t('commands.submitFeedback'));
feedbackBtn.addEventListener('click', () => this.openFeedback());
}

/**
Expand Down Expand Up @@ -288,6 +295,18 @@ export class QoderianView extends ItemView {
return wrapper;
}

private openFeedback(): void {
const sessionId = this.tabManager?.getActiveTab()?.service?.getSessionId() ?? undefined;
void openFeedbackModal(this.app, {
plugin: this.plugin,
sessionId,
callerVersion: this.plugin.manifest?.version,
onRequestSignIn: () => {
this.plugin.qoderServices?.loginService.start();
},
});
}

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

Expand Down
271 changes: 271 additions & 0 deletions src/features/feedback/ui/feedback-modal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
import { type App, Modal, Notice } from 'obsidian';

import { t } from '../../../i18n/i18n';
import type { QoderHostContext } from '../../../qoder/qoder-host-context';
import {
countFeedbackChars,
FEEDBACK_CONTENT_LIMIT,
submitUserFeedback,
type UserFeedbackOutcome,
} from '../../../qoder/services/submit-user-feedback';

export interface FeedbackModalContext {
plugin: QoderHostContext;
/** Active chat session, attached so the team can correlate the report. */
sessionId?: string;
/** Plugin version reported as `--caller-version`. */
callerVersion?: string;
/** Starts the existing device-flow sign-in; shown when a rejection may be auth-related. */
onRequestSignIn?: () => void;
}

type FeedbackFailure = Extract<UserFeedbackOutcome, { ok: false }>;
type FeedbackSuccess = Extract<UserFeedbackOutcome, { ok: true }>;

export function openFeedbackModal(
app: App,
context: FeedbackModalContext,
): Promise<UserFeedbackOutcome | null> {
return new Promise(resolve => {
new FeedbackModal(app, context, resolve).open();
});
}

/**
* qodercli reports a missing session in prose rather than as a status code, so
* the sign-in affordance keys off its wording. Anything else — an unsupported
* flag, a network failure — is shown verbatim without suggesting a re-login.
*/
const AUTH_FAILURE_HINT = /log ?in|logged in|unauthor|unauthenticat|credential/i;

function isAuthFailure(detail: string | undefined): boolean {
return !!detail && AUTH_FAILURE_HINT.test(detail);
}

function describeFailure(failure: FeedbackFailure): string {
switch (failure.reason) {
case 'cliUnavailable':
return t('feedback.errorCliUnavailable');
case 'emptyContent':
return t('feedback.errorEmpty');
case 'contentTooLong':
return t('feedback.errorTooLong', { limit: FEEDBACK_CONTENT_LIMIT });
case 'rejected':
return failure.detail?.trim() || t('feedback.errorRejected');
}
}

class FeedbackModal extends Modal {
private readonly context: FeedbackModalContext;
private readonly resolveOutcome: (outcome: UserFeedbackOutcome | null) => void;
private resolved = false;
private closed = false;
private submitting = false;
private includeDiagnostics = true;

constructor(
app: App,
context: FeedbackModalContext,
resolve: (outcome: UserFeedbackOutcome | null) => void,
) {
super(app);
this.context = context;
this.resolveOutcome = resolve;
}

onOpen(): void {
this.setTitle(t('feedback.title'));
this.modalEl.addClass('qoderian-feedback-modal');

const cliAvailable = this.context.plugin.getResolvedQoderCliPath() !== null;

if (!cliAvailable) {
this.contentEl.createDiv({
cls: 'qoderian-feedback-banner',
text: t('feedback.errorCliUnavailable'),
});
}

const textarea = this.contentEl.createEl('textarea', {
cls: 'qoderian-feedback-textarea',
attr: { rows: '6', placeholder: t('feedback.placeholder') },
});

const counter = this.contentEl.createDiv({ cls: 'qoderian-feedback-counter' });

const emailField = this.contentEl.createDiv({ cls: 'qoderian-feedback-field' });
emailField.createEl('label', {
cls: 'qoderian-feedback-label',
text: t('feedback.emailLabel'),
});
const email = emailField.createEl('input', {
cls: 'qoderian-feedback-input',
attr: { type: 'email', placeholder: t('feedback.emailPlaceholder') },
});
emailField.createDiv({ cls: 'qoderian-feedback-hint', text: t('feedback.emailHint') });

const diagnostics = this.contentEl.createDiv({ cls: 'qoderian-feedback-diagnostics' });
const diagnosticsToggle = diagnostics.createEl('input', {
cls: 'qoderian-feedback-checkbox',
attr: { type: 'checkbox' },
});
diagnosticsToggle.checked = this.includeDiagnostics;
const diagnosticsText = diagnostics.createDiv({ cls: 'qoderian-feedback-diagnostics-text' });
diagnosticsText.createDiv({
cls: 'qoderian-feedback-label',
text: t('feedback.diagnosticsLabel'),
});
diagnosticsText.createDiv({
cls: 'qoderian-feedback-hint',
text: t('feedback.diagnosticsDesc'),
});

if (this.context.sessionId) {
this.contentEl.createDiv({
cls: 'qoderian-feedback-session',
text: t('feedback.sessionHint', { sessionId: this.context.sessionId.slice(0, 8) }),
});
}

const error = this.contentEl.createDiv({ cls: 'qoderian-feedback-error qoderian-hidden' });

const actions = this.contentEl.createDiv({ cls: 'qoderian-feedback-actions' });
const cancelButton = actions.createEl('button', {
cls: 'qoderian-feedback-button',
text: t('common.cancel'),
});
const submitButton = actions.createEl('button', {
cls: 'qoderian-feedback-button qoderian-feedback-button--primary',
text: t('feedback.submit'),
});

const hideError = (): void => {
error.empty();
error.addClass('qoderian-hidden');
};

const refresh = (): void => {
const count = countFeedbackChars(textarea.value ?? '');
counter.setText(`${count} / ${FEEDBACK_CONTENT_LIMIT}`);
if (count > FEEDBACK_CONTENT_LIMIT) {
counter.addClass('qoderian-feedback-counter--over');
} else {
counter.removeClass('qoderian-feedback-counter--over');
}

// Over-limit input is rejected rather than silently truncated, so the
// user never believes a shortened version was submitted.
const submittable =
cliAvailable && !this.submitting && count > 0 && count <= FEEDBACK_CONTENT_LIMIT;
submitButton.disabled = !submittable;
};

const setBusy = (busy: boolean): void => {
this.submitting = busy;
submitButton.setText(busy ? t('feedback.submitting') : t('feedback.submit'));
cancelButton.disabled = busy;
textarea.disabled = busy;
email.disabled = busy;
diagnosticsToggle.disabled = busy;
refresh();
};

const showError = (message: string, signIn: (() => void) | null): void => {
error.empty();
error.createDiv({ cls: 'qoderian-feedback-error-message', text: message });
if (signIn) {
const signInButton = error.createEl('button', {
cls: 'qoderian-feedback-signin',
text: t('feedback.signIn'),
});
signInButton.addEventListener('click', () => {
signIn();
this.close();
});
}
error.removeClass('qoderian-hidden');
};

const submit = async (): Promise<void> => {
if (this.submitting || !cliAvailable) return;
hideError();
setBusy(true);

const outcome = await submitUserFeedback(this.context.plugin, {
content: textarea.value ?? '',
email: email.value ?? '',
sessionId: this.context.sessionId,
callerVersion: this.context.callerVersion,
includeWorkspaceDiagnostics: this.includeDiagnostics,
});

if (outcome.ok) {
this.resolved = true;
this.resolveOutcome(outcome);
this.close();
await this.announceSuccess(outcome);
return;
}

// `submitFeedback` cannot be aborted, so the modal may already be gone by
// the time it settles. Report through a Notice rather than detached DOM.
if (this.closed) {
new Notice(t('feedback.noticeFailed'));
return;
}

setBusy(false);
const onRequestSignIn = this.context.onRequestSignIn;
// A blank email plus auth wording is the only case where signing in is the
// fix: qodercli requires an address when no session exists.
const offerSignIn =
onRequestSignIn && !(email.value ?? '').trim() && isAuthFailure(outcome.detail)
? onRequestSignIn
: null;
showError(describeFailure(outcome), offerSignIn);
};

textarea.addEventListener('input', () => {
hideError();
refresh();
});
email.addEventListener('input', hideError);
diagnosticsToggle.addEventListener('change', () => {
this.includeDiagnostics = diagnosticsToggle.checked;
});
cancelButton.addEventListener('click', () => this.close());
submitButton.addEventListener('click', () => {
void submit();
});

refresh();
textarea.focus();
}

/**
* Copies the server request id so the user can quote it in a follow-up. When
* no clipboard API is available the id is still spelled out in the notice.
*/
private async announceSuccess(outcome: FeedbackSuccess): Promise<void> {
const requestId = outcome.requestId;
if (!requestId) {
new Notice(t('feedback.noticeSuccess'));
return;
}
try {
await navigator.clipboard.writeText(requestId);
new Notice(t('feedback.noticeSuccessCopied', { requestId }));
} catch {
new Notice(t('feedback.noticeSuccessWithId', { requestId }));
}
}

onClose(): void {
this.closed = true;
if (!this.resolved) {
this.resolved = true;
this.resolveOutcome(null);
}
this.contentEl.empty();
}
}
24 changes: 23 additions & 1 deletion src/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,29 @@
"editApplied": "Bearbeitung angewendet",
"newTab": "Neuer Tab",
"newSession": "Neue Sitzung (im aktuellen Tab)",
"closeCurrentTab": "Aktuellen Tab schließen"
"closeCurrentTab": "Aktuellen Tab schließen",
"submitFeedback": "Feedback senden"
},
"feedback": {
"title": "Feedback senden",
"placeholder": "Problem oder Vorschlag beschreiben …",
"emailLabel": "Kontakt-E-Mail (optional)",
"emailPlaceholder": "you@example.com",
"emailHint": "Kann leer bleiben, wenn Sie bei Qoderian angemeldet sind.",
"diagnosticsLabel": "Arbeitsbereich-Diagnosedaten anhängen",
"diagnosticsDesc": "Qoderian erfasst den Laufzeitzustand des aktuellen Vaults und lädt ihn mit Ihrem Feedback hoch, um das Problem einzugrenzen.",
"sessionHint": "Wird mit der aktuellen Sitzung {sessionId} verknüpft.",
"submit": "Senden",
"submitting": "Wird gesendet …",
"signIn": "Bei Qoderian anmelden",
"errorCliUnavailable": "Qoder CLI wurde nicht gefunden. Konfigurieren Sie den Pfad in den Qoderian-Einstellungen oder installieren Sie es, und versuchen Sie es erneut.",
"errorEmpty": "Beschreiben Sie das Problem, bevor Sie senden.",
"errorTooLong": "Feedback ist auf {limit} Zeichen begrenzt.",
"errorRejected": "qodercli konnte das Feedback nicht senden.",
"noticeSuccess": "Feedback gesendet. Vielen Dank!",
"noticeSuccessWithId": "Feedback gesendet (Anfrage {requestId}). Vielen Dank!",
"noticeSuccessCopied": "Feedback gesendet. Anfrage {requestId} wurde in die Zwischenablage kopiert.",
"noticeFailed": "Feedback wurde nicht gesendet."
},
"nav": {
"newConversation": "Neue Unterhaltung",
Expand Down
Loading
Loading