Skip to content

fix: dismissing a Yes/No confirmation is an answer, not an error - #1573

Merged
chhoumann merged 2 commits into
masterfrom
chhoumann/1567-yesno-contract
Jul 27, 2026
Merged

fix: dismissing a Yes/No confirmation is an answer, not an error#1573
chhoumann merged 2 commits into
masterfrom
chhoumann/1567-yesno-contract

Conversation

@chhoumann

@chhoumann chhoumann commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Fixes #1567.

The problem behind the issue

GenericYesNoPrompt.Prompt rejected with the bare string "No answer given." when the user dismissed the dialog (Esc, the close button, a click outside). That makes the single most common user action on a confirmation - walking away from it - the exceptional path, and nothing in the signature (Promise<boolean>) says so. The result is a call site that is wrong by default: three of them logged Uncaught (in promise) No answer given. on every cancelled delete, and the next one added would have too.

The issue proposed wrapping the three offenders in try/catch. I don't think that's the fix. The repo already contains three independently hand-written copies of that same "dismiss = no" workaround, added on three different dates, and the bug still existed - the boilerplate was not converging. So this PR fixes the contract instead.

The contract

/** Yes = true, No = false, dismissed = null. Never rejects. */
static Ask(app, header, text?): Promise<boolean | null>

/** Confirmation: only an explicit "Yes" confirms, so a dismissal is "No". Never rejects. */
static async Prompt(app, header, text?): Promise<boolean>

