diff --git a/changes/kiku-field-grouping-cancel-double-toast.md b/changes/kiku-field-grouping-cancel-double-toast.md new file mode 100644 index 00000000..b1fd7d75 --- /dev/null +++ b/changes/kiku-field-grouping-cancel-double-toast.md @@ -0,0 +1,4 @@ +type: fixed +area: anki + +- Fixed cancelling the Kiku field grouping dialog showing two "Field grouping cancelled" notifications when grouping was started via the trigger shortcut: the manual workflow already notifies about its outcome (cancelled, UI unavailable, failed), and the trigger path re-notified on top of it. The workflow now owns all outcome notifications, and a previously silent failure (the original card no longer loadable) gets its own message. diff --git a/changes/kiku-field-grouping-modal-recovery.md b/changes/kiku-field-grouping-modal-recovery.md new file mode 100644 index 00000000..9a301e4b --- /dev/null +++ b/changes/kiku-field-grouping-modal-recovery.md @@ -0,0 +1,6 @@ +type: fixed +area: overlay + +- Fixed Kiku manual field grouping freezing the overlay after adding a duplicate card: the field grouping modal now reliably appears above fullscreen mpv on Hyprland/Wayland by re-asserting window placement until the compositor maps the modal window, instead of a single post-show attempt that raced the async map and left the dialog invisible. +- Fixed manual field grouping staying broken after the first attempt: the request resolver is now always cleared once a choice is made or the request is abandoned, so later grouping attempts no longer short-circuit to an instant "Field grouping cancelled". +- Fixed a timed-out or failed field grouping request leaving an orphaned, invisible modal window covering mpv: abandoned requests now tear down the modal window and close the dialog so the overlay recovers immediately. diff --git a/changes/kiku-field-grouping-modal-wiring.md b/changes/kiku-field-grouping-modal-wiring.md new file mode 100644 index 00000000..3f7eca99 --- /dev/null +++ b/changes/kiku-field-grouping-modal-wiring.md @@ -0,0 +1,4 @@ +type: fixed +area: overlay + +- Fixed the Kiku field grouping modal misbehaving on Hyprland/Wayland while every other modal worked: its main-process wiring silently dropped the modal-open acknowledgement/retry, failure teardown, and warn-logging callbacks (they are optional, so the incomplete forward compiled cleanly), and it skipped the overlay prerequisites every other modal runs before opening. The field grouping modal now opens through the same path as the other modals — preparing the overlay runtime and visible overlay window first, then acknowledging/retrying the dedicated modal window — so it appears above and works over fullscreen mpv like the rest. diff --git a/package.json b/package.json index 0d14e67b..3a2022fd 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-restart-feedback.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua", "test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts", "test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/history.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src", - "test:core:src": "bun test src/preload-settings.test.ts src/settings/settings-anki-controls.test.ts src/settings/settings-model.test.ts src/settings/settings-field-layout.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/shared/mpv-x11-backend.test.ts src/core/services/cli-command.test.ts src/core/services/ipc.test.ts src/core/services/anki-jimaku-ipc.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/settings-window-z-order.test.ts src/core/services/hyprland-window-placement.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-manager.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/stats-window-lifecycle.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/main/overlay-runtime.test.ts src/main/runtime/macos-mpv-focus.test.ts src/main/runtime/macos-modal-focus-handoff.test.ts src/main/runtime/current-subtitle-snapshot.test.ts src/main/runtime/youtube-media-cache-playback.test.ts src/main/runtime/autoplay-ready-gate.test.ts src/main/runtime/autoplay-tokenization-warm-release.test.ts src/main/runtime/autoplay-subtitle-primer.test.ts src/main/runtime/visible-overlay-autoplay-readiness.test.ts src/main/runtime/character-dictionary-manager-gate.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/core/utils/electron-backend.test.ts src/core/utils/notification.test.ts src/main/runtime/startup-mode-flags.test.ts src/main/runtime/linux-overlay-pointer-interaction.test.ts src/main/runtime/windows-overlay-pointer-interaction.test.ts src/main/runtime/linux-overlay-zorder-keepalive.test.ts src/main/runtime/config-settings-window.test.ts src/main/runtime/settings-window-z-order.test.ts src/main/runtime/setup-window-factory.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/windows-mpv-plugin-detection.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/cli-command-context.test.ts src/main/runtime/composers/cli-startup-composer.test.ts src/main/runtime/log-export.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/fetch-adapter.test.ts src/main/runtime/update/release-metadata-policy.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/overlay-content-measurement.test.ts src/renderer/subtitle-render.test.ts src/renderer/subtitle-render-word-class.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/main/character-dictionary-runtime/term-building.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts src/core/services/overlay-visibility.test.ts src/core/services/overlay-window-config.test.ts src/core/services/overlay-window.test.ts src/main/main-wiring.test.ts src/main/runtime/linux-mpv-fullscreen-overlay-refresh.test.ts src/main/runtime/mpv-main-event-actions.test.ts src/main/runtime/overlay-modal-input-state.test.ts src/main/runtime/overlay-window-factory-main-deps.test.ts src/main/runtime/overlay-window-factory.test.ts src/main/runtime/overlay-window-layout-main-deps.test.ts src/main/runtime/overlay-window-layout.test.ts src/main/runtime/overlay-window-runtime-handlers.test.ts src/main/runtime/yomitan-extension-overlay-reload.test.ts src/renderer/modals/subtitle-sidebar.test.ts src/renderer/overlay-mouse-ignore.test.ts src/main/runtime/linux-visible-overlay-window-mode.test.ts src/main/runtime/linux-x11-cursor-point.test.ts src/renderer/renderer-init-order.test.ts", + "test:core:src": "bun test src/preload-settings.test.ts src/settings/settings-anki-controls.test.ts src/settings/settings-model.test.ts src/settings/settings-field-layout.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/shared/mpv-x11-backend.test.ts src/core/services/cli-command.test.ts src/core/services/ipc.test.ts src/core/services/anki-jimaku-ipc.test.ts src/core/services/field-grouping.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/settings-window-z-order.test.ts src/core/services/hyprland-window-placement.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-manager.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/stats-window-lifecycle.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/main/overlay-runtime.test.ts src/main/runtime/macos-mpv-focus.test.ts src/main/runtime/macos-modal-focus-handoff.test.ts src/main/runtime/current-subtitle-snapshot.test.ts src/main/runtime/youtube-media-cache-playback.test.ts src/main/runtime/autoplay-ready-gate.test.ts src/main/runtime/autoplay-tokenization-warm-release.test.ts src/main/runtime/autoplay-subtitle-primer.test.ts src/main/runtime/visible-overlay-autoplay-readiness.test.ts src/main/runtime/character-dictionary-manager-gate.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/core/utils/electron-backend.test.ts src/core/utils/notification.test.ts src/main/runtime/startup-mode-flags.test.ts src/main/runtime/linux-overlay-pointer-interaction.test.ts src/main/runtime/windows-overlay-pointer-interaction.test.ts src/main/runtime/linux-overlay-zorder-keepalive.test.ts src/main/runtime/config-settings-window.test.ts src/main/runtime/settings-window-z-order.test.ts src/main/runtime/setup-window-factory.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/windows-mpv-plugin-detection.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/cli-command-context.test.ts src/main/runtime/composers/cli-startup-composer.test.ts src/main/runtime/log-export.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/overlay-bootstrap-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/fetch-adapter.test.ts src/main/runtime/update/release-metadata-policy.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/overlay-content-measurement.test.ts src/renderer/subtitle-render.test.ts src/renderer/subtitle-render-word-class.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/main/character-dictionary-runtime/term-building.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts src/core/services/overlay-visibility.test.ts src/core/services/overlay-window-config.test.ts src/core/services/overlay-window.test.ts src/main/main-wiring.test.ts src/main/runtime/linux-mpv-fullscreen-overlay-refresh.test.ts src/main/runtime/mpv-main-event-actions.test.ts src/main/runtime/overlay-modal-input-state.test.ts src/main/runtime/overlay-window-factory-main-deps.test.ts src/main/runtime/overlay-window-factory.test.ts src/main/runtime/overlay-window-layout-main-deps.test.ts src/main/runtime/overlay-window-layout.test.ts src/main/runtime/overlay-window-runtime-handlers.test.ts src/main/runtime/yomitan-extension-overlay-reload.test.ts src/renderer/modals/subtitle-sidebar.test.ts src/renderer/overlay-mouse-ignore.test.ts src/main/runtime/linux-visible-overlay-window-mode.test.ts src/main/runtime/linux-x11-cursor-point.test.ts src/renderer/renderer-init-order.test.ts", "test:core:dist": "bun test dist/preload-settings.test.js dist/settings/settings-anki-controls.test.js dist/settings/settings-model.test.js dist/settings/settings-field-layout.test.js dist/cli/args.test.js dist/cli/help.test.js dist/shared/setup-state.test.js dist/shared/mpv-x11-backend.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/yomitan-extension-loader.test.js dist/core/services/yomitan-settings.test.js dist/core/services/settings-window-z-order.test.js dist/core/services/hyprland-window-placement.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/stats-window.test.js dist/core/services/stats-window-lifecycle.test.js dist/core/services/__tests__/stats-server.test.js dist/main/runtime/stats-server-routing.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/main/overlay-runtime.test.js dist/main/runtime/macos-mpv-focus.test.js dist/main/runtime/macos-modal-focus-handoff.test.js dist/main/runtime/current-subtitle-snapshot.test.js dist/main/runtime/youtube-media-cache-playback.test.js dist/main/runtime/autoplay-ready-gate.test.js dist/main/runtime/autoplay-tokenization-warm-release.test.js dist/main/runtime/autoplay-subtitle-primer.test.js dist/main/runtime/visible-overlay-autoplay-readiness.test.js dist/main/runtime/character-dictionary-manager-gate.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/core/utils/shortcut-config.test.js dist/core/utils/electron-backend.test.js dist/core/utils/notification.test.js dist/main/runtime/startup-mode-flags.test.js dist/main/runtime/linux-overlay-pointer-interaction.test.js dist/main/runtime/windows-overlay-pointer-interaction.test.js dist/main/runtime/linux-overlay-zorder-keepalive.test.js dist/main/runtime/config-settings-window.test.js dist/main/runtime/settings-window-z-order.test.js dist/main/runtime/setup-window-factory.test.js dist/main/runtime/first-run-setup-plugin.test.js dist/main/runtime/windows-mpv-plugin-detection.test.js dist/main/runtime/first-run-setup-service.test.js dist/main/runtime/first-run-setup-window.test.js dist/main/runtime/command-line-launcher.test.js dist/main/runtime/cli-command-context.test.js dist/main/runtime/composers/cli-startup-composer.test.js dist/main/runtime/log-export.test.js dist/main/runtime/tray-runtime.test.js dist/main/runtime/tray-main-actions.test.js dist/main/runtime/tray-main-deps.test.js dist/main/runtime/tray-runtime-handlers.test.js dist/main/runtime/cli-command-context-main-deps.test.js dist/main/runtime/app-ready-main-deps.test.js dist/main/runtime/update/appimage-updater.test.js dist/main/runtime/update/fetch-adapter.test.js dist/main/runtime/update/release-metadata-policy.test.js dist/main/runtime/update/update-dialogs.test.js dist/main/runtime/update/support-assets.test.js dist/renderer/error-recovery.test.js dist/renderer/overlay-content-measurement.test.js dist/renderer/subtitle-render.test.js dist/renderer/subtitle-render-word-class.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/main/character-dictionary-runtime/term-building.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js dist/core/services/overlay-visibility.test.js dist/core/services/overlay-window-config.test.js dist/core/services/overlay-window.test.js dist/main/main-wiring.test.js dist/main/runtime/linux-mpv-fullscreen-overlay-refresh.test.js dist/main/runtime/mpv-main-event-actions.test.js dist/main/runtime/overlay-modal-input-state.test.js dist/main/runtime/overlay-window-factory-main-deps.test.js dist/main/runtime/overlay-window-factory.test.js dist/main/runtime/overlay-window-layout-main-deps.test.js dist/main/runtime/overlay-window-layout.test.js dist/main/runtime/overlay-window-runtime-handlers.test.js dist/main/runtime/yomitan-extension-overlay-reload.test.js dist/renderer/modals/subtitle-sidebar.test.js dist/renderer/overlay-mouse-ignore.test.js dist/main/runtime/linux-visible-overlay-window-mode.test.js dist/main/runtime/linux-x11-cursor-point.test.js dist/renderer/renderer-init-order.test.js", "test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist", diff --git a/src/anki-integration/field-grouping-workflow.test.ts b/src/anki-integration/field-grouping-workflow.test.ts index be21a17a..71306374 100644 --- a/src/anki-integration/field-grouping-workflow.test.ts +++ b/src/anki-integration/field-grouping-workflow.test.ts @@ -26,6 +26,7 @@ function createWorkflowHarness() { const deleted: number[][] = []; const addedTags: Array<{ noteIds: number[]; tags: string[] }> = []; const statuses: string[] = []; + const osdMessages: string[] = []; const rememberedMerges: Array<{ deletedNoteId: number; keptNoteId: number }> = []; const mergeCalls: Array<{ keepNoteId: number; @@ -112,7 +113,9 @@ function createWorkflowHarness() { statuses.push(message); }, showNotification: async () => undefined, - showOsdNotification: () => undefined, + showOsdNotification: (message: string) => { + osdMessages.push(message); + }, logError: () => undefined, logInfo: () => undefined, truncateSentence: (value: string) => value, @@ -125,6 +128,7 @@ function createWorkflowHarness() { addedTags, rememberedMerges, statuses, + osdMessages, mergeCalls, setManualChoice: (choice: typeof manualChoice) => { manualChoice = choice; @@ -191,6 +195,50 @@ test('FieldGroupingWorkflow manual mode returns false when callback unavailable' assert.equal(harness.updates.length, 0); }); +test('FieldGroupingWorkflow manual cancel notifies exactly once', async () => { + const harness = createWorkflowHarness(); + harness.setManualChoice({ + keepNoteId: 0, + deleteNoteId: 0, + deleteDuplicate: true, + cancelled: true, + }); + + const handled = await harness.workflow.handleManual(1, 2, { + noteId: 2, + fields: { + Expression: { value: 'word-2' }, + Sentence: { value: 'line-2' }, + }, + }); + + assert.equal(handled, false); + assert.deepEqual(harness.osdMessages, ['Field grouping cancelled']); + assert.equal(harness.updates.length, 0); +}); + +test('FieldGroupingWorkflow manual mode notifies when the original card cannot be loaded', async () => { + const harness = createWorkflowHarness(); + harness.setManualChoice({ + keepNoteId: 1, + deleteNoteId: 2, + deleteDuplicate: true, + cancelled: false, + }); + harness.deps.client.notesInfo = async () => []; + + const handled = await harness.workflow.handleManual(1, 2, { + noteId: 2, + fields: { + Expression: { value: 'word-2' }, + Sentence: { value: 'line-2' }, + }, + }); + + assert.equal(handled, false); + assert.deepEqual(harness.osdMessages, ['Field grouping failed: original card not found']); +}); + test('FieldGroupingWorkflow manual keep-new uses new note as merge target and old note as source', async () => { const harness = createWorkflowHarness(); harness.setManualChoice({ diff --git a/src/anki-integration/field-grouping-workflow.ts b/src/anki-integration/field-grouping-workflow.ts index 0f42656f..6c3854f1 100644 --- a/src/anki-integration/field-grouping-workflow.ts +++ b/src/anki-integration/field-grouping-workflow.ts @@ -98,6 +98,8 @@ export class FieldGroupingWorkflow { const originalNotesInfoResult = await this.deps.client.notesInfo([originalNoteId]); const originalNotesInfo = originalNotesInfoResult as FieldGroupingWorkflowNoteInfo[]; if (!originalNotesInfo || originalNotesInfo.length === 0) { + // handleManual owns all user-facing notifications; callers must not re-notify on false. + this.deps.showOsdNotification('Field grouping failed: original card not found'); return false; } diff --git a/src/anki-integration/field-grouping.test.ts b/src/anki-integration/field-grouping.test.ts index df637c80..5d44a600 100644 --- a/src/anki-integration/field-grouping.test.ts +++ b/src/anki-integration/field-grouping.test.ts @@ -302,7 +302,7 @@ test('triggerFieldGroupingForLastAddedCard refreshes the card when configured fi assert.deepEqual(harness.manualCalls, []); }); -test('triggerFieldGroupingForLastAddedCard shows a cancellation message when manual grouping is declined', async () => { +test('triggerFieldGroupingForLastAddedCard does not re-notify when manual grouping is declined', async () => { const harness = createHarness({ kikuFieldGrouping: 'manual', noteIds: [9], @@ -339,7 +339,9 @@ test('triggerFieldGroupingForLastAddedCard shows a cancellation message when man expression: 'word-9', }, ]); - assert.equal(harness.calls.at(-1), 'osd:Field grouping cancelled'); + // The manual workflow already notifies about its outcome (cancelled/unavailable/failed); + // the trigger wrapper re-notifying produced two "Field grouping cancelled" toasts. + assert.equal(harness.calls.filter((call) => call === 'osd:Field grouping cancelled').length, 0); }); test('buildFieldGroupingPreview returns merged compact and full previews', async () => { diff --git a/src/anki-integration/field-grouping.ts b/src/anki-integration/field-grouping.ts index cb3f5d3f..b6acb57b 100644 --- a/src/anki-integration/field-grouping.ts +++ b/src/anki-integration/field-grouping.ts @@ -156,15 +156,14 @@ export class FieldGroupingService { ); return; } - const handled = await this.deps.handleFieldGroupingManual( + // The manual workflow owns all user-facing notifications for its outcomes (cancelled, + // unavailable, failed) — re-notifying on a false return here duplicated them. + await this.deps.handleFieldGroupingManual( duplicateNoteId, noteId, noteInfo, expressionText, ); - if (!handled) { - this.deps.showOsdNotification('Field grouping cancelled'); - } }); } catch (error) { log.error('Error triggering field grouping:', (error as Error).message); diff --git a/src/core/services/field-grouping-overlay.test.ts b/src/core/services/field-grouping-overlay.test.ts index 0b5c4614..c485ce05 100644 --- a/src/core/services/field-grouping-overlay.test.ts +++ b/src/core/services/field-grouping-overlay.test.ts @@ -245,6 +245,12 @@ test('createFieldGroupingOverlayRuntime callback cancels and cleans up when kiku restoreOnModalClose: 'kiku', preferModalWindow: true, }, + // Abandonment also asks the renderer hosting the modal to close its dialog. + { + channel: 'kiku:field-grouping-cancel', + restoreOnModalClose: undefined, + preferModalWindow: true, + }, ], ); assert.deepEqual(waitCalls, [ @@ -254,7 +260,76 @@ test('createFieldGroupingOverlayRuntime callback cancels and cleans up when kiku assert.deepEqual(warnings, [ 'Kiku field grouping modal did not acknowledge modal open on first attempt; retrying dedicated modal window.', ]); - assert.deepEqual(closed, ['kiku']); + // Once from the send-failure path inside sendKikuFieldGroupingRequest, once from the + // callback's abandonment cleanup. The real runtime guards this via the restore set, so + // the duplicate is a harmless no-op. + assert.deepEqual(closed, ['kiku', 'kiku']); + } finally { + globalThis.setTimeout = originalSetTimeout; + } +}); + +test('createFieldGroupingOverlayRuntime prepares overlay windows before opening the modal', async () => { + // The field grouping modal must run the same prerequisites as every other modal + // (openOverlayHostedModal) so it opens with the overlay runtime ready and the visible overlay + // window present — otherwise on Hyprland it fails to sit above / focus over fullscreen mpv. + const order: string[] = []; + const originalSetTimeout = globalThis.setTimeout; + // The modal acknowledges open below, so the callback stays pending and arms its response + // timeout; stub the timer so no real 90s handle leaks into the test runner. + globalThis.setTimeout = (() => 0) as unknown as typeof globalThis.setTimeout; + + try { + const runtime = createFieldGroupingOverlayRuntime<'kiku'>({ + getMainWindow: () => null, + getVisibleOverlayVisible: () => false, + setVisibleOverlayVisible: () => {}, + getResolver: () => null, + setResolver: () => {}, + getRestoreVisibleOverlayOnModalClose: () => new Set<'kiku'>(), + ensureOverlayStartupPrereqs: () => order.push('prereqs'), + ensureOverlayWindowsReadyForVisibilityActions: () => order.push('windows-ready'), + sendToVisibleOverlay: (channel) => { + order.push(`send:${channel}`); + return true; + }, + waitForModalOpen: async () => { + order.push('wait'); + return true; + }, + }); + + // Do not await: an acknowledged modal leaves the choice pending until the user responds. + void runtime.createFieldGroupingCallback()({ + original: { + noteId: 1, + expression: 'a', + sentencePreview: 'a', + hasAudio: false, + hasImage: false, + isOriginal: true, + }, + duplicate: { + noteId: 2, + expression: 'b', + sentencePreview: 'b', + hasAudio: false, + hasImage: false, + isOriginal: false, + }, + }); + + // Let the async send + modal-open ack chain run. + for (let i = 0; i < 10; i += 1) { + await Promise.resolve(); + } + + assert.deepEqual(order, [ + 'prereqs', + 'windows-ready', + 'send:kiku:field-grouping-request', + 'wait', + ]); } finally { globalThis.setTimeout = originalSetTimeout; } diff --git a/src/core/services/field-grouping-overlay.ts b/src/core/services/field-grouping-overlay.ts index 2e17aed6..a68936c9 100644 --- a/src/core/services/field-grouping-overlay.ts +++ b/src/core/services/field-grouping-overlay.ts @@ -1,4 +1,5 @@ import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData } from '../../types'; +import { IPC_CHANNELS } from '../../shared/ipc/contracts'; import { createFieldGroupingCallbackRuntime, sendToVisibleOverlayRuntime } from './overlay-bridge'; interface WindowLike { @@ -22,6 +23,15 @@ export interface FieldGroupingOverlayRuntimeOptions { waitForModalOpen?: (modal: T, timeoutMs: number) => Promise; handleOverlayModalClosed?: (modal: T) => void; logWarn?: (message: string) => void; + /** + * Prepare the overlay runtime and (re)create the visible overlay window before opening the + * modal — the same prerequisites every other modal runs via `openOverlayHostedModal`. Without + * them the field grouping modal can open with no sibling overlay window present, and on + * Hyprland it then fails to sit above / take focus over fullscreen mpv the way the other + * modals do. + */ + ensureOverlayStartupPrereqs?: () => void; + ensureOverlayWindowsReadyForVisibilityActions?: () => void; sendToVisibleOverlay?: ( channel: string, payload?: unknown, @@ -69,11 +79,16 @@ export function createFieldGroupingOverlayRuntime( data: KikuFieldGroupingRequestData, ): Promise => { const kikuModal = 'kiku' as T; - const sendOpen = (): boolean => - sendToVisibleOverlay('kiku:field-grouping-request', data, { + const sendOpen = (): boolean => { + // Match every other modal's open path (openOverlayHostedModal): ensure the overlay runtime + // and visible overlay window exist before handing off to the dedicated modal window. + options.ensureOverlayStartupPrereqs?.(); + options.ensureOverlayWindowsReadyForVisibilityActions?.(); + return sendToVisibleOverlay('kiku:field-grouping-request', data, { restoreOnModalClose: kikuModal, preferModalWindow: true, }); + }; if (!options.waitForModalOpen) { return sendOpen(); @@ -102,6 +117,20 @@ export function createFieldGroupingOverlayRuntime( return opened; }; + const dismissModalUi = (): void => { + const kikuModal = 'kiku' as T; + // Best-effort: tell the renderer hosting the modal to close its dialog. When the modal + // lives in the dedicated modal window this is redundant with the teardown below, but it + // also covers the case where the request was routed into the visible overlay. + sendToVisibleOverlay(IPC_CHANNELS.event.kikuFieldGroupingCancel, undefined, { + preferModalWindow: true, + }); + // Reliable teardown of main-side modal state (restore set, main-overlay passthrough, + // dedicated modal window). This is what recovers the frozen overlay when a grouping + // request times out or fails to reach a visible modal. + options.handleOverlayModalClosed?.(kikuModal); + }; + const createFieldGroupingCallback = (): (( data: KikuFieldGroupingRequestData, ) => Promise) => { @@ -112,6 +141,7 @@ export function createFieldGroupingOverlayRuntime( setResolver: options.setResolver, sendToVisibleOverlay, sendKikuFieldGroupingRequest, + dismissModalUi, }); }; diff --git a/src/core/services/field-grouping.test.ts b/src/core/services/field-grouping.test.ts new file mode 100644 index 00000000..4feece86 --- /dev/null +++ b/src/core/services/field-grouping.test.ts @@ -0,0 +1,272 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData } from '../../types'; +import { createFieldGroupingCallback } from './field-grouping'; + +function makeRequestData(): KikuFieldGroupingRequestData { + return { + original: { + noteId: 1, + expression: 'a', + sentencePreview: 'a', + hasAudio: false, + hasImage: false, + isOriginal: true, + }, + duplicate: { + noteId: 2, + expression: 'a', + sentencePreview: 'b', + hasAudio: false, + hasImage: false, + isOriginal: false, + }, + }; +} + +/** + * Mirrors how main stores the resolver: it wraps the callback's resolver in a + * sequence-guarded closure, so the value read back is never identity-equal to the + * callback's own `finish`. The old `getResolver() === finish` clear-guard therefore + * never matched and leaked the resolver, wedging every later grouping attempt. + */ +function createWrappedResolverStore() { + let stored: ((choice: KikuFieldGroupingChoice) => void) | null = null; + return { + getResolver: () => stored, + setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => { + stored = resolver ? (choice) => resolver(choice) : null; + }, + respond: (choice: KikuFieldGroupingChoice) => { + stored?.(choice); + }, + }; +} + +test('field grouping callback clears the wrapped resolver after a renderer response', async () => { + const store = createWrappedResolverStore(); + let visible = false; + const callback = createFieldGroupingCallback({ + getVisibleOverlayVisible: () => visible, + setVisibleOverlayVisible: (next) => { + visible = next; + }, + getResolver: store.getResolver, + setResolver: store.setResolver, + sendRequestToVisibleOverlay: () => true, + }); + + const pending = callback(makeRequestData()); + await Promise.resolve(); + assert.notEqual(store.getResolver(), null); + + const choice: KikuFieldGroupingChoice = { + keepNoteId: 1, + deleteNoteId: 2, + deleteDuplicate: true, + cancelled: false, + }; + store.respond(choice); + + assert.deepEqual(await pending, choice); + assert.equal(store.getResolver(), null); +}); + +test('field grouping callback does not reject the next request after a response', async () => { + const store = createWrappedResolverStore(); + const callback = createFieldGroupingCallback({ + getVisibleOverlayVisible: () => false, + setVisibleOverlayVisible: () => {}, + getResolver: store.getResolver, + setResolver: store.setResolver, + sendRequestToVisibleOverlay: () => true, + }); + + const first = callback(makeRequestData()); + await Promise.resolve(); + store.respond({ keepNoteId: 1, deleteNoteId: 2, deleteDuplicate: true, cancelled: false }); + const firstChoice = await first; + assert.equal(firstChoice.cancelled, false); + + // The second attempt must reach the renderer, not short-circuit to an instant cancel. + const second = callback(makeRequestData()); + await Promise.resolve(); + assert.notEqual(store.getResolver(), null); + store.respond({ keepNoteId: 2, deleteNoteId: 1, deleteDuplicate: false, cancelled: false }); + const secondChoice = await second; + assert.equal(secondChoice.cancelled, false); + assert.equal(secondChoice.keepNoteId, 2); +}); + +test('field grouping callback dismisses the modal UI when the send fails', async () => { + const store = createWrappedResolverStore(); + let dismissed = 0; + const callback = createFieldGroupingCallback({ + getVisibleOverlayVisible: () => false, + setVisibleOverlayVisible: () => {}, + getResolver: store.getResolver, + setResolver: store.setResolver, + sendRequestToVisibleOverlay: () => false, + dismissModalUi: () => { + dismissed += 1; + }, + }); + + const result = await callback(makeRequestData()); + assert.equal(result.cancelled, true); + assert.equal(dismissed, 1); + assert.equal(store.getResolver(), null); +}); + +test('field grouping callback handles modal dismiss failures on send failure', async () => { + const store = createWrappedResolverStore(); + const originalConsoleError = console.error; + console.error = () => {}; + try { + const callback = createFieldGroupingCallback({ + getVisibleOverlayVisible: () => false, + setVisibleOverlayVisible: () => {}, + getResolver: store.getResolver, + setResolver: store.setResolver, + sendRequestToVisibleOverlay: () => false, + dismissModalUi: () => { + throw new Error('dismiss failed'); + }, + }); + + const result = await callback(makeRequestData()); + + assert.equal(result.cancelled, true); + assert.equal(store.getResolver(), null); + } finally { + console.error = originalConsoleError; + } +}); + +test('field grouping callback handles modal dismiss failures on timeout', async () => { + const store = createWrappedResolverStore(); + const originalConsoleError = console.error; + console.error = () => {}; + try { + const callback = createFieldGroupingCallback({ + getVisibleOverlayVisible: () => false, + setVisibleOverlayVisible: () => {}, + getResolver: store.getResolver, + setResolver: store.setResolver, + sendRequestToVisibleOverlay: () => true, + dismissModalUi: () => { + throw new Error('dismiss failed'); + }, + responseTimeoutMs: 5, + }); + + const result = await callback(makeRequestData()); + + assert.equal(result.cancelled, true); + assert.equal(store.getResolver(), null); + } finally { + console.error = originalConsoleError; + } +}); + +test('field grouping callback reports modal dismiss failures', async () => { + const store = createWrappedResolverStore(); + const errors: unknown[] = []; + const originalConsoleError = console.error; + console.error = (...args: unknown[]) => { + errors.push(args); + }; + try { + const callback = createFieldGroupingCallback({ + getVisibleOverlayVisible: () => false, + setVisibleOverlayVisible: () => {}, + getResolver: store.getResolver, + setResolver: store.setResolver, + sendRequestToVisibleOverlay: () => false, + dismissModalUi: () => { + throw new Error('dismiss failed'); + }, + }); + + await callback(makeRequestData()); + + assert.equal(errors.length, 1); + } finally { + console.error = originalConsoleError; + } +}); + +test('field grouping callback dismisses the modal UI when the response times out', async () => { + const store = createWrappedResolverStore(); + let dismissed = 0; + const callback = createFieldGroupingCallback({ + getVisibleOverlayVisible: () => false, + setVisibleOverlayVisible: () => {}, + getResolver: store.getResolver, + setResolver: store.setResolver, + sendRequestToVisibleOverlay: () => true, + dismissModalUi: () => { + dismissed += 1; + }, + responseTimeoutMs: 5, + }); + + const result = await callback(makeRequestData()); + assert.equal(result.cancelled, true); + assert.equal(dismissed, 1); + assert.equal(store.getResolver(), null); +}); + +test('field grouping callback does not dismiss the modal UI on a normal response', async () => { + const store = createWrappedResolverStore(); + let dismissed = 0; + const callback = createFieldGroupingCallback({ + getVisibleOverlayVisible: () => false, + setVisibleOverlayVisible: () => {}, + getResolver: store.getResolver, + setResolver: store.setResolver, + sendRequestToVisibleOverlay: () => true, + dismissModalUi: () => { + dismissed += 1; + }, + responseTimeoutMs: 10000, + }); + + const pending = callback(makeRequestData()); + await Promise.resolve(); + store.respond({ keepNoteId: 1, deleteNoteId: 2, deleteDuplicate: true, cancelled: false }); + await pending; + assert.equal(dismissed, 0); +}); + +test('field grouping callback rejects a concurrent request while one is pending', async () => { + const store = createWrappedResolverStore(); + let sends = 0; + let dismissed = 0; + const callback = createFieldGroupingCallback({ + getVisibleOverlayVisible: () => false, + setVisibleOverlayVisible: () => {}, + getResolver: store.getResolver, + setResolver: store.setResolver, + sendRequestToVisibleOverlay: () => { + sends += 1; + return true; + }, + dismissModalUi: () => { + dismissed += 1; + }, + responseTimeoutMs: 10000, + }); + + const first = callback(makeRequestData()); + await Promise.resolve(); + assert.equal(sends, 1); + + const second = await callback(makeRequestData()); + assert.equal(second.cancelled, true); + assert.equal(sends, 1); + assert.equal(dismissed, 0); + + store.respond({ keepNoteId: 1, deleteNoteId: 2, deleteDuplicate: true, cancelled: false }); + await first; +}); diff --git a/src/core/services/field-grouping.ts b/src/core/services/field-grouping.ts index 6585bcd5..e4034881 100644 --- a/src/core/services/field-grouping.ts +++ b/src/core/services/field-grouping.ts @@ -1,21 +1,41 @@ import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData } from '../../types'; +const DEFAULT_FIELD_GROUPING_RESPONSE_TIMEOUT_MS = 90000; + export function createFieldGroupingCallback(options: { getVisibleOverlayVisible: () => boolean; setVisibleOverlayVisible: (visible: boolean) => void; getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null; setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void; sendRequestToVisibleOverlay: (data: KikuFieldGroupingRequestData) => boolean | Promise; + /** + * Tears down the modal UI when the request is abandoned without a renderer response + * (send failure or response timeout). Without this the dedicated modal window, its + * restore-set entry, and the forced main-overlay passthrough stay orphaned — which on + * Wayland leaves an invisible modal covering mpv and the overlay stuck unresponsive. + */ + dismissModalUi?: () => void; + responseTimeoutMs?: number; }): (data: KikuFieldGroupingRequestData) => Promise { + const cancelledChoice = (): KikuFieldGroupingChoice => ({ + keepNoteId: 0, + deleteNoteId: 0, + deleteDuplicate: true, + cancelled: true, + }); + + const dismissModalUi = (): void => { + try { + options.dismissModalUi?.(); + } catch (error) { + console.error('Failed to dismiss Kiku field grouping modal UI:', error); + } + }; + return async (data: KikuFieldGroupingRequestData): Promise => { return new Promise((resolve) => { if (options.getResolver()) { - resolve({ - keepNoteId: 0, - deleteNoteId: 0, - deleteDuplicate: true, - cancelled: true, - }); + resolve(cancelledChoice()); return; } @@ -23,18 +43,26 @@ export function createFieldGroupingCallback(options: { let settled = false; let timeout: ReturnType | null = null; - const finish = (choice: KikuFieldGroupingChoice): void => { + const finish = (choice: KikuFieldGroupingChoice, abandoned = false): void => { if (settled) return; settled = true; if (timeout !== null) { clearTimeout(timeout); timeout = null; } - if (options.getResolver() === finish) { - options.setResolver(null); - } + // Always release the resolver. Callers (main) wrap this in a sequence-guarded + // resolver, so an identity check against `finish` never matches and would leak + // the resolver — blocking every later grouping attempt with an instant cancel. + options.setResolver(null); resolve(choice); + // When abandoned without a renderer response, tear down the modal window/state + // that the request path spun up. A normal response already routes through the + // renderer's close handler, so only the abandon paths need this. + if (abandoned) { + dismissModalUi(); + } + if (!previousVisibleOverlay && options.getVisibleOverlayVisible()) { options.setVisibleOverlayVisible(false); } @@ -45,32 +73,17 @@ export function createFieldGroupingCallback(options: { (sent) => { if (settled) return; if (!sent) { - finish({ - keepNoteId: 0, - deleteNoteId: 0, - deleteDuplicate: true, - cancelled: true, - }); + finish(cancelledChoice(), true); return; } timeout = setTimeout(() => { if (!settled) { - finish({ - keepNoteId: 0, - deleteNoteId: 0, - deleteDuplicate: true, - cancelled: true, - }); + finish(cancelledChoice(), true); } - }, 90000); + }, options.responseTimeoutMs ?? DEFAULT_FIELD_GROUPING_RESPONSE_TIMEOUT_MS); }, () => { - finish({ - keepNoteId: 0, - deleteNoteId: 0, - deleteDuplicate: true, - cancelled: true, - }); + finish(cancelledChoice(), true); }, ); }); diff --git a/src/core/services/hyprland-window-placement.test.ts b/src/core/services/hyprland-window-placement.test.ts index 538e7487..79e194ac 100644 --- a/src/core/services/hyprland-window-placement.test.ts +++ b/src/core/services/hyprland-window-placement.test.ts @@ -3,6 +3,7 @@ import test from 'node:test'; import { buildHyprlandPlacementDispatches, ensureHyprlandWindowFloatingByTitle, + ensureHyprlandWindowFloatingByTitleWithStatus, findHyprlandWindowForPlacement, hasHyprlandWindowPlacementBoundsMismatch, shouldAttemptHyprlandWindowPlacement, @@ -203,6 +204,67 @@ test('buildHyprlandPlacementDispatches unpins previously pinned overlay windows' ); }); +test('ensureHyprlandWindowFloatingByTitleWithStatus reports not-applicable off Hyprland', () => { + const status = ensureHyprlandWindowFloatingByTitleWithStatus({ + title: 'SubMiner Overlay Modal', + platform: 'linux', + env: {}, + execFileSync: (() => { + throw new Error('should not query the compositor when placement is not applicable'); + }) as never, + }); + + assert.deepEqual(status, { applicable: false, clientFound: false, dispatched: false }); +}); + +test('ensureHyprlandWindowFloatingByTitleWithStatus reports pending when the client is not yet mapped', () => { + // The window has not been mapped by the compositor yet, so no client matches. Callers use + // this to keep retrying until the modal is actually placed above fullscreen mpv. + const status = ensureHyprlandWindowFloatingByTitleWithStatus({ + title: 'SubMiner Overlay Modal', + platform: 'linux', + env: { HYPRLAND_INSTANCE_SIGNATURE: 'abc' }, + pid: 999, + execFileSync: ((command: string, args: string[]) => { + if (args.join(' ') === '-j clients') { + return JSON.stringify([]); + } + return ''; + }) as never, + }); + + assert.deepEqual(status, { applicable: true, clientFound: false, dispatched: false }); +}); + +test('ensureHyprlandWindowFloatingByTitleWithStatus reports the client found once mapped', () => { + const status = ensureHyprlandWindowFloatingByTitleWithStatus({ + title: 'SubMiner Overlay Modal', + platform: 'linux', + env: { HYPRLAND_INSTANCE_SIGNATURE: 'abc' }, + pid: 456, + execFileSync: ((command: string, args: string[]) => { + if (args.join(' ') === '-j clients') { + return JSON.stringify([ + { + address: '0xmatch', + pid: 456, + title: 'SubMiner Overlay Modal', + mapped: true, + floating: false, + pinned: false, + }, + ]); + } + if (args.join(' ') === '-j status') { + return JSON.stringify({ configProvider: 'lua' }); + } + return ''; + }) as never, + }); + + assert.deepEqual(status, { applicable: true, clientFound: true, dispatched: true }); +}); + test('ensureHyprlandWindowFloatingByTitle dispatches float-only placement for matching tiled window', () => { const calls: unknown[][] = []; const placed = ensureHyprlandWindowFloatingByTitle({ diff --git a/src/core/services/hyprland-window-placement.ts b/src/core/services/hyprland-window-placement.ts index fe8c4c0e..16fd217a 100644 --- a/src/core/services/hyprland-window-placement.ts +++ b/src/core/services/hyprland-window-placement.ts @@ -272,6 +272,21 @@ export function hasHyprlandWindowPlacementBoundsMismatch(options: { } } +/** + * Placement outcome for a single reconcile attempt. + * - `applicable`: false when not running under Hyprland (nothing to retry). + * - `clientFound`: whether a mapped Hyprland client matched. On Wayland the window maps + * asynchronously after `show()`, so early attempts can miss it — callers retry while this + * is false so the modal actually gets promoted above fullscreen mpv instead of staying + * invisible. + * - `dispatched`: whether any hyprctl dispatch was issued. + */ +export interface HyprlandPlacementStatus { + applicable: boolean; + clientFound: boolean; + dispatched: boolean; +} + export function ensureHyprlandWindowFloatingByTitle(options: { title: string; bounds?: HyprlandPlacementBounds | null; @@ -281,8 +296,20 @@ export function ensureHyprlandWindowFloatingByTitle(options: { promote?: boolean; execFileSync?: ExecFileSync; }): boolean { + return ensureHyprlandWindowFloatingByTitleWithStatus(options).dispatched; +} + +export function ensureHyprlandWindowFloatingByTitleWithStatus(options: { + title: string; + bounds?: HyprlandPlacementBounds | null; + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + pid?: number; + promote?: boolean; + execFileSync?: ExecFileSync; +}): HyprlandPlacementStatus { if (!shouldAttemptHyprlandWindowPlacement(options.platform, options.env)) { - return false; + return { applicable: false, clientFound: false, dispatched: false }; } const run = options.execFileSync ?? execFileSync; @@ -293,7 +320,7 @@ export function ensureHyprlandWindowFloatingByTitle(options: { title: options.title, }); if (!client) { - return false; + return { applicable: true, clientFound: false, dispatched: false }; } const configProvider = detectHyprlandConfigProvider(run); @@ -329,9 +356,9 @@ export function ensureHyprlandWindowFloatingByTitle(options: { // Best-effort reconciliation: the initial placement dispatches already ran. } } - return dispatches.length > 0; + return { applicable: true, clientFound: true, dispatched: dispatches.length > 0 }; } catch { - return false; + return { applicable: true, clientFound: false, dispatched: false }; } } diff --git a/src/core/services/overlay-bridge.ts b/src/core/services/overlay-bridge.ts index fd977aa0..8187c7e1 100644 --- a/src/core/services/overlay-bridge.ts +++ b/src/core/services/overlay-bridge.ts @@ -65,12 +65,14 @@ export function createFieldGroupingCallbackRuntime(options: { runtimeOptions?: { restoreOnModalClose?: T; preferModalWindow?: boolean }, ) => boolean; sendKikuFieldGroupingRequest?: (data: KikuFieldGroupingRequestData) => Promise; + dismissModalUi?: () => void; }): (data: KikuFieldGroupingRequestData) => Promise { return createFieldGroupingCallback({ getVisibleOverlayVisible: options.getVisibleOverlayVisible, setVisibleOverlayVisible: options.setVisibleOverlayVisible, getResolver: options.getResolver, setResolver: options.setResolver, + dismissModalUi: options.dismissModalUi, sendRequestToVisibleOverlay: (data) => options.sendKikuFieldGroupingRequest ? options.sendKikuFieldGroupingRequest(data) diff --git a/src/core/services/overlay-manager.ts b/src/core/services/overlay-manager.ts index a72f9338..5c73fab7 100644 --- a/src/core/services/overlay-manager.ts +++ b/src/core/services/overlay-manager.ts @@ -1,6 +1,7 @@ import type { BrowserWindow } from 'electron'; import { RuntimeOptionState, WindowGeometry } from '../../types'; import { updateOverlayWindowBounds } from './overlay-window'; +import type { HyprlandPlacementStatus } from './hyprland-window-placement'; export interface OverlayManager { getMainWindow: () => BrowserWindow | null; @@ -9,7 +10,7 @@ export interface OverlayManager { setModalWindow: (window: BrowserWindow | null) => void; getOverlayWindow: () => BrowserWindow | null; setOverlayWindowBounds: (geometry: WindowGeometry) => void; - setModalWindowBounds: (geometry: WindowGeometry) => void; + setModalWindowBounds: (geometry: WindowGeometry) => HyprlandPlacementStatus; getVisibleOverlayVisible: () => boolean; setVisibleOverlayVisible: (visible: boolean) => void; getOverlayWindows: () => BrowserWindow[]; @@ -29,9 +30,12 @@ export function createOverlayManager(options: OverlayManagerOptions = {}): Overl let visibleOverlayVisible = false; const applyOverlayBounds = options.updateOverlayWindowBounds ?? updateOverlayWindowBounds; - const updateWindowBounds = (geometry: WindowGeometry, window: BrowserWindow | null): void => { + const updateWindowBounds = ( + geometry: WindowGeometry, + window: BrowserWindow | null, + ): HyprlandPlacementStatus => { const promote = window ? (options.shouldPromoteWindowOnBoundsUpdate?.(window) ?? true) : true; - applyOverlayBounds(geometry, window, { promote }); + return applyOverlayBounds(geometry, window, { promote }); }; return { @@ -47,9 +51,7 @@ export function createOverlayManager(options: OverlayManagerOptions = {}): Overl setOverlayWindowBounds: (geometry) => { updateWindowBounds(geometry, mainWindow); }, - setModalWindowBounds: (geometry) => { - updateWindowBounds(geometry, modalWindow); - }, + setModalWindowBounds: (geometry) => updateWindowBounds(geometry, modalWindow), getVisibleOverlayVisible: () => visibleOverlayVisible, setVisibleOverlayVisible: (visible) => { visibleOverlayVisible = visible; diff --git a/src/core/services/overlay-window.ts b/src/core/services/overlay-window.ts index d13f7e77..f3ad3258 100644 --- a/src/core/services/overlay-window.ts +++ b/src/core/services/overlay-window.ts @@ -9,7 +9,11 @@ import { handleOverlayWindowBlurred, type OverlayWindowKind, } from './overlay-window-input'; -import { ensureHyprlandWindowFloatingByTitle } from './hyprland-window-placement'; +import { + ensureHyprlandWindowFloatingByTitle, + ensureHyprlandWindowFloatingByTitleWithStatus, + type HyprlandPlacementStatus, +} from './hyprland-window-placement'; import { buildOverlayWindowOptions, OVERLAY_WINDOW_TITLES } from './overlay-window-options'; import { normalizeOverlayWindowBoundsForPlatform } from './overlay-window-bounds'; import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags'; @@ -54,8 +58,10 @@ export function updateOverlayWindowBounds( options: { promote?: boolean; } = {}, -): void { - if (!geometry || !window || window.isDestroyed()) return; +): HyprlandPlacementStatus { + if (!geometry || !window || window.isDestroyed()) { + return { applicable: false, clientFound: false, dispatched: false }; + } const bounds = normalizeOverlayWindowBoundsForPlatform( geometry, process.platform, @@ -63,7 +69,7 @@ export function updateOverlayWindowBounds( window, ); window.setBounds(bounds); - ensureHyprlandWindowFloatingByTitle({ + return ensureHyprlandWindowFloatingByTitleWithStatus({ title: window.getTitle(), bounds, promote: options.promote, diff --git a/src/main.ts b/src/main.ts index bcf19d4a..cd87b624 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2324,6 +2324,9 @@ const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime overlayModalRuntime.waitForModalOpen(modal, timeoutMs), handleOverlayModalClosed: (modal) => overlayModalRuntime.handleOverlayModalClosed(modal), logWarn: (message) => logger.warn(message), + ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(), + ensureOverlayWindowsReadyForVisibilityActions: () => + ensureOverlayWindowsReadyForVisibilityActions(), sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => overlayModalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions), })(), diff --git a/src/main/overlay-runtime.test.ts b/src/main/overlay-runtime.test.ts index 13d530ec..9f2843ea 100644 --- a/src/main/overlay-runtime.test.ts +++ b/src/main/overlay-runtime.test.ts @@ -926,3 +926,156 @@ test('waitForModalOpen resolves false on timeout', async () => { assert.equal(await runtime.waitForModalOpen('youtube-track-picker', 5), false); }); + +test('modal placement reconcile retries until the Hyprland client is mapped', () => { + const window = createMockWindow(); + const timers: Array<() => void> = []; + const originalSetTimeout = globalThis.setTimeout; + globalThis.setTimeout = ((cb: () => void) => { + timers.push(cb); + return { unref() {} }; + }) as unknown as typeof globalThis.setTimeout; + + const statuses: Array<{ applicable: boolean; clientFound: boolean }> = []; + try { + const runtime = createOverlayModalRuntimeService({ + getMainWindow: () => null, + getModalWindow: () => window as never, + createModalWindow: () => window as never, + getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), + setModalWindowBounds: () => { + // The compositor never maps the window, so every reconcile reports pending. + const status = { applicable: true, clientFound: false, dispatched: false }; + statuses.push(status); + return status; + }, + }); + + runtime.sendToActiveOverlayWindow( + 'kiku:field-grouping-open', + { test: true }, + { restoreOnModalClose: 'kiku', preferModalWindow: true }, + ); + runtime.notifyOverlayModalOpened('kiku'); + + let iterations = 0; + while (timers.length > 0 && iterations < 50) { + const next = timers.shift(); + next?.(); + iterations += 1; + } + + // The reconcile ladder re-asserts placement across all six delays while the client + // stays unmapped, instead of the old single post-show attempt. + assert.ok( + statuses.length >= 6, + `expected at least 6 pending reconcile attempts, saw ${statuses.length}`, + ); + } finally { + globalThis.setTimeout = originalSetTimeout; + } +}); + +test('modal placement reconcile stops retrying once the client is mapped', () => { + const window = createMockWindow(); + const timers: Array<() => void> = []; + const originalSetTimeout = globalThis.setTimeout; + globalThis.setTimeout = ((cb: () => void) => { + timers.push(cb); + return { unref() {} }; + }) as unknown as typeof globalThis.setTimeout; + + let reconcileCount = 0; + try { + const runtime = createOverlayModalRuntimeService({ + getMainWindow: () => null, + getModalWindow: () => window as never, + createModalWindow: () => window as never, + getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), + setModalWindowBounds: () => { + reconcileCount += 1; + // Client is already mapped, so placement is settled on the first attempt. + return { applicable: true, clientFound: true, dispatched: true }; + }, + }); + + runtime.sendToActiveOverlayWindow( + 'kiku:field-grouping-open', + { test: true }, + { restoreOnModalClose: 'kiku', preferModalWindow: true }, + ); + runtime.notifyOverlayModalOpened('kiku'); + + let iterations = 0; + while (timers.length > 0 && iterations < 50) { + const next = timers.shift(); + next?.(); + iterations += 1; + } + + // No 6-deep ladder: a settled placement should not keep rescheduling. + assert.ok( + reconcileCount < 6, + `expected the ladder to stop early, saw ${reconcileCount} reconcile attempts`, + ); + } finally { + globalThis.setTimeout = originalSetTimeout; + } +}); + +test('modal placement reconcile cancels stale retry ladder after a newer visible modal interaction', () => { + const window = createMockWindow(); + type TimerEntry = { active: boolean; callback: () => void }; + const timers: TimerEntry[] = []; + const activeTimerCount = () => timers.filter((timer) => timer.active).length; + const runNextActiveTimer = () => { + const timer = timers.find((candidate) => candidate.active); + if (!timer) return; + timer.active = false; + timer.callback(); + }; + const originalSetTimeout = globalThis.setTimeout; + const originalClearTimeout = globalThis.clearTimeout; + globalThis.setTimeout = ((cb: () => void) => { + const timer = { active: true, callback: cb, unref() {} }; + timers.push(timer); + return timer; + }) as unknown as typeof globalThis.setTimeout; + globalThis.clearTimeout = ((timeout: TimerEntry | undefined) => { + if (timeout) { + timeout.active = false; + } + }) as unknown as typeof globalThis.clearTimeout; + + try { + const runtime = createOverlayModalRuntimeService({ + getMainWindow: () => null, + getModalWindow: () => window as never, + createModalWindow: () => window as never, + getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), + setModalWindowBounds: () => ({ applicable: true, clientFound: false, dispatched: false }), + }); + + runtime.sendToActiveOverlayWindow( + 'kiku:field-grouping-open', + { test: true }, + { restoreOnModalClose: 'kiku', preferModalWindow: true }, + ); + runtime.notifyOverlayModalOpened('kiku'); + assert.equal(activeTimerCount(), 1); + + runtime.sendToActiveOverlayWindow( + 'kiku:field-grouping-open', + { test: true }, + { restoreOnModalClose: 'kiku', preferModalWindow: true }, + ); + assert.equal(activeTimerCount(), 2); + + runNextActiveTimer(); + + assert.equal(activeTimerCount(), 1, 'stale retry should not schedule a continuation'); + } finally { + globalThis.setTimeout = originalSetTimeout; + globalThis.clearTimeout = originalClearTimeout; + } +}); diff --git a/src/main/overlay-runtime.ts b/src/main/overlay-runtime.ts index 45b45ead..d284e511 100644 --- a/src/main/overlay-runtime.ts +++ b/src/main/overlay-runtime.ts @@ -1,10 +1,14 @@ import type { BrowserWindow } from 'electron'; import type { OverlayHostedModal } from '../shared/ipc/contracts'; import type { WindowGeometry } from '../types'; +import type { HyprlandPlacementStatus } from '../core/services/hyprland-window-placement'; import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from '../core/services/overlay-window-flags'; const MODAL_REVEAL_FALLBACK_DELAY_MS = 250; -const MODAL_POST_SHOW_BOUNDS_RECONCILE_DELAY_MS = 50; +// The dedicated modal window maps asynchronously on Wayland; a single reconcile can fire +// before the compositor has a client to place, leaving the modal buried under fullscreen mpv. +// Re-assert placement across this ladder until the Hyprland client is found (or attempts run out). +const MODAL_POST_SHOW_BOUNDS_RECONCILE_DELAYS_MS = [50, 120, 250, 500, 900, 1400]; function requestOverlayApplicationFocus(): void { try { @@ -31,7 +35,7 @@ export interface OverlayWindowResolver { getModalWindow: () => BrowserWindow | null; createModalWindow: () => BrowserWindow | null; getModalGeometry: () => WindowGeometry; - setModalWindowBounds: (geometry: WindowGeometry) => void; + setModalWindowBounds: (geometry: WindowGeometry) => HyprlandPlacementStatus | void; } export interface OverlayModalRuntime { @@ -73,6 +77,7 @@ export function createOverlayModalRuntimeService( let modalWindowPrimedForImmediateShow = false; let pendingModalWindowReveal: BrowserWindow | null = null; let pendingModalWindowRevealTimeout: RevealFallbackHandle | null = null; + const modalWindowBoundsReconcileGenerations = new WeakMap(); const scheduleRevealFallback = (callback: () => void, delayMs: number): RevealFallbackHandle => (options.scheduleRevealFallback ?? globalThis.setTimeout)(callback, delayMs); const clearRevealFallback = (timeout: RevealFallbackHandle): void => @@ -145,21 +150,47 @@ export function createOverlayModalRuntimeService( window.moveTop(); }; - const reconcileModalWindowBounds = (window: BrowserWindow): void => { + const reconcileModalWindowBounds = (window: BrowserWindow): HyprlandPlacementStatus | void => { const modalWindow = deps.getModalWindow(); if (!modalWindow || modalWindow !== window || window.isDestroyed()) { return; } - deps.setModalWindowBounds(deps.getModalGeometry()); + return deps.setModalWindowBounds(deps.getModalGeometry()); }; - const scheduleModalWindowBoundsReconcile = (window: BrowserWindow): void => { + const nextModalWindowBoundsReconcileGeneration = (window: BrowserWindow): number => { + const generation = (modalWindowBoundsReconcileGenerations.get(window) ?? 0) + 1; + modalWindowBoundsReconcileGenerations.set(window, generation); + return generation; + }; + + const isCurrentModalWindowBoundsReconcileGeneration = ( + window: BrowserWindow, + generation: number, + ): boolean => modalWindowBoundsReconcileGenerations.get(window) === generation; + + const scheduleModalWindowBoundsReconcile = ( + window: BrowserWindow, + generation: number, + attempt = 0, + ): void => { + if (attempt >= MODAL_POST_SHOW_BOUNDS_RECONCILE_DELAYS_MS.length) { + return; + } const timeout = setTimeout(() => { + if (!isCurrentModalWindowBoundsReconcileGeneration(window, generation)) { + return; + } if (window.isDestroyed() || !window.isVisible()) { return; } - reconcileModalWindowBounds(window); - }, MODAL_POST_SHOW_BOUNDS_RECONCILE_DELAY_MS); + const status = reconcileModalWindowBounds(window); + // Keep retrying only while a Hyprland placement is applicable but the compositor has + // not mapped the window yet. Once the client is found (or we're not on Hyprland), stop. + if (status && status.applicable && !status.clientFound) { + scheduleModalWindowBoundsReconcile(window, generation, attempt + 1); + } + }, MODAL_POST_SHOW_BOUNDS_RECONCILE_DELAYS_MS[attempt]); timeout.unref?.(); }; @@ -206,8 +237,9 @@ export function createOverlayModalRuntimeService( if (!window.webContents.isFocused()) { window.webContents.focus(); } + const reconcileGeneration = nextModalWindowBoundsReconcileGeneration(window); reconcileModalWindowBounds(window); - scheduleModalWindowBoundsReconcile(window); + scheduleModalWindowBoundsReconcile(window, reconcileGeneration); }; const ensureModalWindowInteractive = (window: BrowserWindow): void => { @@ -219,8 +251,9 @@ export function createOverlayModalRuntimeService( if (window.isVisible()) { window.focus(); window.webContents.focus(); + const reconcileGeneration = nextModalWindowBoundsReconcileGeneration(window); reconcileModalWindowBounds(window); - scheduleModalWindowBoundsReconcile(window); + scheduleModalWindowBoundsReconcile(window, reconcileGeneration); return; } diff --git a/src/main/runtime/field-grouping-overlay-main-deps.test.ts b/src/main/runtime/field-grouping-overlay-main-deps.test.ts index 662dea38..c3ff06b6 100644 --- a/src/main/runtime/field-grouping-overlay-main-deps.test.ts +++ b/src/main/runtime/field-grouping-overlay-main-deps.test.ts @@ -36,3 +36,50 @@ test('field grouping overlay main deps builder maps window visibility and resolv assert.equal(deps.sendToVisibleOverlay('kiku:open', 1), true); assert.deepEqual(calls, ['visible:true', 'set-resolver:null', 'send:kiku:open:1']); }); + +test('field grouping overlay main deps builder forwards modal open/teardown/prereq wiring', () => { + // Regression: these are optional on the runtime options, so a missing forward compiled + // silently and left the field grouping modal with no ack/retry, no teardown, and no prereqs — + // the reason the earlier recovery fixes never took effect. + const calls: string[] = []; + const waitForModalOpen = async (modal: 'kiku', timeoutMs: number): Promise => { + calls.push(`wait:${modal}:${timeoutMs}`); + return true; + }; + const handleOverlayModalClosed = (modal: 'kiku'): void => { + calls.push(`closed:${modal}`); + }; + const logWarn = (message: string): void => { + calls.push(`warn:${message}`); + }; + const ensureOverlayStartupPrereqs = (): void => { + calls.push('prereqs'); + }; + const ensureOverlayWindowsReadyForVisibilityActions = (): void => { + calls.push('windows-ready'); + }; + + const deps = createBuildFieldGroupingOverlayMainDepsHandler<'kiku'>({ + getMainWindow: () => null, + getVisibleOverlayVisible: () => false, + setVisibleOverlayVisible: () => {}, + getResolver: () => null, + setResolver: () => {}, + getRestoreVisibleOverlayOnModalClose: () => new Set<'kiku'>(), + waitForModalOpen, + handleOverlayModalClosed, + logWarn, + ensureOverlayStartupPrereqs, + ensureOverlayWindowsReadyForVisibilityActions, + sendToActiveOverlayWindow: () => true, + })(); + + assert.equal(deps.waitForModalOpen, waitForModalOpen); + assert.equal(deps.handleOverlayModalClosed, handleOverlayModalClosed); + assert.equal(deps.logWarn, logWarn); + assert.equal(deps.ensureOverlayStartupPrereqs, ensureOverlayStartupPrereqs); + assert.equal( + deps.ensureOverlayWindowsReadyForVisibilityActions, + ensureOverlayWindowsReadyForVisibilityActions, + ); +}); diff --git a/src/main/runtime/field-grouping-overlay-main-deps.ts b/src/main/runtime/field-grouping-overlay-main-deps.ts index 57a97436..3de132cc 100644 --- a/src/main/runtime/field-grouping-overlay-main-deps.ts +++ b/src/main/runtime/field-grouping-overlay-main-deps.ts @@ -28,6 +28,15 @@ export function createBuildFieldGroupingOverlayMainDepsHandler deps.getResolver(), setResolver: (resolver) => deps.setResolver(resolver), getRestoreVisibleOverlayOnModalClose: () => deps.getRestoreVisibleOverlayOnModalClose(), + // These are optional on the runtime options, so a missing forward compiles silently — but + // dropping them left the field grouping modal with no modal-open ack/retry, no teardown on + // failure, and no warn logging, which is why the earlier recovery fixes never took effect. + waitForModalOpen: deps.waitForModalOpen, + handleOverlayModalClosed: deps.handleOverlayModalClosed, + logWarn: deps.logWarn, + ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs, + ensureOverlayWindowsReadyForVisibilityActions: + deps.ensureOverlayWindowsReadyForVisibilityActions, sendToVisibleOverlay: ( channel: string, payload?: unknown, diff --git a/src/main/runtime/overlay-bootstrap-main-deps.test.ts b/src/main/runtime/overlay-bootstrap-main-deps.test.ts index 4fd86f1f..90d6b1ca 100644 --- a/src/main/runtime/overlay-bootstrap-main-deps.test.ts +++ b/src/main/runtime/overlay-bootstrap-main-deps.test.ts @@ -26,8 +26,9 @@ test('overlay modal runtime main deps builder maps window resolvers', () => { getModalWindow: () => modalWindow as never, createModalWindow: () => modalWindow as never, getModalGeometry: () => ({ x: 1, y: 2, width: 3, height: 4 }), - setModalWindowBounds: (geometry) => - calls.push(`modal-bounds:${geometry.x},${geometry.y},${geometry.width},${geometry.height}`), + setModalWindowBounds: (geometry) => { + calls.push(`modal-bounds:${geometry.x},${geometry.y},${geometry.width},${geometry.height}`); + }, })(); assert.equal(deps.getMainWindow(), mainWindow); diff --git a/src/preload.ts b/src/preload.ts index 0d1d551f..4314002b 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -201,6 +201,9 @@ const onKikuFieldGroupingRequestEvent = IPC_CHANNELS.event.kikuFieldGroupingRequest, (payload) => payload as KikuFieldGroupingRequestData, ); +const onKikuFieldGroupingCancelEvent = createQueuedIpcListener( + IPC_CHANNELS.event.kikuFieldGroupingCancel, +); const onSubtitleSetEvent = createLatestValueIpcListenerWithPayload( IPC_CHANNELS.event.subtitleSet, (payload) => payload as SubtitleData, @@ -396,6 +399,7 @@ const electronAPI: ElectronAPI = { ipcRenderer.invoke(IPC_CHANNELS.request.runSubsyncManual, request), onKikuFieldGroupingRequest: onKikuFieldGroupingRequestEvent, + onKikuFieldGroupingCancel: onKikuFieldGroupingCancelEvent, kikuBuildMergePreview: (request: KikuMergePreviewRequest): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.kikuBuildMergePreview, request), diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 0d88561c..1e07598d 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -565,6 +565,13 @@ function registerModalOpenHandlers(): void { }); }, ); + window.electronAPI.onKikuFieldGroupingCancel(() => { + runGuarded('kiku:field-grouping-cancel', () => { + // Main already settled the choice (timeout/failure); just close the dialog. Using the + // plain close path avoids sending a second, redundant response back to main. + kikuModal.closeKikuFieldGroupingModal(); + }); + }); } function registerKeyboardCommandHandlers(): void { diff --git a/src/shared/ipc/contracts.ts b/src/shared/ipc/contracts.ts index dc73d7f6..fc730fc3 100644 --- a/src/shared/ipc/contracts.ts +++ b/src/shared/ipc/contracts.ts @@ -130,6 +130,7 @@ export const IPC_CHANNELS = { secondarySubtitleMode: 'secondary-subtitle:mode', subsyncOpenManual: 'subsync:open-manual', kikuFieldGroupingRequest: 'kiku:field-grouping-request', + kikuFieldGroupingCancel: 'kiku:field-grouping-cancel', runtimeOptionsChanged: 'runtime-options:changed', runtimeOptionsOpen: 'runtime-options:open', jimakuOpen: 'jimaku:open', diff --git a/src/types/runtime.ts b/src/types/runtime.ts index 23d26fc2..bb26c170 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -471,6 +471,7 @@ export interface ElectronAPI { onSubsyncManualOpen: (callback: (payload: SubsyncManualPayload) => void) => void; runSubsyncManual: (request: SubsyncManualRunRequest) => Promise; onKikuFieldGroupingRequest: (callback: (data: KikuFieldGroupingRequestData) => void) => void; + onKikuFieldGroupingCancel: (callback: () => void) => void; kikuBuildMergePreview: (request: KikuMergePreviewRequest) => Promise; kikuFieldGroupingRespond: (choice: KikuFieldGroupingChoice) => void; getRuntimeOptions: () => Promise;