Skip to content

Commit 29f71a6

Browse files
feat: implement M4 settings and recovery flow (#4)
1 parent 321d514 commit 29f71a6

File tree

8 files changed

+470
-1
lines changed

8 files changed

+470
-1
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@ 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`
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`
1616
- 로컬 세션/상태 저장 및 pending change 적용 기본 흐름 구현
1717
- M3 컨텍스트/변경 흐름 확장: 명시적 추가 컨텍스트 노트 병합, 변경 전 diff 프리뷰, discard/undo 적용
1818
- M1 인증 실연동 경로(Desktop): `gh auth status`/`gh copilot status` 기반 로그인/권한 상태 probe + 수동 재검증
19+
- M4 설정/복구 흐름: 사이드바 내 설정 패널(모델/컨텍스트 정책/쓰기 정책/디버그/재시도) + 실패 프롬프트 재시도
1920
- 상세 요구사항: `docs/requirements.md`
2021
- 아키텍처 초안: `docs/architecture.md`
2122
- 구현 마일스톤: `docs/milestones.md`

docs/atomic-task-plan.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,12 @@
100100
| M1-C02 | entitlement/no-entitlement 상태 매핑 | Copilot CLI | `npm run smoke:run && npm run smoke:assert` |
101101
| M1-C03 | 수동 재검증 명령/버튼 연계 | Copilot CLI | `npm run verify:e2e` |
102102
| M1-CL01 | M1 변경의 Cloud 검증 실행 | Cloud Agent | `npm run cloud:dispatch``npm run cloud:status`에서 최신 run 확인 |
103+
104+
## 11) Iteration 6 (M4 Settings + Recovery) 템플릿
105+
106+
| ID | 목표 | 담당 | 독립 검증 기준 |
107+
|---|---|---|---|
108+
| M4-C01 | 사이드바 설정 패널(모델/컨텍스트 정책/쓰기 정책) 구현 | Copilot CLI | `npm run check && npm run build` |
109+
| M4-C02 | 쓰기 적용 정책(confirm/auto) 반영 | Copilot CLI | `npm run smoke:run && npm run smoke:assert` |
110+
| M4-C03 | 실패 프롬프트 저장/재시도 명령 및 UI 연계 | Copilot CLI | `npm run verify:e2e` |
111+
| M4-CL01 | M4 변경의 Cloud 검증 실행 | Cloud Agent | `npm run cloud:dispatch``npm run cloud:status`에서 최신 run 확인 |

docs/requirements.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@
5858
- `Start new chat session`
5959
- `Undo last applied change`
6060
- `Refresh auth status`
61+
- `Open sidebar settings panel`
62+
- `Retry last failed prompt`
6163

6264
### FR-006 세션/설정 저장
6365

@@ -69,6 +71,7 @@
6971

7072
- 네트워크/인증/권한 오류를 사용자 친화 메시지로 표기
7173
- 재시도, 재인증 경로 제공
74+
- 인증 실패 상태에서 실패 프롬프트를 저장하고 재시도 명령으로 복구할 수 있어야 한다.
7275

7376
## 6. 비기능 요구사항
7477

main.js

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ var MAX_PENDING_CHANGES = 10;
3030
var MAX_ADDITIONAL_CONTEXT = 8;
3131
var STREAM_DELAY_MS = 30;
3232
var PREVIEW_MAX_LINES = 120;
33+
var RECOMMENDED_MODELS = ["gpt-5.3-codex", "gpt-4.1", "gpt-4o-mini"];
3334
var AUTH_ORDER = ["logged-in", "no-entitlement", "token-expired", "offline"];
3435
function createId(prefix) {
3536
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
@@ -57,6 +58,9 @@ function defaultSettings() {
5758
authCheckedAt: Date.now(),
5859
model: "gpt-5.3-codex",
5960
contextPolicy: "selection-first",
61+
changeApplyPolicy: "confirm-write",
62+
retryFailedPrompt: true,
63+
lastFailedPrompt: "",
6064
debugLogging: false
6165
};
6266
}
@@ -66,6 +70,9 @@ function isAuthState(value) {
6670
function isContextPolicy(value) {
6771
return value === "selection-first" || value === "note-only";
6872
}
73+
function isChangeApplyPolicy(value) {
74+
return value === "confirm-write" || value === "auto-apply";
75+
}
6976
function isMessageRole(value) {
7077
return value === "user" || value === "assistant" || value === "system";
7178
}
@@ -175,6 +182,9 @@ function normalizeSettings(raw) {
175182
authCheckedAt: typeof source.authCheckedAt === "number" ? source.authCheckedAt : fallback.authCheckedAt,
176183
model: typeof source.model === "string" && source.model.trim().length > 0 ? source.model : fallback.model,
177184
contextPolicy: isContextPolicy(source.contextPolicy) ? source.contextPolicy : fallback.contextPolicy,
185+
changeApplyPolicy: isChangeApplyPolicy(source.changeApplyPolicy) ? source.changeApplyPolicy : fallback.changeApplyPolicy,
186+
retryFailedPrompt: typeof source.retryFailedPrompt === "boolean" ? source.retryFailedPrompt : fallback.retryFailedPrompt,
187+
lastFailedPrompt: typeof source.lastFailedPrompt === "string" ? source.lastFailedPrompt : fallback.lastFailedPrompt,
178188
debugLogging: Boolean(source.debugLogging)
179189
};
180190
}
@@ -220,10 +230,16 @@ var CopilotSidebarView = class extends import_obsidian.ItemView {
220230
text: "Refresh Auth",
221231
cls: "copilot-button"
222232
});
233+
const retryFailedButton = controlRow.createEl("button", {
234+
text: "Retry Failed",
235+
cls: "copilot-button"
236+
});
223237
const layout = root.createDiv({ cls: "copilot-sidebar-layout" });
224238
const leftPane = layout.createDiv({ cls: "copilot-sidebar-pane copilot-sidebar-pane-sessions" });
225239
leftPane.createDiv({ text: "Sessions", cls: "copilot-pane-title" });
226240
const sessionList = leftPane.createDiv({ cls: "copilot-session-list" });
241+
leftPane.createDiv({ text: "Settings", cls: "copilot-pane-title" });
242+
const settingsPanel = leftPane.createDiv({ cls: "copilot-settings-panel" });
227243
leftPane.createDiv({ text: "Context Notes", cls: "copilot-pane-title" });
228244
const contextList = leftPane.createDiv({ cls: "copilot-context-list" });
229245
leftPane.createDiv({ text: "Pending Changes", cls: "copilot-pane-title" });
@@ -255,6 +271,9 @@ var CopilotSidebarView = class extends import_obsidian.ItemView {
255271
refreshAuthButton.addEventListener("click", () => {
256272
void this.plugin.refreshAuthStatus("manual");
257273
});
274+
retryFailedButton.addEventListener("click", () => {
275+
void this.plugin.retryLastFailedPrompt();
276+
});
258277
composerButton.addEventListener("click", () => {
259278
void this.submitComposer();
260279
});
@@ -269,6 +288,7 @@ var CopilotSidebarView = class extends import_obsidian.ItemView {
269288
authBadge,
270289
authMeta: authDetail,
271290
sessionList,
291+
settingsPanel,
272292
contextList,
273293
pendingList,
274294
messages,
@@ -295,6 +315,7 @@ var CopilotSidebarView = class extends import_obsidian.ItemView {
295315
const compactDetail = snapshot.authDetail.length > 180 ? `${snapshot.authDetail.slice(0, 177)}...` : snapshot.authDetail;
296316
this.elements.authMeta.setText(`${compactDetail} | checked ${checkedAt}`);
297317
this.renderSessions(snapshot);
318+
this.renderSettings(snapshot);
298319
this.renderContextNotes(snapshot);
299320
this.renderPendingChanges(snapshot);
300321
this.renderMessages(activeSession);
@@ -328,6 +349,81 @@ var CopilotSidebarView = class extends import_obsidian.ItemView {
328349
});
329350
}
330351
}
352+
renderSettings(snapshot) {
353+
if (!this.elements) {
354+
return;
355+
}
356+
const panel = this.elements.settingsPanel;
357+
panel.empty();
358+
const modelRow = panel.createDiv({ cls: "copilot-setting-row" });
359+
modelRow.createDiv({ text: "Model", cls: "copilot-setting-label" });
360+
const modelSelect = modelRow.createEl("select", { cls: "copilot-setting-select" });
361+
for (const model of RECOMMENDED_MODELS) {
362+
const option = modelSelect.createEl("option");
363+
option.value = model;
364+
option.text = model;
365+
}
366+
if (!RECOMMENDED_MODELS.includes(snapshot.model)) {
367+
const customOption = modelSelect.createEl("option");
368+
customOption.value = snapshot.model;
369+
customOption.text = `${snapshot.model} (custom)`;
370+
}
371+
modelSelect.value = snapshot.model;
372+
modelSelect.addEventListener("change", () => {
373+
void this.plugin.updateModel(modelSelect.value);
374+
});
375+
const contextRow = panel.createDiv({ cls: "copilot-setting-row" });
376+
contextRow.createDiv({ text: "Context Policy", cls: "copilot-setting-label" });
377+
const contextSelect = contextRow.createEl("select", { cls: "copilot-setting-select" });
378+
const selectionOption = contextSelect.createEl("option");
379+
selectionOption.value = "selection-first";
380+
selectionOption.text = "Selection First";
381+
const noteOption = contextSelect.createEl("option");
382+
noteOption.value = "note-only";
383+
noteOption.text = "Note Only";
384+
contextSelect.value = snapshot.contextPolicy;
385+
contextSelect.addEventListener("change", () => {
386+
const nextPolicy = contextSelect.value === "note-only" ? "note-only" : "selection-first";
387+
void this.plugin.updateContextPolicy(nextPolicy);
388+
});
389+
const applyPolicyRow = panel.createDiv({ cls: "copilot-setting-row" });
390+
applyPolicyRow.createDiv({ text: "Write Policy", cls: "copilot-setting-label" });
391+
const applyPolicySelect = applyPolicyRow.createEl("select", { cls: "copilot-setting-select" });
392+
const confirmOption = applyPolicySelect.createEl("option");
393+
confirmOption.value = "confirm-write";
394+
confirmOption.text = "Confirm Before Apply";
395+
const autoOption = applyPolicySelect.createEl("option");
396+
autoOption.value = "auto-apply";
397+
autoOption.text = "Auto Apply";
398+
applyPolicySelect.value = snapshot.changeApplyPolicy;
399+
applyPolicySelect.addEventListener("change", () => {
400+
const nextPolicy = applyPolicySelect.value === "auto-apply" ? "auto-apply" : "confirm-write";
401+
void this.plugin.updateChangeApplyPolicy(nextPolicy);
402+
});
403+
const debugRow = panel.createDiv({ cls: "copilot-setting-row" });
404+
const debugToggle = debugRow.createEl("input", {
405+
cls: "copilot-setting-checkbox",
406+
attr: { type: "checkbox" }
407+
});
408+
debugToggle.checked = snapshot.debugLogging;
409+
debugRow.createDiv({ text: "Debug logging", cls: "copilot-setting-label" });
410+
debugToggle.addEventListener("change", () => {
411+
void this.plugin.updateDebugLogging(debugToggle.checked);
412+
});
413+
const retryRow = panel.createDiv({ cls: "copilot-setting-row" });
414+
const retryToggle = retryRow.createEl("input", {
415+
cls: "copilot-setting-checkbox",
416+
attr: { type: "checkbox" }
417+
});
418+
retryToggle.checked = snapshot.retryFailedPrompt;
419+
retryRow.createDiv({ text: "Enable failed prompt retry", cls: "copilot-setting-label" });
420+
retryToggle.addEventListener("change", () => {
421+
void this.plugin.updateRetryFailedPrompt(retryToggle.checked);
422+
});
423+
const failedPrompt = snapshot.lastFailedPrompt.trim();
424+
const failedText = failedPrompt.length > 0 ? failedPrompt.slice(0, 120) : "None";
425+
panel.createDiv({ text: `Last failed prompt: ${failedText}`, cls: "copilot-setting-hint" });
426+
}
331427
renderPendingChanges(snapshot) {
332428
if (!this.elements) {
333429
return;
@@ -515,6 +611,21 @@ var CopilotSidebarPlugin = class extends import_obsidian.Plugin {
515611
await this.undoLastAppliedChange();
516612
}
517613
});
614+
this.addCommand({
615+
id: "open-sidebar-settings-panel",
616+
name: "Open sidebar settings panel",
617+
callback: async () => {
618+
await this.activateView();
619+
new import_obsidian.Notice("Open the Copilot Sidebar and use the Settings section.");
620+
}
621+
});
622+
this.addCommand({
623+
id: "retry-last-failed-prompt",
624+
name: "Retry last failed prompt",
625+
callback: async () => {
626+
await this.retryLastFailedPrompt();
627+
}
628+
});
518629
this.addCommand({
519630
id: "refresh-auth-status",
520631
name: "Refresh auth status",
@@ -553,6 +664,11 @@ var CopilotSidebarPlugin = class extends import_obsidian.Plugin {
553664
authDetail: this.settings.authDetail,
554665
authCheckedAt: this.settings.authCheckedAt,
555666
model: this.settings.model,
667+
contextPolicy: this.settings.contextPolicy,
668+
changeApplyPolicy: this.settings.changeApplyPolicy,
669+
retryFailedPrompt: this.settings.retryFailedPrompt,
670+
lastFailedPrompt: this.settings.lastFailedPrompt,
671+
debugLogging: this.settings.debugLogging,
556672
isStreaming: this.streaming
557673
};
558674
}
@@ -566,6 +682,30 @@ var CopilotSidebarPlugin = class extends import_obsidian.Plugin {
566682
this.settings.activeSessionId = sessionId;
567683
await this.persistAndRender();
568684
}
685+
async updateModel(model) {
686+
const trimmed = model.trim();
687+
if (!trimmed) {
688+
return;
689+
}
690+
this.settings.model = trimmed;
691+
await this.persistAndRender();
692+
}
693+
async updateContextPolicy(contextPolicy) {
694+
this.settings.contextPolicy = contextPolicy;
695+
await this.persistAndRender();
696+
}
697+
async updateChangeApplyPolicy(changeApplyPolicy) {
698+
this.settings.changeApplyPolicy = changeApplyPolicy;
699+
await this.persistAndRender();
700+
}
701+
async updateRetryFailedPrompt(enabled) {
702+
this.settings.retryFailedPrompt = enabled;
703+
await this.persistAndRender();
704+
}
705+
async updateDebugLogging(enabled) {
706+
this.settings.debugLogging = enabled;
707+
await this.persistAndRender();
708+
}
569709
async startNewSession() {
570710
const session = createSession();
571711
this.settings.sessions = [session, ...this.settings.sessions].slice(0, MAX_SESSIONS);
@@ -758,6 +898,13 @@ var CopilotSidebarPlugin = class extends import_obsidian.Plugin {
758898
await this.persistAndRender();
759899
return;
760900
}
901+
if (this.settings.changeApplyPolicy === "confirm-write") {
902+
const accepted = typeof window !== "undefined" ? window.confirm(`Apply pending change to ${change.notePath}?`) : true;
903+
if (!accepted) {
904+
new import_obsidian.Notice("Apply canceled by policy confirmation.");
905+
return;
906+
}
907+
}
761908
await this.app.vault.modify(target, change.after);
762909
this.settings.lastAppliedChange = {
763910
...change,
@@ -805,11 +952,35 @@ var CopilotSidebarPlugin = class extends import_obsidian.Plugin {
805952
}
806953
await this.applyPendingChange(first.id);
807954
}
955+
async retryLastFailedPrompt() {
956+
const lastFailed = this.settings.lastFailedPrompt.trim();
957+
if (!lastFailed) {
958+
new import_obsidian.Notice("No failed prompt to retry.");
959+
return;
960+
}
961+
await this.refreshAuthStatus("manual");
962+
if (this.settings.authState !== "logged-in") {
963+
new import_obsidian.Notice(`Cannot retry while auth is ${this.settings.authState}.`);
964+
return;
965+
}
966+
await this.sendUserMessage(lastFailed);
967+
}
808968
async sendUserMessage(prompt, providedContext) {
809969
const trimmed = prompt.trim();
810970
if (!trimmed) {
811971
return;
812972
}
973+
if (this.settings.retryFailedPrompt && this.settings.authState !== "logged-in") {
974+
this.settings.lastFailedPrompt = trimmed;
975+
}
976+
if (this.settings.debugLogging) {
977+
console.info("[copilot-sidebar] sendUserMessage", {
978+
authState: this.settings.authState,
979+
promptLength: trimmed.length,
980+
contextPolicy: this.settings.contextPolicy,
981+
changeApplyPolicy: this.settings.changeApplyPolicy
982+
});
983+
}
813984
const session = this.ensureActiveSession();
814985
session.messages.push({
815986
id: createId("msg-user"),
@@ -842,6 +1013,9 @@ var CopilotSidebarPlugin = class extends import_obsidian.Plugin {
8421013
assistantMessage.streaming = false;
8431014
session.updatedAt = Date.now();
8441015
this.streaming = false;
1016+
if (this.settings.authState === "logged-in") {
1017+
this.settings.lastFailedPrompt = "";
1018+
}
8451019
await this.maybeCreatePendingChange(trimmed, response, context);
8461020
this.syncSelectedPendingChange();
8471021
await this.persistAndRender();
@@ -947,6 +1121,7 @@ var CopilotSidebarPlugin = class extends import_obsidian.Plugin {
9471121
`Prompt: ${prompt}`,
9481122
`Primary context (${this.settings.contextPolicy}): ${primaryContext}`,
9491123
`Secondary context: ${secondaryContext}`,
1124+
`Write policy: ${this.settings.changeApplyPolicy}`,
9501125
`Merged additional context: ${additionalSummary}`,
9511126
"Suggested next steps:",
9521127
"1. Validate key claims in your note.",
@@ -955,6 +1130,9 @@ var CopilotSidebarPlugin = class extends import_obsidian.Plugin {
9551130
].join(" ");
9561131
}
9571132
async maybeCreatePendingChange(prompt, response, context) {
1133+
if (this.settings.authState !== "logged-in") {
1134+
return;
1135+
}
9581136
if (!context.notePath || !context.noteContent) {
9591137
return;
9601138
}

scripts/smoke-assert.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ const requiredCommands = [
2121
"apply-pending-changes",
2222
"ask-about-current-note",
2323
"open-copilot-sidebar",
24+
"open-sidebar-settings-panel",
2425
"refresh-auth-status",
26+
"retry-last-failed-prompt",
2527
"start-new-chat-session",
2628
"undo-last-applied-change"
2729
];

0 commit comments

Comments
 (0)