Skip to content

Commit 9cf6cd3

Browse files
luoxuanzaoQoder-AI
andauthored
feat(feedback): add in-app issue reporting (#62)
Add in-app issue reporting: a question-bubble button in the view header (and a "Submit feedback" command) opens a dialog that sends the user's note to the Qoder team through the SDK's one-shot `qodercli feedback` subprocess, reusing the plugin's resolved CLI path and enhanced PATH. Optionally attaches the current vault's runtime diagnostics and the active session id; a successful submit copies the returned request id to the clipboard. User-facing copy names Qoderian rather than qodercli, and there is no intro paragraph above the textarea. Co-authored-by: QoderAI (Qwen 3.8 Max) <qoder_ai@qoder.com>
1 parent b01fc61 commit 9cf6cd3

26 files changed

Lines changed: 1275 additions & 12 deletions

‎CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ version with its date and start a fresh empty `[Unreleased]` above it.
1818
grip also answers ArrowUp/ArrowDown for keyboard users, and double-click
1919
or Home returns to the automatic content-driven height.
2020

21+
- Feedback submission from inside Obsidian: a question-bubble button in the
22+
view header (and a "Submit feedback" command) opens a dialog that sends
23+
your note to the Qoder team through `qodercli feedback`, optionally
24+
attaching the current vault's runtime diagnostics and the active session
25+
id. Submissions are always explicit — nothing is reported automatically —
26+
and a successful one copies the server request id to your clipboard.
27+
2128
## [1.0.8] - 2026-09-12
2229

2330
### Fixed

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,8 @@ Open **Settings → Qoderian**:
153153
- **Environment variables**: The qodercli subprocess inherits Obsidian's process environment. Qoderian does not persist environment-variable overrides in the vault.
154154
- **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.
155155
- **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.
156-
- **Background activity**: Qoderian runs no telemetry of its own. Network activity is limited to qodercli and the MCP endpoints you configure.
156+
- **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.
157+
- **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.
157158

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

‎README.zh-CN.md‎

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

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

‎src/features/chat/chat-view.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
} from '../../shared/dom/animation-frame';
1414
import { setButtonTooltip } from '../../shared/dom/tooltip';
1515
import { createIconSvg, QODER_ICON,QODERIAN_ICON_ID } from '../../shared/icons';
16+
import { openFeedbackModal } from '../feedback/ui/feedback-modal';
1617
import type { HistoryConversationStatus } from './controllers/conversation-controller';
1718
import {
1819
sendTabInputMessageFromExplicitEnterShortcut,
@@ -223,6 +224,12 @@ export class QoderianView extends ItemView {
223224
this.syncHeaderLogo();
224225

225226
titleEl.createEl('h4', { text: 'Qoder', cls: 'qoderian-title-text' });
227+
228+
const headerActions = header.createDiv({ cls: 'qoderian-header-actions' });
229+
const feedbackBtn = headerActions.createDiv({ cls: 'qoderian-header-btn' });
230+
setIcon(feedbackBtn, 'message-circle-question');
231+
setButtonTooltip(feedbackBtn, t('commands.submitFeedback'));
232+
feedbackBtn.addEventListener('click', () => this.openFeedback());
226233
}
227234

228235
/**
@@ -288,6 +295,18 @@ export class QoderianView extends ItemView {
288295
return wrapper;
289296
}
290297

298+
private openFeedback(): void {
299+
const sessionId = this.tabManager?.getActiveTab()?.service?.getSessionId() ?? undefined;
300+
void openFeedbackModal(this.app, {
301+
plugin: this.plugin,
302+
sessionId,
303+
callerVersion: this.plugin.manifest?.version,
304+
onRequestSignIn: () => {
305+
this.plugin.qoderServices?.loginService.start();
306+
},
307+
});
308+
}
309+
291310
private buildInputFooter(): void {
292311
if (!this.viewContainerEl) return;
293312

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
import { type App, Modal, Notice } from 'obsidian';
2+
3+
import { t } from '../../../i18n/i18n';
4+
import type { QoderHostContext } from '../../../qoder/qoder-host-context';
5+
import {
6+
countFeedbackChars,
7+
FEEDBACK_CONTENT_LIMIT,
8+
submitUserFeedback,
9+
type UserFeedbackOutcome,
10+
} from '../../../qoder/services/submit-user-feedback';
11+
12+
export interface FeedbackModalContext {
13+
plugin: QoderHostContext;
14+
/** Active chat session, attached so the team can correlate the report. */
15+
sessionId?: string;
16+
/** Plugin version reported as `--caller-version`. */
17+
callerVersion?: string;
18+
/** Starts the existing device-flow sign-in; shown when a rejection may be auth-related. */
19+
onRequestSignIn?: () => void;
20+
}
21+
22+
type FeedbackFailure = Extract<UserFeedbackOutcome, { ok: false }>;
23+
type FeedbackSuccess = Extract<UserFeedbackOutcome, { ok: true }>;
24+
25+
export function openFeedbackModal(
26+
app: App,
27+
context: FeedbackModalContext,
28+
): Promise<UserFeedbackOutcome | null> {
29+
return new Promise(resolve => {
30+
new FeedbackModal(app, context, resolve).open();
31+
});
32+
}
33+
34+
/**
35+
* qodercli reports a missing session in prose rather than as a status code, so
36+
* the sign-in affordance keys off its wording. Anything else — an unsupported
37+
* flag, a network failure — is shown verbatim without suggesting a re-login.
38+
*/
39+
const AUTH_FAILURE_HINT = /log ?in|logged in|unauthor|unauthenticat|credential/i;
40+
41+
function isAuthFailure(detail: string | undefined): boolean {
42+
return !!detail && AUTH_FAILURE_HINT.test(detail);
43+
}
44+
45+
function describeFailure(failure: FeedbackFailure): string {
46+
switch (failure.reason) {
47+
case 'cliUnavailable':
48+
return t('feedback.errorCliUnavailable');
49+
case 'emptyContent':
50+
return t('feedback.errorEmpty');
51+
case 'contentTooLong':
52+
return t('feedback.errorTooLong', { limit: FEEDBACK_CONTENT_LIMIT });
53+
case 'rejected':
54+
return failure.detail?.trim() || t('feedback.errorRejected');
55+
}
56+
}
57+
58+
class FeedbackModal extends Modal {
59+
private readonly context: FeedbackModalContext;
60+
private readonly resolveOutcome: (outcome: UserFeedbackOutcome | null) => void;
61+
private resolved = false;
62+
private closed = false;
63+
private submitting = false;
64+
private includeDiagnostics = true;
65+
66+
constructor(
67+
app: App,
68+
context: FeedbackModalContext,
69+
resolve: (outcome: UserFeedbackOutcome | null) => void,
70+
) {
71+
super(app);
72+
this.context = context;
73+
this.resolveOutcome = resolve;
74+
}
75+
76+
onOpen(): void {
77+
this.setTitle(t('feedback.title'));
78+
this.modalEl.addClass('qoderian-feedback-modal');
79+
80+
const cliAvailable = this.context.plugin.getResolvedQoderCliPath() !== null;
81+
82+
if (!cliAvailable) {
83+
this.contentEl.createDiv({
84+
cls: 'qoderian-feedback-banner',
85+
text: t('feedback.errorCliUnavailable'),
86+
});
87+
}
88+
89+
const textarea = this.contentEl.createEl('textarea', {
90+
cls: 'qoderian-feedback-textarea',
91+
attr: { rows: '6', placeholder: t('feedback.placeholder') },
92+
});
93+
94+
const counter = this.contentEl.createDiv({ cls: 'qoderian-feedback-counter' });
95+
96+
const emailField = this.contentEl.createDiv({ cls: 'qoderian-feedback-field' });
97+
emailField.createEl('label', {
98+
cls: 'qoderian-feedback-label',
99+
text: t('feedback.emailLabel'),
100+
});
101+
const email = emailField.createEl('input', {
102+
cls: 'qoderian-feedback-input',
103+
attr: { type: 'email', placeholder: t('feedback.emailPlaceholder') },
104+
});
105+
emailField.createDiv({ cls: 'qoderian-feedback-hint', text: t('feedback.emailHint') });
106+
107+
const diagnostics = this.contentEl.createDiv({ cls: 'qoderian-feedback-diagnostics' });
108+
const diagnosticsToggle = diagnostics.createEl('input', {
109+
cls: 'qoderian-feedback-checkbox',
110+
attr: { type: 'checkbox' },
111+
});
112+
diagnosticsToggle.checked = this.includeDiagnostics;
113+
const diagnosticsText = diagnostics.createDiv({ cls: 'qoderian-feedback-diagnostics-text' });
114+
diagnosticsText.createDiv({
115+
cls: 'qoderian-feedback-label',
116+
text: t('feedback.diagnosticsLabel'),
117+
});
118+
diagnosticsText.createDiv({
119+
cls: 'qoderian-feedback-hint',
120+
text: t('feedback.diagnosticsDesc'),
121+
});
122+
123+
if (this.context.sessionId) {
124+
this.contentEl.createDiv({
125+
cls: 'qoderian-feedback-session',
126+
text: t('feedback.sessionHint', { sessionId: this.context.sessionId.slice(0, 8) }),
127+
});
128+
}
129+
130+
const error = this.contentEl.createDiv({ cls: 'qoderian-feedback-error qoderian-hidden' });
131+
132+
const actions = this.contentEl.createDiv({ cls: 'qoderian-feedback-actions' });
133+
const cancelButton = actions.createEl('button', {
134+
cls: 'qoderian-feedback-button',
135+
text: t('common.cancel'),
136+
});
137+
const submitButton = actions.createEl('button', {
138+
cls: 'qoderian-feedback-button qoderian-feedback-button--primary',
139+
text: t('feedback.submit'),
140+
});
141+
142+
const hideError = (): void => {
143+
error.empty();
144+
error.addClass('qoderian-hidden');
145+
};
146+
147+
const refresh = (): void => {
148+
const count = countFeedbackChars(textarea.value ?? '');
149+
counter.setText(`${count} / ${FEEDBACK_CONTENT_LIMIT}`);
150+
if (count > FEEDBACK_CONTENT_LIMIT) {
151+
counter.addClass('qoderian-feedback-counter--over');
152+
} else {
153+
counter.removeClass('qoderian-feedback-counter--over');
154+
}
155+
156+
// Over-limit input is rejected rather than silently truncated, so the
157+
// user never believes a shortened version was submitted.
158+
const submittable =
159+
cliAvailable && !this.submitting && count > 0 && count <= FEEDBACK_CONTENT_LIMIT;
160+
submitButton.disabled = !submittable;
161+
};
162+
163+
const setBusy = (busy: boolean): void => {
164+
this.submitting = busy;
165+
submitButton.setText(busy ? t('feedback.submitting') : t('feedback.submit'));
166+
cancelButton.disabled = busy;
167+
textarea.disabled = busy;
168+
email.disabled = busy;
169+
diagnosticsToggle.disabled = busy;
170+
refresh();
171+
};
172+
173+
const showError = (message: string, signIn: (() => void) | null): void => {
174+
error.empty();
175+
error.createDiv({ cls: 'qoderian-feedback-error-message', text: message });
176+
if (signIn) {
177+
const signInButton = error.createEl('button', {
178+
cls: 'qoderian-feedback-signin',
179+
text: t('feedback.signIn'),
180+
});
181+
signInButton.addEventListener('click', () => {
182+
signIn();
183+
this.close();
184+
});
185+
}
186+
error.removeClass('qoderian-hidden');
187+
};
188+
189+
const submit = async (): Promise<void> => {
190+
if (this.submitting || !cliAvailable) return;
191+
hideError();
192+
setBusy(true);
193+
194+
const outcome = await submitUserFeedback(this.context.plugin, {
195+
content: textarea.value ?? '',
196+
email: email.value ?? '',
197+
sessionId: this.context.sessionId,
198+
callerVersion: this.context.callerVersion,
199+
includeWorkspaceDiagnostics: this.includeDiagnostics,
200+
});
201+
202+
if (outcome.ok) {
203+
this.resolved = true;
204+
this.resolveOutcome(outcome);
205+
this.close();
206+
await this.announceSuccess(outcome);
207+
return;
208+
}
209+
210+
// `submitFeedback` cannot be aborted, so the modal may already be gone by
211+
// the time it settles. Report through a Notice rather than detached DOM.
212+
if (this.closed) {
213+
new Notice(t('feedback.noticeFailed'));
214+
return;
215+
}
216+
217+
setBusy(false);
218+
const onRequestSignIn = this.context.onRequestSignIn;
219+
// A blank email plus auth wording is the only case where signing in is the
220+
// fix: qodercli requires an address when no session exists.
221+
const offerSignIn =
222+
onRequestSignIn && !(email.value ?? '').trim() && isAuthFailure(outcome.detail)
223+
? onRequestSignIn
224+
: null;
225+
showError(describeFailure(outcome), offerSignIn);
226+
};
227+
228+
textarea.addEventListener('input', () => {
229+
hideError();
230+
refresh();
231+
});
232+
email.addEventListener('input', hideError);
233+
diagnosticsToggle.addEventListener('change', () => {
234+
this.includeDiagnostics = diagnosticsToggle.checked;
235+
});
236+
cancelButton.addEventListener('click', () => this.close());
237+
submitButton.addEventListener('click', () => {
238+
void submit();
239+
});
240+
241+
refresh();
242+
textarea.focus();
243+
}
244+
245+
/**
246+
* Copies the server request id so the user can quote it in a follow-up. When
247+
* no clipboard API is available the id is still spelled out in the notice.
248+
*/
249+
private async announceSuccess(outcome: FeedbackSuccess): Promise<void> {
250+
const requestId = outcome.requestId;
251+
if (!requestId) {
252+
new Notice(t('feedback.noticeSuccess'));
253+
return;
254+
}
255+
try {
256+
await navigator.clipboard.writeText(requestId);
257+
new Notice(t('feedback.noticeSuccessCopied', { requestId }));
258+
} catch {
259+
new Notice(t('feedback.noticeSuccessWithId', { requestId }));
260+
}
261+
}
262+
263+
onClose(): void {
264+
this.closed = true;
265+
if (!this.resolved) {
266+
this.resolved = true;
267+
this.resolveOutcome(null);
268+
}
269+
this.contentEl.empty();
270+
}
271+
}