The modal no longer rejects at all. Of the seven call sites, six are confirmations that want dismiss = No; they keep calling Prompt and are now correct by construction, so the hand-rolled guards in choiceService (#1562) and packageExportService are deleted rather than replicated.

Exactly one caller needs the distinction: quickAddApi.yesNoPrompt, the script-facing API. There, answering No is a value the script acts on, while dismissing must abort the macro - otherwise if (await yesNoPrompt(...)) {...} else {...} would run its else-branch when the user meant "get me out of here". That caller uses Ask and maps null to UserCancelError, so the observable script behaviour is unchanged.

"No answer given." is removed from isCancellationError's string list; nothing produces it any more.

Why this shape, and what it doesn't do

  • Why not "always resolve false"? It would silently convert a script's Escape into a false and let the macro carry on. That's a real regression for the one caller that cares.
  • Does it fit the codebase? Yes - AIToolConfirmModal already documents "Dismissing (Esc / click-out) resolves to 'deny'", and ModelDirectoryModal / AIAssistantCommandSettingsModal already resolve null on dismissal. Sentinel-on-dismiss is already the house style for confirmations; GenericYesNoPrompt was the outlier.
  • Scope. The sibling prompts (GenericInputPrompt, GenericSuggester, GenericCheckboxPrompt, …) still reject with bare strings, and that is deliberately left alone: their return types (string, T, string[]) have no natural "cancelled" value, so rejection is a legitimate signal there. GenericYesNoPrompt is genuinely the different one. Follow-ups are filed rather than folded in (listed below).

Validation

All checks below were run in this worktree's own isolated Obsidian 1.13.0 e2e vault, with real DOM clicks and a real Escape keydown, and a window.addEventListener("unhandledrejection", …) listener attached.

Reproduced first, on the pre-fix build - Settings → QuickAdd → Configure a Macro → Delete command → Esc:

before

After - identical UI, identical outcome (the command is kept), no console noise:

after

(The panel is a test overlay printing the real captured unhandledrejection events; the harness's own dev:errors buffer agrees - No answer given. before, No errors captured. after.)

Path Before After
Macro → Delete command → Esc ["No answer given."], command kept [], command kept
AI Assistant → Edit providers → delete → Esc ["No answer given."] []
Delete command → Yes deletes deletes (["Wait"][])
Delete command → No keeps keeps
api.yesNoPrompt → Esc throws MacroAbortError throws MacroAbortError
api.yesNoPrompt → No / Yes false / true false / true
Real macro (UserScript + a second command) → Esc aborts, second command never runs identical
Real macro → No script gets false, macro continues identical

Plus: full suite 4075 passed / 37 skipped, tsc --noEmit clean, eslint clean. The four new dismissal tests were mutation-checked - reverting onClose to throw makes exactly those four fail and nothing else.

Tests

The contract is pinned on the real modal (GenericYesNoPrompt.test.ts), not on a mock, so a future refactor can't quietly break it while the suite stays green: Ask resolves true/false/null, Prompt resolves false on dismissal, and neither rejects. quickAddApi.test.ts pins the script-side contract, including that an explicit No is not an abort.

Follow-ups (filed, not folded in)

Summary by CodeRabbit

  • Bug Fixes
    • Dismissing yes/no dialogs now completes safely without causing unexpected promise errors.
    • Explicit “No” responses remain distinct from closing or dismissing a dialog.
    • Quick Add prompts now correctly treat dismissal as user cancellation.
    • Choice deletion confirmations handle dismissed dialogs consistently without logging spurious errors.
    • Overwrite confirmations continue to prevent overwriting when the prompt is dismissed.
  • Tests
    • Expanded coverage for dialog dismissal, cancellation, and error-handling behavior.

GenericYesNoPrompt rejected with the bare string "No answer given." when the
user dismissed it (Esc, close button, click outside). Three call sites awaited
it from handlers that discard the promise, so a normal cancel surfaced as
"Uncaught (in promise) No answer given." in the console.

Make the modal never reject. Prompt() is the confirmation helper - only an
explicit Yes confirms, so a dismissal is simply "no" - and Ask() returns
boolean | null for the one caller that must tell a dismissal from an explicit
No: quickAddApi.yesNoPrompt, where dismissing still aborts the macro with
UserCancelError exactly as before.

Every confirmation call site is now correct by construction, so the hand-rolled
try/catch guards in choiceService (#1562) and packageExportService are gone.

Fixes #1567
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bec6704d-0d62-47fd-b46a-efeddd84a30b

📥 Commits

Reviewing files that changed from the base of the PR and between bf39e2b and 3aced7b.

📒 Files selected for processing (9)
  • src/gui/GenericYesNoPrompt/GenericYesNoPrompt.test.ts
  • src/gui/GenericYesNoPrompt/GenericYesNoPrompt.ts
  • src/quickAddApi.audit-api-prompts.test.ts
  • src/quickAddApi.test.ts
  • src/quickAddApi.ts
  • src/services/choiceService.test.ts
  • src/services/choiceService.ts
  • src/services/packageExportService.ts
  • src/utils/errorUtils.ts

📝 Walkthrough

Walkthrough

GenericYesNoPrompt now resolves dismissal as null for Ask and false for Prompt. QuickAddApi distinguishes dismissal from explicit No, while service callers and tests adopt the resolved-dismissal contract.

Changes

Confirmation dismissal handling

Layer / File(s) Summary
Prompt contract and dismissal resolution
src/gui/GenericYesNoPrompt/GenericYesNoPrompt.ts, src/gui/GenericYesNoPrompt/GenericYesNoPrompt.test.ts
Dismissed modals resolve instead of rejecting; Ask returns null, while Prompt maps dismissal to false.
QuickAdd API cancellation mapping
src/quickAddApi.ts, src/quickAddApi.test.ts, src/quickAddApi.audit-api-prompts.test.ts
yesNoPrompt uses Ask, preserves explicit false, and converts null dismissal into UserCancelError.
Service confirmation callers
src/services/choiceService.ts, src/services/choiceService.test.ts, src/services/packageExportService.ts, src/utils/errorUtils.ts
Deletion and overwrite confirmation flows await the updated prompt behavior, and cancellation classification removes the obsolete rejection message.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant QuickAddApi
  participant GenericYesNoPrompt
  participant ObsidianModal
  Caller->>QuickAddApi: yesNoPrompt()
  QuickAddApi->>GenericYesNoPrompt: Ask()
  GenericYesNoPrompt->>ObsidianModal: Open modal
  ObsidianModal-->>GenericYesNoPrompt: Resolve true, false, or null
  GenericYesNoPrompt-->>QuickAddApi: Return answer
  QuickAddApi-->>Caller: Return boolean or raise cancellation
Loading

Possibly related issues

Possibly related PRs

Poem

A bunny clicks “No” with a hop,
Or closes the prompt at the top.
Null tells where they fled,
False means “No” was said,
No rejected promises to stop!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly reflects the main change: dismissing a Yes/No prompt is treated as a normal answer instead of an error.
Linked Issues check ✅ Passed The PR implements the requested behavior change and updates callers/tests so dismissal no longer rejects.
Out of Scope Changes check ✅ Passed The changes stay focused on Yes/No prompt dismissal handling, related callers, and tests; no clear unrelated feature work appears.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chhoumann/1567-yesno-contract

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chhoumann
chhoumann marked this pull request as ready for review July 27, 2026 06:16
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying quickadd with  Cloudflare Pages  Cloudflare Pages

Latest commit: 3aced7b
Status: ✅  Deploy successful!
Preview URL: https://fc8d0847.quickadd.pages.dev
Branch Preview URL: https://chhoumann-1567-yesno-contrac.quickadd.pages.dev

View logs

@chhoumann
chhoumann merged commit 918ae0b into master Jul 27, 2026
14 checks passed
@chhoumann
chhoumann deleted the chhoumann/1567-yesno-contract branch July 27, 2026 07:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Dismissing a Yes/No confirmation logs an unhandled rejection (3 remaining call sites)

1 participant