Skip to content

Commit a94d8e1

Browse files
feat: add beta feedback capture loop (#6)
1 parent e4aa2fe commit a94d8e1

File tree

7 files changed

+258
-2
lines changed

7 files changed

+258
-2
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,13 @@ GitHub Copilot SDK 기반으로 Obsidian 사이드바에 Copilot CLI 수준의
1212

1313
- 리포지토리/문서/기본 플러그인 스캐폴드 생성 완료
1414
- M2 기본 UX 반영: 사이드바 채팅 뷰, 세션 생성/전환/삭제, 모의 스트리밍 응답
15-
- 명령 팔레트 연동 확장: `Open`, `Ask about current note`, `Apply pending changes`, `Start new chat session`, `Undo last applied change`, `Refresh auth status`, `Open sidebar settings panel`, `Retry last failed prompt`, `Copy diagnostics summary`
15+
- 명령 팔레트 연동 확장: `Open`, `Ask about current note`, `Apply pending changes`, `Start new chat session`, `Undo last applied change`, `Refresh auth status`, `Open sidebar settings panel`, `Retry last failed prompt`, `Copy diagnostics summary`, `Capture beta feedback note`
1616
- 로컬 세션/상태 저장 및 pending change 적용 기본 흐름 구현
1717
- M3 컨텍스트/변경 흐름 확장: 명시적 추가 컨텍스트 노트 병합, 변경 전 diff 프리뷰, discard/undo 적용
1818
- M1 인증 실연동 경로(Desktop): `gh auth status`/`gh copilot status` 기반 로그인/권한 상태 probe + 수동 재검증
1919
- M4 설정/복구 흐름: 사이드바 내 설정 패널(모델/컨텍스트 정책/쓰기 정책/디버그/재시도) + 실패 프롬프트 재시도
2020
- M5 안정화 1차: 스트리밍 렌더 배치 최적화, 진단 메트릭(지연/토큰/렌더 횟수/오류 카테고리), 진단 요약 복사 명령
21+
- M5 안정화 2차: 베타 피드백 노트 자동 생성(`Copilot Sidebar Feedback/*`) 및 최근 대화/진단 자동 첨부
2122
- 상세 요구사항: `docs/requirements.md`
2223
- 아키텍처 초안: `docs/architecture.md`
2324
- 구현 마일스톤: `docs/milestones.md`

docs/atomic-task-plan.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,3 +118,12 @@
118118
| M5-C02 | 진단 메트릭(첫 토큰 지연/응답 시간/토큰/렌더 횟수) 반영 | Copilot CLI | `npm run smoke:run && npm run smoke:assert` |
119119
| M5-C03 | 진단 요약 복사 명령 및 설정 패널 표시 | Copilot CLI | `npm run verify:e2e` |
120120
| M5-CL01 | M5 변경의 Cloud 검증 실행 | Cloud Agent | `npm run cloud:dispatch``npm run cloud:status`에서 최신 run 확인 |
121+
122+
## 13) Iteration 8 (M5 Beta Feedback Loop) 템플릿
123+
124+
| ID | 목표 | 담당 | 독립 검증 기준 |
125+
|---|---|---|---|
126+
| M5B-C01 | 베타 피드백 노트 생성 명령/버튼 추가 | Copilot CLI | `npm run check && npm run build` |
127+
| M5B-C02 | 피드백 노트에 진단/최근 대화 자동 첨부 | Copilot CLI | `npm run smoke:run && npm run smoke:assert` |
128+
| M5B-C03 | 마지막 피드백 노트 경로 표시 및 오류 복구 반영 | Copilot CLI | `npm run verify:e2e` |
129+
| M5B-CL01 | M5B 변경의 Cloud 검증 실행 | Cloud Agent | `npm run cloud:dispatch``npm run cloud:status`에서 최신 run 확인 |

docs/requirements.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
- `Open sidebar settings panel`
6262
- `Retry last failed prompt`
6363
- `Copy diagnostics summary`
64+
- `Capture beta feedback note`
6465

6566
### FR-006 세션/설정 저장
6667

@@ -73,6 +74,7 @@
7374
- 네트워크/인증/권한 오류를 사용자 친화 메시지로 표기
7475
- 재시도, 재인증 경로 제공
7576
- 인증 실패 상태에서 실패 프롬프트를 저장하고 재시도 명령으로 복구할 수 있어야 한다.
77+
- 사용자가 진단/최근 대화 맥락을 포함한 베타 피드백 노트를 생성할 수 있어야 한다.
7678

7779
## 6. 비기능 요구사항
7880

main.js

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ var STREAM_DELAY_MS = 30;
3333
var STREAM_RENDER_INTERVAL_MS = 90;
3434
var STREAM_RENDER_BATCH_TOKENS = 6;
3535
var PREVIEW_MAX_LINES = 120;
36+
var BETA_FEEDBACK_FOLDER = "Copilot Sidebar Feedback";
3637
var RECOMMENDED_MODELS = ["gpt-5.3-codex", "gpt-4.1", "gpt-4o-mini"];
3738
var AUTH_ORDER = ["logged-in", "no-entitlement", "token-expired", "offline"];
3839
function createId(prefix) {
@@ -64,6 +65,7 @@ function defaultSettings() {
6465
changeApplyPolicy: "confirm-write",
6566
retryFailedPrompt: true,
6667
lastFailedPrompt: "",
68+
lastFeedbackNotePath: "",
6769
diagnostics: defaultDiagnostics(),
6870
debugLogging: false
6971
};
@@ -238,6 +240,7 @@ function normalizeSettings(raw) {
238240
changeApplyPolicy: isChangeApplyPolicy(source.changeApplyPolicy) ? source.changeApplyPolicy : fallback.changeApplyPolicy,
239241
retryFailedPrompt: typeof source.retryFailedPrompt === "boolean" ? source.retryFailedPrompt : fallback.retryFailedPrompt,
240242
lastFailedPrompt: typeof source.lastFailedPrompt === "string" ? source.lastFailedPrompt : fallback.lastFailedPrompt,
243+
lastFeedbackNotePath: typeof source.lastFeedbackNotePath === "string" ? source.lastFeedbackNotePath : fallback.lastFeedbackNotePath,
241244
diagnostics,
242245
debugLogging: Boolean(source.debugLogging)
243246
};
@@ -292,6 +295,10 @@ var CopilotSidebarView = class extends import_obsidian.ItemView {
292295
text: "Copy Diagnostics",
293296
cls: "copilot-button"
294297
});
298+
const captureFeedbackButton = controlRow.createEl("button", {
299+
text: "Capture Feedback",
300+
cls: "copilot-button"
301+
});
295302
const layout = root.createDiv({ cls: "copilot-sidebar-layout" });
296303
const leftPane = layout.createDiv({ cls: "copilot-sidebar-pane copilot-sidebar-pane-sessions" });
297304
leftPane.createDiv({ text: "Sessions", cls: "copilot-pane-title" });
@@ -335,6 +342,9 @@ var CopilotSidebarView = class extends import_obsidian.ItemView {
335342
copyDiagnosticsButton.addEventListener("click", () => {
336343
void this.plugin.copyDiagnosticsSummary();
337344
});
345+
captureFeedbackButton.addEventListener("click", () => {
346+
void this.plugin.captureBetaFeedbackNote();
347+
});
338348
composerButton.addEventListener("click", () => {
339349
void this.submitComposer();
340350
});
@@ -484,6 +494,8 @@ var CopilotSidebarView = class extends import_obsidian.ItemView {
484494
const failedPrompt = snapshot.lastFailedPrompt.trim();
485495
const failedText = failedPrompt.length > 0 ? failedPrompt.slice(0, 120) : "None";
486496
panel.createDiv({ text: `Last failed prompt: ${failedText}`, cls: "copilot-setting-hint" });
497+
const feedbackPath = snapshot.lastFeedbackNotePath.trim().length > 0 ? snapshot.lastFeedbackNotePath : "None";
498+
panel.createDiv({ text: `Last feedback note: ${feedbackPath}`, cls: "copilot-setting-hint" });
487499
const diagnostics = snapshot.diagnostics;
488500
const diagnosticsLines = [
489501
`First token: ${formatDurationMs(diagnostics.lastFirstTokenLatencyMs)}`,
@@ -706,6 +718,13 @@ var CopilotSidebarPlugin = class extends import_obsidian.Plugin {
706718
await this.copyDiagnosticsSummary();
707719
}
708720
});
721+
this.addCommand({
722+
id: "capture-beta-feedback-note",
723+
name: "Capture beta feedback note",
724+
callback: async () => {
725+
await this.captureBetaFeedbackNote();
726+
}
727+
});
709728
this.addCommand({
710729
id: "refresh-auth-status",
711730
name: "Refresh auth status",
@@ -748,6 +767,7 @@ var CopilotSidebarPlugin = class extends import_obsidian.Plugin {
748767
changeApplyPolicy: this.settings.changeApplyPolicy,
749768
retryFailedPrompt: this.settings.retryFailedPrompt,
750769
lastFailedPrompt: this.settings.lastFailedPrompt,
770+
lastFeedbackNotePath: this.settings.lastFeedbackNotePath,
751771
diagnostics: { ...this.settings.diagnostics },
752772
debugLogging: this.settings.debugLogging,
753773
isStreaming: this.streaming
@@ -803,6 +823,42 @@ var CopilotSidebarPlugin = class extends import_obsidian.Plugin {
803823
new import_obsidian.Notice("Diagnostics summary ready in console (clipboard unavailable).");
804824
await this.persistAndRender();
805825
}
826+
async captureBetaFeedbackNote() {
827+
const existingFolder = this.app.vault.getAbstractFileByPath(BETA_FEEDBACK_FOLDER);
828+
if (existingFolder instanceof import_obsidian.TFile) {
829+
this.recordError("filesystem", `${BETA_FEEDBACK_FOLDER} exists as a file, not a folder.`);
830+
new import_obsidian.Notice("Cannot capture feedback: target folder path is already a file.");
831+
await this.persistAndRender();
832+
return;
833+
}
834+
if (!existingFolder) {
835+
try {
836+
await this.app.vault.createFolder(BETA_FEEDBACK_FOLDER);
837+
} catch (error) {
838+
const message = error instanceof Error ? error.message : String(error);
839+
if (!containsAny(message, ["already exists"])) {
840+
this.recordError("filesystem", `Failed to create feedback folder: ${message}`);
841+
new import_obsidian.Notice("Cannot capture feedback note. Failed to create folder.");
842+
await this.persistAndRender();
843+
return;
844+
}
845+
}
846+
}
847+
const feedbackPath = `${BETA_FEEDBACK_FOLDER}/${this.createFeedbackFileName()}`;
848+
const content = this.buildBetaFeedbackNoteContent();
849+
try {
850+
const createdFile = await this.app.vault.create(feedbackPath, content);
851+
this.settings.lastFeedbackNotePath = createdFile.path;
852+
this.clearError();
853+
await this.persistAndRender();
854+
new import_obsidian.Notice(`Beta feedback note created: ${createdFile.path}`);
855+
} catch (error) {
856+
const message = error instanceof Error ? error.message : String(error);
857+
this.recordError("filesystem", `Failed to create feedback note: ${message}`);
858+
new import_obsidian.Notice("Cannot capture feedback note. See diagnostics for details.");
859+
await this.persistAndRender();
860+
}
861+
}
806862
async startNewSession() {
807863
const session = createSession();
808864
this.settings.sessions = [session, ...this.settings.sessions].slice(0, MAX_SESSIONS);
@@ -1102,6 +1158,49 @@ var CopilotSidebarPlugin = class extends import_obsidian.Plugin {
11021158
`diagnosticsUpdatedAt=${new Date(d.updatedAt).toISOString()}`
11031159
].join("\n");
11041160
}
1161+
createFeedbackFileName() {
1162+
const now = /* @__PURE__ */ new Date();
1163+
const yyyy = String(now.getFullYear());
1164+
const mm = String(now.getMonth() + 1).padStart(2, "0");
1165+
const dd = String(now.getDate()).padStart(2, "0");
1166+
const hh = String(now.getHours()).padStart(2, "0");
1167+
const min = String(now.getMinutes()).padStart(2, "0");
1168+
const ss = String(now.getSeconds()).padStart(2, "0");
1169+
const ms = String(now.getMilliseconds()).padStart(3, "0");
1170+
return `feedback-${yyyy}${mm}${dd}-${hh}${min}${ss}-${ms}.md`;
1171+
}
1172+
buildBetaFeedbackNoteContent() {
1173+
const session = this.ensureActiveSession();
1174+
const activePath = this.app.workspace.getActiveFile()?.path ?? "<none>";
1175+
const recentMessages = session.messages.slice(-6).map((message) => `- ${message.role}: ${message.content.slice(0, 200)}`).join("\n");
1176+
return [
1177+
"# Copilot Sidebar Beta Feedback",
1178+
"",
1179+
`- Captured at: ${(/* @__PURE__ */ new Date()).toISOString()}`,
1180+
`- Active note: ${activePath}`,
1181+
`- Session: ${session.title} (${session.id})`,
1182+
`- Session message count: ${session.messages.length}`,
1183+
`- Pending changes: ${this.settings.pendingChanges.length}`,
1184+
"",
1185+
"## Feedback",
1186+
"- What did you try?",
1187+
"- What worked well?",
1188+
"- What felt confusing or slow?",
1189+
"- What outcome did you expect?",
1190+
"",
1191+
"## Recent Chat Context",
1192+
recentMessages.length > 0 ? recentMessages : "- <no recent messages>",
1193+
"",
1194+
"## Diagnostics",
1195+
"```text",
1196+
this.buildDiagnosticsSummary(),
1197+
"```",
1198+
"",
1199+
"## Notes",
1200+
"- Add screenshots/log snippets if needed.",
1201+
"- Include reproduction steps for non-deterministic issues."
1202+
].join("\n");
1203+
}
11051204
trimSessionMessages(session) {
11061205
if (session.messages.length <= MAX_MESSAGES_PER_SESSION) {
11071206
return;

scripts/smoke-assert.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ assert.equal(runReport.pass, true, "smoke:run must pass");
2020
const requiredCommands = [
2121
"apply-pending-changes",
2222
"ask-about-current-note",
23+
"capture-beta-feedback-note",
2324
"copy-diagnostics-summary",
2425
"open-copilot-sidebar",
2526
"open-sidebar-settings-panel",

scripts/smoke-run.mjs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,12 +116,16 @@ const events = {
116116
detachedTypes: [],
117117
notices: [],
118118
modifiedFiles: [],
119+
createdFolders: [],
120+
createdFiles: [],
119121
commandIds: []
120122
};
121123

122124
const noteFile = new MockTFile("Inbox.md");
123125
let noteContent = "# Inbox\n\nInitial note text.";
124126
let opened = false;
127+
const createdFolders = new Set();
128+
const createdFiles = new Map([[noteFile.path, noteFile]]);
125129

126130
const leaf = {
127131
async setViewState(state) {
@@ -164,7 +168,23 @@ const app = {
164168
return noteContent;
165169
},
166170
getAbstractFileByPath(filePath) {
167-
return filePath === noteFile.path ? noteFile : null;
171+
if (createdFiles.has(filePath)) {
172+
return createdFiles.get(filePath);
173+
}
174+
if (createdFolders.has(filePath)) {
175+
return { path: filePath };
176+
}
177+
return null;
178+
},
179+
async createFolder(folderPath) {
180+
createdFolders.add(folderPath);
181+
events.createdFolders.push(folderPath);
182+
},
183+
async create(filePath, content) {
184+
const file = new MockTFile(filePath);
185+
createdFiles.set(filePath, file);
186+
events.createdFiles.push({ path: filePath, size: String(content).length });
187+
return file;
168188
},
169189
async modify(file, content) {
170190
noteContent = content;
@@ -214,6 +234,7 @@ try {
214234
const requiredCommands = [
215235
"apply-pending-changes",
216236
"ask-about-current-note",
237+
"capture-beta-feedback-note",
217238
"copy-diagnostics-summary",
218239
"open-copilot-sidebar",
219240
"open-sidebar-settings-panel",
@@ -240,6 +261,7 @@ try {
240261
const openCommand = plugin.__commands.find((command) => command.id === "open-copilot-sidebar");
241262
const startSessionCommand = plugin.__commands.find((command) => command.id === "start-new-chat-session");
242263
const applyCommand = plugin.__commands.find((command) => command.id === "apply-pending-changes");
264+
const captureFeedbackCommand = plugin.__commands.find((command) => command.id === "capture-beta-feedback-note");
243265
const copyDiagnosticsCommand = plugin.__commands.find((command) => command.id === "copy-diagnostics-summary");
244266
const openSettingsCommand = plugin.__commands.find((command) => command.id === "open-sidebar-settings-panel");
245267
const refreshAuthCommand = plugin.__commands.find((command) => command.id === "refresh-auth-status");
@@ -249,6 +271,7 @@ try {
249271
assert.ok(openCommand, "open command should exist");
250272
assert.ok(startSessionCommand, "start session command should exist");
251273
assert.ok(applyCommand, "apply command should exist");
274+
assert.ok(captureFeedbackCommand, "capture feedback command should exist");
252275
assert.ok(copyDiagnosticsCommand, "copy diagnostics command should exist");
253276
assert.ok(openSettingsCommand, "open settings command should exist");
254277
assert.ok(refreshAuthCommand, "refresh auth command should exist");
@@ -259,6 +282,7 @@ try {
259282
await openSettingsCommand.callback();
260283
await startSessionCommand.callback();
261284
await applyCommand.callback();
285+
await captureFeedbackCommand.callback();
262286
await copyDiagnosticsCommand.callback();
263287
await refreshAuthCommand.callback();
264288
await retryFailedPromptCommand.callback();
@@ -269,8 +293,11 @@ try {
269293
assert.equal(events.setViewStateCalls.length, 1, "setViewState should be called once");
270294
assert.ok(events.revealLeafCalls >= 1, "revealLeaf should be called at least once");
271295
assert.ok(events.notices.includes("No pending changes to apply."), "apply command should show empty-state notice");
296+
assert.ok(events.notices.some((notice) => notice.startsWith("Beta feedback note created:")), "capture feedback should create a feedback note");
272297
assert.ok(events.notices.includes("No failed prompt to retry."), "retry command should show empty-state notice");
273298
assert.ok(events.notices.includes("No applied change to undo."), "undo command should show empty-state notice");
299+
assert.ok(events.createdFolders.includes("Copilot Sidebar Feedback"), "feedback folder should be created");
300+
assert.ok(events.createdFiles.length >= 1, "at least one feedback file should be created");
274301

275302
await plugin.onunload();
276303
assert.deepEqual(events.detachedTypes, ["copilot-sidebar-view"], "detachLeavesOfType call mismatch");

0 commit comments

Comments
 (0)