‎src/i18n/locales/de.json‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,29 @@
3737
"editApplied": "Bearbeitung angewendet",
3838
"newTab": "Neuer Tab",
3939
"newSession": "Neue Sitzung (im aktuellen Tab)",
40-
"closeCurrentTab": "Aktuellen Tab schließen"
40+
"closeCurrentTab": "Aktuellen Tab schließen",
41+
"submitFeedback": "Feedback senden"
42+
},
43+
"feedback": {
44+
"title": "Feedback senden",
45+
"placeholder": "Problem oder Vorschlag beschreiben …",
46+
"emailLabel": "Kontakt-E-Mail (optional)",
47+
"emailPlaceholder": "you@example.com",
48+
"emailHint": "Kann leer bleiben, wenn Sie bei Qoderian angemeldet sind.",
49+
"diagnosticsLabel": "Arbeitsbereich-Diagnosedaten anhängen",
50+
"diagnosticsDesc": "Qoderian erfasst den Laufzeitzustand des aktuellen Vaults und lädt ihn mit Ihrem Feedback hoch, um das Problem einzugrenzen.",
51+
"sessionHint": "Wird mit der aktuellen Sitzung {sessionId} verknüpft.",
52+
"submit": "Senden",
53+
"submitting": "Wird gesendet …",
54+
"signIn": "Bei Qoderian anmelden",
55+
"errorCliUnavailable": "Qoder CLI wurde nicht gefunden. Konfigurieren Sie den Pfad in den Qoderian-Einstellungen oder installieren Sie es, und versuchen Sie es erneut.",
56+
"errorEmpty": "Beschreiben Sie das Problem, bevor Sie senden.",
57+
"errorTooLong": "Feedback ist auf {limit} Zeichen begrenzt.",
58+
"errorRejected": "qodercli konnte das Feedback nicht senden.",
59+
"noticeSuccess": "Feedback gesendet. Vielen Dank!",
60+
"noticeSuccessWithId": "Feedback gesendet (Anfrage {requestId}). Vielen Dank!",
61+
"noticeSuccessCopied": "Feedback gesendet. Anfrage {requestId} wurde in die Zwischenablage kopiert.",
62+
"noticeFailed": "Feedback wurde nicht gesendet."
4163
},
4264
"nav": {
4365
"newConversation": "Neue Unterhaltung",

0 commit comments

Comments
 (0)