From 1d7dc68c592fbe8cc6130a04ead5dd1017f8efed Mon Sep 17 00:00:00 2001 From: liuxuezhuo Date: Mon, 14 Sep 2026 09:18:41 +0800 Subject: [PATCH] feat(feedback): add in-app issue reporting Reporting a problem meant leaving Obsidian and running the CLI by hand. Add a question-bubble button in the view header that opens a modal, submits through the CLI feedback endpoint, and copies the returned request id to the clipboard on success. --- CHANGELOG.md | 7 + README.md | 3 +- README.zh-CN.md | 3 +- src/features/chat/chat-view.ts | 19 ++ src/features/feedback/ui/feedback-modal.ts | 271 ++++++++++++++++++ src/i18n/locales/de.json | 24 +- src/i18n/locales/en.json | 24 +- src/i18n/locales/es.json | 24 +- src/i18n/locales/fr.json | 24 +- src/i18n/locales/ja.json | 24 +- src/i18n/locales/ko.json | 24 +- src/i18n/locales/pt.json | 24 +- src/i18n/locales/ru.json | 24 +- src/i18n/locales/zh-CN.json | 24 +- src/i18n/locales/zh-TW.json | 24 +- src/i18n/types.ts | 22 ++ src/main.ts | 19 ++ src/qoder/services/submit-user-feedback.ts | 96 +++++++ src/style/accessibility.css | 1 + src/style/components/header.css | 25 ++ src/style/index.css | 1 + src/style/modals/feedback-modal.css | 150 ++++++++++ tests/__mocks__/qoder-agent-sdk.ts | 30 ++ .../feedback/ui/feedback-modal.test.ts | 258 +++++++++++++++++ tests/unit/i18n/locales.test.ts | 19 ++ .../services/submit-user-feedback.test.ts | 123 ++++++++ 26 files changed, 1275 insertions(+), 12 deletions(-) create mode 100644 src/features/feedback/ui/feedback-modal.ts create mode 100644 src/qoder/services/submit-user-feedback.ts create mode 100644 src/style/modals/feedback-modal.css create mode 100644 tests/unit/features/feedback/ui/feedback-modal.test.ts create mode 100644 tests/unit/qoder/services/submit-user-feedback.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 559b93b..111cd7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index eba6034..678c675 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/README.zh-CN.md b/README.zh-CN.md index 70d4196..2c76cfd 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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 前请打码设置、日志与截图。 diff --git a/src/features/chat/chat-view.ts b/src/features/chat/chat-view.ts index 2becba5..22c1355 100644 --- a/src/features/chat/chat-view.ts +++ b/src/features/chat/chat-view.ts @@ -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, @@ -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()); } /** @@ -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; diff --git a/src/features/feedback/ui/feedback-modal.ts b/src/features/feedback/ui/feedback-modal.ts new file mode 100644 index 0000000..7b7cb80 --- /dev/null +++ b/src/features/feedback/ui/feedback-modal.ts @@ -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; +type FeedbackSuccess = Extract; + +export function openFeedbackModal( + app: App, + context: FeedbackModalContext, +): Promise { + 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 => { + 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 { + 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(); + } +} diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 3b23317..593b181 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -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", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 46eb06e..4818be4 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -37,7 +37,29 @@ "editApplied": "Edit applied", "newTab": "New tab", "newSession": "New session (in current tab)", - "closeCurrentTab": "Close current tab" + "closeCurrentTab": "Close current tab", + "submitFeedback": "Submit feedback" + }, + "feedback": { + "title": "Submit feedback", + "placeholder": "Describe the problem or suggestion…", + "emailLabel": "Contact email (optional)", + "emailPlaceholder": "you@example.com", + "emailHint": "Can be left blank when you are signed in to Qoderian.", + "diagnosticsLabel": "Attach workspace diagnostics", + "diagnosticsDesc": "Qoderian collects the current vault runtime state and uploads it with your note to help locate the problem.", + "sessionHint": "Will be linked to the current session {sessionId}.", + "submit": "Submit feedback", + "submitting": "Submitting…", + "signIn": "Sign in to Qoderian", + "errorCliUnavailable": "Qoder CLI was not found. Configure its path in Qoderian settings or install it, then try again.", + "errorEmpty": "Describe the problem before submitting.", + "errorTooLong": "Feedback is limited to {limit} characters.", + "errorRejected": "qodercli could not submit the feedback.", + "noticeSuccess": "Feedback submitted. Thank you!", + "noticeSuccessWithId": "Feedback submitted (request {requestId}). Thank you!", + "noticeSuccessCopied": "Feedback submitted. Request {requestId} copied to your clipboard.", + "noticeFailed": "Feedback was not submitted." }, "nav": { "newConversation": "New conversation", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 85c5794..24e52af 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -37,7 +37,29 @@ "editApplied": "Edición aplicada", "newTab": "Nueva pestaña", "newSession": "Nueva sesión (en la pestaña actual)", - "closeCurrentTab": "Cerrar pestaña actual" + "closeCurrentTab": "Cerrar pestaña actual", + "submitFeedback": "Enviar comentarios" + }, + "feedback": { + "title": "Enviar comentarios", + "placeholder": "Describe el problema o la sugerencia…", + "emailLabel": "Correo de contacto (opcional)", + "emailPlaceholder": "you@example.com", + "emailHint": "Puede dejarse vacío si has iniciado sesión en Qoderian.", + "diagnosticsLabel": "Adjuntar diagnóstico del espacio de trabajo", + "diagnosticsDesc": "Qoderian recopila el estado de ejecución del vault actual y lo sube con tu comentario para ayudar a localizar el problema.", + "sessionHint": "Se vinculará con la sesión actual {sessionId}.", + "submit": "Enviar", + "submitting": "Enviando…", + "signIn": "Iniciar sesión en Qoderian", + "errorCliUnavailable": "No se encontró Qoder CLI. Configura su ruta en los ajustes de Qoderian o instálalo y vuelve a intentarlo.", + "errorEmpty": "Describe el problema antes de enviar.", + "errorTooLong": "Los comentarios están limitados a {limit} caracteres.", + "errorRejected": "qodercli no pudo enviar los comentarios.", + "noticeSuccess": "Comentarios enviados. ¡Gracias!", + "noticeSuccessWithId": "Comentarios enviados (solicitud {requestId}). ¡Gracias!", + "noticeSuccessCopied": "Comentarios enviados. La solicitud {requestId} se copió al portapapeles.", + "noticeFailed": "Los comentarios no se enviaron." }, "nav": { "newConversation": "Nueva conversación", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 555c537..8dff43c 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -37,7 +37,29 @@ "editApplied": "Modification appliquée", "newTab": "Nouvel onglet", "newSession": "Nouvelle session (dans l'onglet actuel)", - "closeCurrentTab": "Fermer l'onglet actuel" + "closeCurrentTab": "Fermer l'onglet actuel", + "submitFeedback": "Envoyer un retour" + }, + "feedback": { + "title": "Envoyer un retour", + "placeholder": "Décrivez le problème ou la suggestion…", + "emailLabel": "E-mail de contact (facultatif)", + "emailPlaceholder": "you@example.com", + "emailHint": "Peut rester vide si vous êtes connecté à Qoderian.", + "diagnosticsLabel": "Joindre les diagnostics de l’espace de travail", + "diagnosticsDesc": "Qoderian collecte l’état d’exécution du vault actuel et l’envoie avec votre retour pour aider à localiser le problème.", + "sessionHint": "Sera lié à la session actuelle {sessionId}.", + "submit": "Envoyer", + "submitting": "Envoi…", + "signIn": "Se connecter à Qoderian", + "errorCliUnavailable": "Qoder CLI est introuvable. Configurez son chemin dans les paramètres Qoderian ou installez-le, puis réessayez.", + "errorEmpty": "Décrivez le problème avant d’envoyer.", + "errorTooLong": "Le retour est limité à {limit} caractères.", + "errorRejected": "qodercli n’a pas pu envoyer le retour.", + "noticeSuccess": "Retour envoyé. Merci !", + "noticeSuccessWithId": "Retour envoyé (requête {requestId}). Merci !", + "noticeSuccessCopied": "Retour envoyé. La requête {requestId} a été copiée dans le presse-papiers.", + "noticeFailed": "Le retour n’a pas été envoyé." }, "nav": { "newConversation": "Nouvelle conversation", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 7ff3022..7dc0137 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -37,7 +37,29 @@ "editApplied": "編集を適用しました", "newTab": "新しいタブ", "newSession": "新しいセッション(現在のタブ)", - "closeCurrentTab": "現在のタブを閉じる" + "closeCurrentTab": "現在のタブを閉じる", + "submitFeedback": "フィードバックを送信" + }, + "feedback": { + "title": "フィードバックを送信", + "placeholder": "問題や提案を記入…", + "emailLabel": "連絡先メール(任意)", + "emailPlaceholder": "you@example.com", + "emailHint": "Qoderian にサインイン済みの場合は空欄で構いません。", + "diagnosticsLabel": "ワークスペースの診断情報を添付", + "diagnosticsDesc": "Qoderian が現在の vault の実行状態を収集し、フィードバックと一緒にアップロードして問題の特定に役立てます。", + "sessionHint": "現在のセッション {sessionId} に関連付けられます。", + "submit": "送信", + "submitting": "送信中…", + "signIn": "Qoderian にサインイン", + "errorCliUnavailable": "Qoder CLI が見つかりません。Qoderian の設定でパスを設定するか、インストールしてから再試行してください。", + "errorEmpty": "送信前に問題を記入してください。", + "errorTooLong": "フィードバックは {limit} 文字以内です。", + "errorRejected": "qodercli がフィードバックを送信できませんでした。", + "noticeSuccess": "フィードバックを送信しました。ありがとうございます!", + "noticeSuccessWithId": "フィードバックを送信しました(リクエスト {requestId})。ありがとうございます!", + "noticeSuccessCopied": "フィードバックを送信しました。リクエスト {requestId} をクリップボードにコピーしました。", + "noticeFailed": "フィードバックは送信されませんでした。" }, "nav": { "newConversation": "新しい会話", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 7c8170a..0829151 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -37,7 +37,29 @@ "editApplied": "편집 적용됨", "newTab": "새 탭", "newSession": "새 세션 (현재 탭)", - "closeCurrentTab": "현재 탭 닫기" + "closeCurrentTab": "현재 탭 닫기", + "submitFeedback": "피드백 보내기" + }, + "feedback": { + "title": "피드백 보내기", + "placeholder": "문제 또는 제안 설명…", + "emailLabel": "연락용 이메일(선택)", + "emailPlaceholder": "you@example.com", + "emailHint": "Qoderian에 로그인한 상태라면 비워 둘 수 있습니다.", + "diagnosticsLabel": "워크스페이스 진단 정보 첨부", + "diagnosticsDesc": "Qoderian이 현재 vault의 실행 상태를 수집해 피드백과 함께 업로드하여 문제 파악에 활용합니다.", + "sessionHint": "현재 세션 {sessionId}과(와) 연결됩니다.", + "submit": "보내기", + "submitting": "보내는 중…", + "signIn": "Qoderian에 로그인", + "errorCliUnavailable": "Qoder CLI를 찾을 수 없습니다. Qoderian 설정에서 경로를 설정하거나 설치한 후 다시 시도하세요.", + "errorEmpty": "보내기 전에 문제를 입력하세요.", + "errorTooLong": "피드백은 {limit}자 이내여야 합니다.", + "errorRejected": "qodercli가 피드백을 보내지 못했습니다.", + "noticeSuccess": "피드백을 보냈습니다. 감사합니다!", + "noticeSuccessWithId": "피드백을 보냈습니다(요청 {requestId}). 감사합니다!", + "noticeSuccessCopied": "피드백을 보냈습니다. 요청 {requestId}을(를) 클립보드에 복사했습니다.", + "noticeFailed": "피드백이 전송되지 않았습니다." }, "nav": { "newConversation": "새 대화", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 58b8df2..071a65d 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -37,7 +37,29 @@ "editApplied": "Edição aplicada", "newTab": "Nova aba", "newSession": "Nova sessão (na aba atual)", - "closeCurrentTab": "Fechar aba atual" + "closeCurrentTab": "Fechar aba atual", + "submitFeedback": "Enviar feedback" + }, + "feedback": { + "title": "Enviar feedback", + "placeholder": "Descreva o problema ou a sugestão…", + "emailLabel": "E-mail de contato (opcional)", + "emailPlaceholder": "you@example.com", + "emailHint": "Pode ficar em branco se você estiver conectado ao Qoderian.", + "diagnosticsLabel": "Anexar diagnósticos do espaço de trabalho", + "diagnosticsDesc": "O Qoderian coleta o estado de execução do vault atual e o envia junto com seu feedback para ajudar a localizar o problema.", + "sessionHint": "Será vinculado à sessão atual {sessionId}.", + "submit": "Enviar", + "submitting": "Enviando…", + "signIn": "Entrar no Qoderian", + "errorCliUnavailable": "Qoder CLI não foi encontrado. Configure o caminho nas configurações do Qoderian ou instale-o e tente novamente.", + "errorEmpty": "Descreva o problema antes de enviar.", + "errorTooLong": "O feedback é limitado a {limit} caracteres.", + "errorRejected": "O qodercli não conseguiu enviar o feedback.", + "noticeSuccess": "Feedback enviado. Obrigado!", + "noticeSuccessWithId": "Feedback enviado (solicitação {requestId}). Obrigado!", + "noticeSuccessCopied": "Feedback enviado. A solicitação {requestId} foi copiada para a área de transferência.", + "noticeFailed": "O feedback não foi enviado." }, "nav": { "newConversation": "Nova conversa", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 19a86b8..6cf3f94 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -37,7 +37,29 @@ "editApplied": "Правка применена", "newTab": "Новая вкладка", "newSession": "Новая сессия (в текущей вкладке)", - "closeCurrentTab": "Закрыть текущую вкладку" + "closeCurrentTab": "Закрыть текущую вкладку", + "submitFeedback": "Отправить отзыв" + }, + "feedback": { + "title": "Отправить отзыв", + "placeholder": "Опишите проблему или предложение…", + "emailLabel": "Контактный e-mail (необязательно)", + "emailPlaceholder": "you@example.com", + "emailHint": "Можно оставить пустым, если вы вошли в Qoderian.", + "diagnosticsLabel": "Приложить диагностику рабочего пространства", + "diagnosticsDesc": "Qoderian соберёт состояние текущего vault и загрузит его вместе с отзывом, чтобы помочь найти проблему.", + "sessionHint": "Будет привязано к текущей сессии {sessionId}.", + "submit": "Отправить", + "submitting": "Отправка…", + "signIn": "Войти в Qoderian", + "errorCliUnavailable": "Qoder CLI не найден. Укажите путь к нему в настройках Qoderian или установите его, затем повторите попытку.", + "errorEmpty": "Опишите проблему перед отправкой.", + "errorTooLong": "Отзыв ограничен {limit} символами.", + "errorRejected": "qodercli не смог отправить отзыв.", + "noticeSuccess": "Отзыв отправлен. Спасибо!", + "noticeSuccessWithId": "Отзыв отправлен (запрос {requestId}). Спасибо!", + "noticeSuccessCopied": "Отзыв отправлен. Запрос {requestId} скопирован в буфер обмена.", + "noticeFailed": "Отзыв не был отправлен." }, "nav": { "newConversation": "Новый разговор", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 0576e07..73130ca 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -37,7 +37,29 @@ "editApplied": "已应用编辑", "newTab": "新建标签页", "newSession": "新建会话(当前标签页)", - "closeCurrentTab": "关闭当前标签页" + "closeCurrentTab": "关闭当前标签页", + "submitFeedback": "提交反馈" + }, + "feedback": { + "title": "提交反馈", + "placeholder": "描述问题或建议…", + "emailLabel": "联系邮箱(可选)", + "emailPlaceholder": "you@example.com", + "emailHint": "已登录 Qoderian 时可留空。", + "diagnosticsLabel": "附带工作区诊断信息", + "diagnosticsDesc": "Qoderian 会收集当前 vault 的运行状态,随反馈一起上传,用于定位问题。", + "sessionHint": "将关联当前会话 {sessionId}。", + "submit": "提交反馈", + "submitting": "提交中…", + "signIn": "登录 Qoderian", + "errorCliUnavailable": "未找到 Qoder CLI。请在 Qoderian 设置中配置其路径或先安装,然后重试。", + "errorEmpty": "请先描述问题再提交。", + "errorTooLong": "反馈内容不能超过 {limit} 个字符。", + "errorRejected": "qodercli 未能提交反馈。", + "noticeSuccess": "反馈已提交,感谢!", + "noticeSuccessWithId": "反馈已提交(请求号 {requestId}),感谢!", + "noticeSuccessCopied": "反馈已提交,请求号 {requestId} 已复制到剪贴板。", + "noticeFailed": "反馈未提交成功。" }, "nav": { "newConversation": "新建会话", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 11823f9..e820c4c 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -37,7 +37,29 @@ "editApplied": "已套用編輯", "newTab": "新增分頁", "newSession": "新增工作階段(目前分頁)", - "closeCurrentTab": "關閉目前分頁" + "closeCurrentTab": "關閉目前分頁", + "submitFeedback": "提交回饋" + }, + "feedback": { + "title": "提交回饋", + "placeholder": "描述問題或建議…", + "emailLabel": "聯絡信箱(選填)", + "emailPlaceholder": "you@example.com", + "emailHint": "已登入 Qoderian 時可留空。", + "diagnosticsLabel": "附上工作區診斷資訊", + "diagnosticsDesc": "Qoderian 會收集目前 vault 的執行狀態,隨回饋一起上傳,用於定位問題。", + "sessionHint": "將關聯目前工作階段 {sessionId}。", + "submit": "提交回饋", + "submitting": "提交中…", + "signIn": "登入 Qoderian", + "errorCliUnavailable": "找不到 Qoder CLI。請在 Qoderian 設定中設定其路徑或先安裝,然後重試。", + "errorEmpty": "請先描述問題再提交。", + "errorTooLong": "回饋內容不能超過 {limit} 個字元。", + "errorRejected": "qodercli 未能提交回饋。", + "noticeSuccess": "回饋已提交,感謝!", + "noticeSuccessWithId": "回饋已提交(請求編號 {requestId}),感謝!", + "noticeSuccessCopied": "回饋已提交,請求編號 {requestId} 已複製到剪貼簿。", + "noticeFailed": "回饋未提交成功。" }, "nav": { "newConversation": "新建對話", diff --git a/src/i18n/types.ts b/src/i18n/types.ts index de9c61c..b0b8f82 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -10,6 +10,28 @@ export type TranslationKey = | 'commands.newTab' | 'commands.newSession' | 'commands.closeCurrentTab' + | 'commands.submitFeedback' + + // Feedback submission + | 'feedback.title' + | 'feedback.placeholder' + | 'feedback.emailLabel' + | 'feedback.emailPlaceholder' + | 'feedback.emailHint' + | 'feedback.diagnosticsLabel' + | 'feedback.diagnosticsDesc' + | 'feedback.sessionHint' + | 'feedback.submit' + | 'feedback.submitting' + | 'feedback.signIn' + | 'feedback.errorCliUnavailable' + | 'feedback.errorEmpty' + | 'feedback.errorTooLong' + | 'feedback.errorRejected' + | 'feedback.noticeSuccess' + | 'feedback.noticeSuccessWithId' + | 'feedback.noticeSuccessCopied' + | 'feedback.noticeFailed' // Nav row buttons and tab badges | 'nav.newConversation' diff --git a/src/main.ts b/src/main.ts index 536afd9..c092353 100644 --- a/src/main.ts +++ b/src/main.ts @@ -21,6 +21,7 @@ import { import type { AppTabManagerState } from './core/types/services'; import type { ChatViewPlacement } from './core/types/settings'; 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 { setLocale, t } from './i18n/i18n'; @@ -174,6 +175,24 @@ export default class QoderianPlugin extends Plugin { }, }); + this.addCommand({ + id: 'submit-feedback', + name: t('commands.submitFeedback'), + callback: () => { + const view = this.getView(); + const sessionId = + view?.getTabManager()?.getActiveTab()?.service?.getSessionId() ?? undefined; + void openFeedbackModal(this.app, { + plugin: this, + sessionId, + callerVersion: this.manifest?.version, + onRequestSignIn: () => { + this.qoderServices?.loginService.start(); + }, + }); + }, + }); + this.addSettingTab(new QoderianSettingTab(this.app, this)); } diff --git a/src/qoder/services/submit-user-feedback.ts b/src/qoder/services/submit-user-feedback.ts new file mode 100644 index 0000000..5741a2f --- /dev/null +++ b/src/qoder/services/submit-user-feedback.ts @@ -0,0 +1,96 @@ +import { submitFeedback } from '@qoder-ai/qoder-agent-sdk'; + +import { getEnhancedPath } from '../../core/env/environment'; +import { getVaultPath } from '../../core/fs/path'; +import type { QoderHostContext } from '../qoder-host-context'; + +/** Hard limit enforced by `qodercli feedback --content`. */ +export const FEEDBACK_CONTENT_LIMIT = 2000; + +/** + * Reported as `--ide-type`. The SDK maps `qoder_work`/`quest` to their own + * labels and passes any other string through verbatim, so the backend can + * attribute submissions to this plugin. + */ +const FEEDBACK_IDE_TYPE = 'Qoderian'; + +/** + * Shorter than the SDK's 60s default: `submitFeedback` exposes no abort + * signal, so the modal cannot offer a cancel while this runs. + */ +const FEEDBACK_TIMEOUT_MS = 30_000; + +export interface UserFeedbackDraft { + content: string; + /** When true, the vault path is passed as `--workdir` so the CLI collects workspace state. */ + includeWorkspaceDiagnostics: boolean; + email?: string; + sessionId?: string; + callerVersion?: string; +} + +export type UserFeedbackOutcome = + | { ok: true; message: string; requestId?: string } + | { + ok: false; + reason: 'cliUnavailable' | 'emptyContent' | 'contentTooLong' | 'rejected'; + detail?: string; + }; + +/** Counts Unicode code points so emoji are not charged twice against the CLI limit. */ +export function countFeedbackChars(content: string): number { + return [...content].length; +} + +/** + * Submits user feedback through the SDK's one-shot `qodercli feedback` + * subprocess. + * + * Mirrors `credits-usage.ts`: it reuses the plugin's resolved CLI path and the + * enhanced PATH so a custom CLI location, a node-requiring CLI and the active + * edition all behave exactly like a normal runtime spawn. Returns a reason code + * instead of throwing so callers own the user-facing copy. + */ +export async function submitUserFeedback( + plugin: QoderHostContext, + draft: UserFeedbackDraft, +): Promise { + const cliPath = plugin.getResolvedQoderCliPath(); + if (!cliPath) { + return { ok: false, reason: 'cliUnavailable' }; + } + + const content = draft.content.trim(); + if (!content) { + return { ok: false, reason: 'emptyContent' }; + } + if (countFeedbackChars(content) > FEEDBACK_CONTENT_LIMIT) { + return { ok: false, reason: 'contentTooLong' }; + } + + // `getVaultPath` is null without a filesystem adapter (mobile/web), where the + // CLI cannot collect anything meaningful either. + const workdir = draft.includeWorkspaceDiagnostics ? getVaultPath(plugin.app) : null; + const email = draft.email?.trim(); + + const result = await submitFeedback( + { + content, + ...(workdir ? { workdir } : {}), + ...(draft.sessionId ? { sessionId: draft.sessionId } : {}), + ...(email ? { email } : {}), + ...(draft.callerVersion ? { callerVersion: draft.callerVersion } : {}), + }, + { + cliPath, + env: { ...process.env, PATH: getEnhancedPath(undefined, cliPath) }, + integrationMode: FEEDBACK_IDE_TYPE, + timeout: FEEDBACK_TIMEOUT_MS, + }, + ); + + if (result.success) { + return { ok: true, message: result.message, requestId: result.requestId }; + } + return { ok: false, reason: 'rejected', detail: result.message }; +} diff --git a/src/style/accessibility.css b/src/style/accessibility.css index d16d16c..58a2c9a 100644 --- a/src/style/accessibility.css +++ b/src/style/accessibility.css @@ -5,6 +5,7 @@ .qoderian-thinking-header:focus-visible, .qoderian-subagent-header:focus-visible, .qoderian-input-nav-btn:focus-visible, +.qoderian-header-btn:focus-visible, .qoderian-model-btn:focus-visible, .qoderian-permission-button:focus-visible { outline: 2px solid var(--interactive-accent); diff --git a/src/style/components/header.css b/src/style/components/header.css index f927eab..45523d4 100644 --- a/src/style/components/header.css +++ b/src/style/components/header.css @@ -25,3 +25,28 @@ align-items: center; color: var(--qoderian-brand); } + +.qoderian-header-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.qoderian-header-btn { + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--text-faint); + transition: color 0.15s ease; +} + +.qoderian-header-btn:hover { + color: var(--text-normal); +} + +.qoderian-header-btn svg { + width: 16px; + height: 16px; +} diff --git a/src/style/index.css b/src/style/index.css index 14cb18a..bbd92d0 100644 --- a/src/style/index.css +++ b/src/style/index.css @@ -46,6 +46,7 @@ @import "./modals/instruction.css"; @import "./modals/mcp-modal.css"; @import "./modals/fork-target.css"; +@import "./modals/feedback-modal.css"; /* Settings */ @import "./settings/base.css"; diff --git a/src/style/modals/feedback-modal.css b/src/style/modals/feedback-modal.css new file mode 100644 index 0000000..35004bd --- /dev/null +++ b/src/style/modals/feedback-modal.css @@ -0,0 +1,150 @@ +/* Feedback Modal */ +.qoderian-feedback-modal { + max-width: 520px; +} + +.qoderian-feedback-banner { + margin-bottom: 12px; + padding: 8px 10px; + border-radius: 6px; + background: var(--background-modifier-error); + color: var(--text-on-accent); + font-size: 13px; +} + +.qoderian-feedback-textarea { + width: 100%; + min-height: 120px; + resize: vertical; + padding: 8px 10px; + border-radius: 6px; + border: 1px solid var(--background-modifier-border); + background: var(--background-primary); + color: var(--text-normal); + font-family: inherit; + font-size: 13px; +} + +.qoderian-feedback-counter { + margin-top: 4px; + text-align: right; + color: var(--text-faint); + font-size: 12px; + font-variant-numeric: tabular-nums; +} + +.qoderian-feedback-counter--over { + color: var(--text-error); +} + +.qoderian-feedback-field { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 14px; +} + +.qoderian-feedback-label { + color: var(--text-normal); + font-size: 13px; + font-weight: 500; +} + +.qoderian-feedback-input { + padding: 6px 10px; + border-radius: 6px; + border: 1px solid var(--background-modifier-border); + background: var(--background-primary); + color: var(--text-normal); + font-size: 13px; +} + +.qoderian-feedback-hint { + color: var(--text-faint); + font-size: 12px; +} + +.qoderian-feedback-diagnostics { + display: flex; + align-items: flex-start; + gap: 8px; + margin-top: 14px; +} + +.qoderian-feedback-checkbox { + margin-top: 2px; + flex-shrink: 0; +} + +.qoderian-feedback-diagnostics-text { + display: flex; + flex-direction: column; + gap: 2px; +} + +.qoderian-feedback-session { + margin-top: 14px; + color: var(--text-faint); + font-size: 12px; +} + +.qoderian-feedback-error { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + margin-top: 14px; + padding: 8px 10px; + border-radius: 6px; + border: 1px solid var(--background-modifier-error); +} + +.qoderian-feedback-error-message { + color: var(--text-error); + font-size: 13px; + white-space: pre-wrap; + word-break: break-word; +} + +.qoderian-feedback-signin { + padding: 0; + border: none; + background: none; + color: var(--text-accent); + font-size: 13px; + cursor: pointer; +} + +.qoderian-feedback-signin:hover { + text-decoration: underline; +} + +.qoderian-feedback-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 18px; + padding-top: 14px; + border-top: 1px solid var(--background-modifier-border); +} + +.qoderian-feedback-button { + padding: 6px 14px; + border-radius: 6px; + border: 1px solid var(--background-modifier-border); + background: var(--background-secondary); + color: var(--text-normal); + font-size: 13px; + cursor: pointer; +} + +.qoderian-feedback-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.qoderian-feedback-button--primary { + border-color: var(--interactive-accent); + background: var(--interactive-accent); + color: var(--text-on-accent); +} diff --git a/tests/__mocks__/qoder-agent-sdk.ts b/tests/__mocks__/qoder-agent-sdk.ts index abf6545..23dc8a0 100644 --- a/tests/__mocks__/qoder-agent-sdk.ts +++ b/tests/__mocks__/qoder-agent-sdk.ts @@ -146,6 +146,13 @@ let shouldThrowOnIteration = false; let throwAfterChunks = 0; let queryCallCount = 0; +// submitFeedback control +let mockFeedbackResult: { success: boolean; message: string; requestId?: string } = { + success: true, + message: 'Feedback submitted successfully', +}; +let lastFeedbackCall: { params: any; options: any } | undefined; + // Allow tests to set custom mock messages export function setMockMessages(messages: any[], options?: { appendResult?: boolean }) { customMockMessages = messages; @@ -164,6 +171,8 @@ export function resetMockMessages() { shouldThrowOnIteration = false; throwAfterChunks = 0; queryCallCount = 0; + mockFeedbackResult = { success: true, message: 'Feedback submitted successfully' }; + lastFeedbackCall = undefined; } export function setMockSupportedCommands( @@ -209,6 +218,27 @@ export function getLastResponse(): typeof lastResponse { return lastResponse; } +/** Stubs what `qodercli feedback` reports back; the real SDK never throws. */ +export function setMockFeedbackResult(result: { + success: boolean; + message: string; + requestId?: string; +}) { + mockFeedbackResult = result; +} + +export function getLastFeedbackCall(): { params: any; options: any } | undefined { + return lastFeedbackCall; +} + +export async function submitFeedback( + params: any, + options?: any +): Promise<{ success: boolean; message: string; requestId?: string }> { + lastFeedbackCall = { params, options }; + return { ...mockFeedbackResult }; +} + // Helper to run PreToolUse hooks async function runPreToolUseHooks( hooks: HookCallbackMatcher[] | undefined, diff --git a/tests/unit/features/feedback/ui/feedback-modal.test.ts b/tests/unit/features/feedback/ui/feedback-modal.test.ts new file mode 100644 index 0000000..c3efdc0 --- /dev/null +++ b/tests/unit/features/feedback/ui/feedback-modal.test.ts @@ -0,0 +1,258 @@ +import * as sdkModule from '@qoder-ai/qoder-agent-sdk'; +import { createMockEl } from '@test/helpers/mock-element'; +import { Notice } from 'obsidian'; + +import { openFeedbackModal } from '@/features/feedback/ui/feedback-modal'; +import type { QoderHostContext } from '@/qoder/qoder-host-context'; + +const sdkMock = sdkModule as unknown as { + resetMockMessages: () => void; + setMockFeedbackResult: (result: { + success: boolean; + message: string; + requestId?: string; + }) => void; + getLastFeedbackCall: () => { params: any; options: any } | undefined; +}; + +let lastModalInstance: any; + +jest.mock('obsidian', () => { + const actual = jest.requireActual('obsidian'); + + class MockModal { + app: any; + modalEl: any = { addClass: jest.fn() }; + contentEl: any; + + constructor(app: any) { + this.app = app; + this.contentEl = createMockEl(); + // eslint-disable-next-line @typescript-eslint/no-this-alias + lastModalInstance = this; + } + + setTitle = jest.fn(); + + open() { + this.onOpen(); + } + + close() { + this.onClose(); + } + + onOpen() { + // Overridden by subclass + } + + onClose() { + // Overridden by subclass + } + } + + return { + ...actual, + Modal: MockModal, + }; +}); + +const mockApp = {} as any; + +interface TestContext { + plugin: QoderHostContext; + sessionId: string; + callerVersion: string; + onRequestSignIn?: () => void; +} + +function createContext( + overrides: Partial & { cliPath?: string | null } = {}, +): TestContext { + const { cliPath = '/mock/qoder', ...rest } = overrides; + const plugin = { + app: { vault: { adapter: { basePath: '/test/vault' } } }, + settings: {}, + getResolvedQoderCliPath: jest.fn().mockReturnValue(cliPath), + } as unknown as QoderHostContext; + return { plugin, sessionId: 'abcdef123456', callerVersion: '1.0.7', ...rest }; +} + +function find(cls: string): any { + return lastModalInstance.contentEl.querySelector(`.${cls}`); +} + +function typeText(text: string): void { + const textarea = find('qoderian-feedback-textarea'); + textarea.value = text; + textarea.dispatchEvent('input'); +} + +function click(cls: string): void { + find(cls).click(); +} + +async function flush(): Promise { + await new Promise(resolve => setTimeout(resolve, 0)); +} + +const originalClipboard = (navigator as { clipboard?: unknown }).clipboard; + +function mockClipboardWriteText(writeText: jest.Mock): jest.Mock { + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }); + return writeText; +} + +beforeEach(() => { + lastModalInstance = null; + sdkMock.resetMockMessages(); + (Notice as unknown as jest.Mock).mockClear(); +}); + +afterEach(() => { + Object.defineProperty(navigator, 'clipboard', { + value: originalClipboard, + configurable: true, + }); +}); + +describe('FeedbackModal', () => { + it('resolves null when closed without submitting', async () => { + const result = openFeedbackModal(mockApp, createContext()); + lastModalInstance.close(); + + expect(await result).toBeNull(); + }); + + it('disables submit until content is typed and shows the session hint', () => { + openFeedbackModal(mockApp, createContext()); + + expect(find('qoderian-feedback-button--primary').disabled).toBe(true); + expect(find('qoderian-feedback-session').textContent).toContain('abcdef12'); + + typeText('Something is broken.'); + + expect(find('qoderian-feedback-button--primary').disabled).toBe(false); + expect(find('qoderian-feedback-counter').textContent).toBe('20 / 2000'); + }); + + it('blocks over-limit input instead of truncating it', () => { + openFeedbackModal(mockApp, createContext()); + + typeText('a'.repeat(2001)); + + expect(find('qoderian-feedback-counter').textContent).toBe('2001 / 2000'); + expect(find('qoderian-feedback-counter').hasClass('qoderian-feedback-counter--over')) + .toBe(true); + expect(find('qoderian-feedback-button--primary').disabled).toBe(true); + }); + + it('submits the draft and reports success through a Notice', async () => { + const result = openFeedbackModal(mockApp, createContext()); + typeText('Something is broken.'); + click('qoderian-feedback-button--primary'); + await flush(); + + expect(await result).toMatchObject({ ok: true }); + expect(sdkMock.getLastFeedbackCall()?.params).toMatchObject({ + content: 'Something is broken.', + workdir: '/test/vault', + sessionId: 'abcdef123456', + callerVersion: '1.0.7', + }); + expect(Notice).toHaveBeenCalledWith('Feedback submitted. Thank you!'); + }); + + it('copies the request id to the clipboard when the CLI returns one', async () => { + const writeText = mockClipboardWriteText(jest.fn().mockResolvedValue(undefined)); + sdkMock.setMockFeedbackResult({ success: true, message: 'ok', requestId: 'req-42' }); + + openFeedbackModal(mockApp, createContext()); + typeText('Something is broken.'); + click('qoderian-feedback-button--primary'); + await flush(); + + expect(writeText).toHaveBeenCalledWith('req-42'); + expect(Notice).toHaveBeenCalledWith( + 'Feedback submitted. Request req-42 copied to your clipboard.', + ); + }); + + it('still reports the request id when the clipboard write fails', async () => { + mockClipboardWriteText(jest.fn().mockRejectedValue(new Error('denied'))); + sdkMock.setMockFeedbackResult({ success: true, message: 'ok', requestId: 'req-42' }); + + openFeedbackModal(mockApp, createContext()); + typeText('Something is broken.'); + click('qoderian-feedback-button--primary'); + await flush(); + + expect(Notice).toHaveBeenCalledWith('Feedback submitted (request req-42). Thank you!'); + }); + + it('keeps the modal and the typed text when the CLI rejects the submission', async () => { + sdkMock.setMockFeedbackResult({ success: false, message: 'not logged in' }); + const onRequestSignIn = jest.fn(); + + const result = openFeedbackModal(mockApp, createContext({ onRequestSignIn })); + typeText('Something is broken.'); + click('qoderian-feedback-button--primary'); + await flush(); + + let settled = false; + void result.then(() => { + settled = true; + }); + await flush(); + expect(settled).toBe(false); + + expect(find('qoderian-feedback-error').hasClass('qoderian-hidden')).toBe(false); + expect(find('qoderian-feedback-error-message').textContent).toBe('not logged in'); + expect(find('qoderian-feedback-textarea').value).toBe('Something is broken.'); + + click('qoderian-feedback-signin'); + expect(onRequestSignIn).toHaveBeenCalled(); + expect(await result).toBeNull(); + }); + + it('does not offer sign-in when the failure is not auth-related', async () => { + sdkMock.setMockFeedbackResult({ + success: false, + message: "error: unknown option '--storage-dir'", + }); + const onRequestSignIn = jest.fn(); + + const result = openFeedbackModal(mockApp, createContext({ onRequestSignIn })); + typeText('Something is broken.'); + click('qoderian-feedback-button--primary'); + await flush(); + + expect(find('qoderian-feedback-error-message').textContent) + .toBe("error: unknown option '--storage-dir'"); + expect(find('qoderian-feedback-signin')).toBeNull(); + + lastModalInstance.close(); + expect(await result).toBeNull(); + }); + + it('shows a banner and keeps submit disabled when no CLI is resolved', async () => { + const result = openFeedbackModal(mockApp, createContext({ cliPath: null })); + + expect(find('qoderian-feedback-banner')).not.toBeNull(); + + typeText('Something is broken.'); + expect(find('qoderian-feedback-button--primary').disabled).toBe(true); + + lastModalInstance.close(); + expect(await result).toBeNull(); + }); + + it('resolves null when the cancel button is used', async () => { + const result = openFeedbackModal(mockApp, createContext()); + typeText('Something is broken.'); + click('qoderian-feedback-button'); + + expect(await result).toBeNull(); + expect(sdkMock.getLastFeedbackCall()).toBeUndefined(); + }); +}); diff --git a/tests/unit/i18n/locales.test.ts b/tests/unit/i18n/locales.test.ts index dacee25..576b0e8 100644 --- a/tests/unit/i18n/locales.test.ts +++ b/tests/unit/i18n/locales.test.ts @@ -92,6 +92,25 @@ const localizedKeys = [ 'chat.permissionMode.changeFailed', 'chat.slashCommand.requiresInteractiveTerminal', 'composer.resize', + 'commands.submitFeedback', + 'feedback.title', + 'feedback.placeholder', + 'feedback.emailLabel', + 'feedback.emailHint', + 'feedback.diagnosticsLabel', + 'feedback.diagnosticsDesc', + 'feedback.sessionHint', + 'feedback.submit', + 'feedback.submitting', + 'feedback.signIn', + 'feedback.errorCliUnavailable', + 'feedback.errorEmpty', + 'feedback.errorTooLong', + 'feedback.errorRejected', + 'feedback.noticeSuccess', + 'feedback.noticeSuccessWithId', + 'feedback.noticeSuccessCopied', + 'feedback.noticeFailed', ] as const; const staleBangBashDesc = diff --git a/tests/unit/qoder/services/submit-user-feedback.test.ts b/tests/unit/qoder/services/submit-user-feedback.test.ts new file mode 100644 index 0000000..bda4965 --- /dev/null +++ b/tests/unit/qoder/services/submit-user-feedback.test.ts @@ -0,0 +1,123 @@ +import * as sdkModule from '@qoder-ai/qoder-agent-sdk'; + +import type QoderianPlugin from '@/main'; +import { + countFeedbackChars, + FEEDBACK_CONTENT_LIMIT, + submitUserFeedback, +} from '@/qoder/services/submit-user-feedback'; + +const sdkMock = sdkModule as unknown as { + resetMockMessages: () => void; + setMockFeedbackResult: (result: { + success: boolean; + message: string; + requestId?: string; + }) => void; + getLastFeedbackCall: () => { params: any; options: any } | undefined; +}; + +jest.mock('@/core/fs/path', () => ({ + getVaultPath: jest.fn().mockReturnValue('/test/vault'), +})); + +jest.mock('@/core/env/environment', () => ({ + getEnhancedPath: jest.fn().mockReturnValue('/usr/bin:/mock/bin'), +})); + +function createMockPlugin(cliPath: string | null = '/mock/qoder'): QoderianPlugin { + return { + app: {}, + settings: {}, + getResolvedQoderCliPath: jest.fn().mockReturnValue(cliPath), + } as unknown as QoderianPlugin; +} + +function draft(overrides: Record = {}) { + return { + content: 'The rewind button loses my draft.', + includeWorkspaceDiagnostics: true, + ...overrides, + } as Parameters[1]; +} + +describe('submitUserFeedback', () => { + beforeEach(() => { + sdkMock.resetMockMessages(); + }); + + it('reports cliUnavailable without spawning the CLI', async () => { + const outcome = await submitUserFeedback(createMockPlugin(null), draft()); + + expect(outcome).toEqual({ ok: false, reason: 'cliUnavailable' }); + expect(sdkMock.getLastFeedbackCall()).toBeUndefined(); + }); + + it('maps the draft onto the qodercli feedback arguments', async () => { + const outcome = await submitUserFeedback(createMockPlugin(), draft({ + email: ' dev@example.com ', + sessionId: 'session-abc', + callerVersion: '1.0.7', + })); + + expect(outcome.ok).toBe(true); + expect(sdkMock.getLastFeedbackCall()?.params).toEqual({ + content: 'The rewind button loses my draft.', + workdir: '/test/vault', + sessionId: 'session-abc', + email: 'dev@example.com', + callerVersion: '1.0.7', + }); + }); + + it('omits workdir when diagnostics are declined', async () => { + await submitUserFeedback( + createMockPlugin(), + draft({ includeWorkspaceDiagnostics: false }), + ); + + expect(sdkMock.getLastFeedbackCall()?.params.workdir).toBeUndefined(); + }); + + it('spawns with the resolved CLI, enhanced PATH and the Qoderian ide type', async () => { + await submitUserFeedback(createMockPlugin(), draft()); + + const options = sdkMock.getLastFeedbackCall()?.options; + expect(options.cliPath).toBe('/mock/qoder'); + expect(options.env.PATH).toBe('/usr/bin:/mock/bin'); + expect(options.integrationMode).toBe('Qoderian'); + expect(options.timeout).toBe(30_000); + // `qodercli feedback` rejects `--storage-dir`, so it must never be forwarded. + expect(options.storageDir).toBeUndefined(); + }); + + it('rejects empty and over-limit content before spawning', async () => { + expect(await submitUserFeedback(createMockPlugin(), draft({ content: ' ' }))) + .toEqual({ ok: false, reason: 'emptyContent' }); + expect(await submitUserFeedback(createMockPlugin(), draft({ + content: 'a'.repeat(FEEDBACK_CONTENT_LIMIT + 1), + }))).toEqual({ ok: false, reason: 'contentTooLong' }); + expect(sdkMock.getLastFeedbackCall()).toBeUndefined(); + }); + + it('counts emoji as single characters against the CLI limit', () => { + expect(countFeedbackChars('😀'.repeat(FEEDBACK_CONTENT_LIMIT))) + .toBe(FEEDBACK_CONTENT_LIMIT); + }); + + it('surfaces the CLI message when the submission is rejected', async () => { + sdkMock.setMockFeedbackResult({ success: false, message: 'not logged in' }); + + const outcome = await submitUserFeedback(createMockPlugin(), draft()); + + expect(outcome).toEqual({ ok: false, reason: 'rejected', detail: 'not logged in' }); + }); + + it('passes the request id through on success', async () => { + sdkMock.setMockFeedbackResult({ success: true, message: 'ok', requestId: 'req-1' }); + + const outcome = await submitUserFeedback(createMockPlugin(), draft()); + + expect(outcome).toEqual({ ok: true, message: 'ok', requestId: 'req-1' }); + }); +});