feat(nudges): Phase 6 — Margot soft-touch nudges via frontend nudge bus
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled

Frontend-owned nudge bus that consumes in-app signals Corbie already
produces, applies suppression, and fans out to OS notification + an
optional TTS read-aloud. OS-wide keyboard/window activity detection
stays deferred per the revised roadmap — the plan before rewrite
would have been brittle on Wayland, permission-heavy on macOS, and
low-quality everywhere.

Triggers (v1, all in-app signals):
- inactivity_with_active_timer — timer running, window blurred ≥ 90 s,
  at least 60 s into the timer.
- pending_morning_triage — ritual enabled, past 10:00 local, last
  shown ≠ today. Polls every 5 min while focused.
- micro_step_idle — micro-step decomposition created, no child step
  or parent task completed within 15 min.

Suppression:
- Respects nudgesEnabled + nudgesMuted.
- No nudge while the app has focus (document.hasFocus).
- Hard cap 3 per rolling hour.
- Permission requested via @tauri-apps/plugin-notification on first
  delivery; denial is silently respected.

Rust side:
- tauri-plugin-notification registered + ACL entries on the main-
  window capability only (secondary windows can't fire nudges).
- commands::nudges::deliver_nudge — thin wrapper, security-guarded
  via ensure_main_window, delegates to the plugin. No DB writes —
  the roadmap's nudges-audit table is deferred until a concrete need
  emerges.

Frontend glue:
- nudgeBus.svelte.ts — subscribes to window events, applies
  suppression, calls deliver_nudge (+ tts_speak when speakAloud is
  on).
- kon:task-completed now dispatched on complete_task_cmd success.
- kon:microstep-generated + kon:step-completed dispatched from
  MicroSteps so the idle trigger can clear itself on any engagement.
- kon:focus-timer-cancelled added to focusTimer so the bus can reset
  its inactivity state on cancel, not only on natural completion.
- nudgeBus started from +layout.svelte onMount, stopped on destroy.

Settings:
- New "Nudges" section with three toggles: Enable nudges, Mute for
  now (separate so a hard mute doesn't lose preferences),
  Speak nudges aloud (reuses Phase 4 TTS).
- All default OFF. No first-run prompt — nudges are a Settings-found
  feature rather than a walkthrough step.

Out of scope per the revised Phase 6 spec: OS-wide keyboard/window
hooks, biometric signals, custom trigger editor (Phase 7), notification
sound (platform variance too high for Layer-1 — revisit in Phase 9
polish with a bundled .wav).

Gates: fmt clean, clippy -D warnings clean, cargo test 262/0 (4
existing + 262 current), npm run check 0/0, npm run build green.
This commit is contained in:
2026-04-24 19:05:21 +01:00
parent b333c6229e
commit eebea8cb9a
19 changed files with 980 additions and 152 deletions

107
Cargo.lock generated
View File

@@ -2531,10 +2531,12 @@ dependencies = [
[[package]]
name = "js-sys"
version = "0.3.85"
version = "0.3.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3"
checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca"
dependencies = [
"cfg-if",
"futures-util",
"once_cell",
"wasm-bindgen",
]
@@ -2599,6 +2601,7 @@ dependencies = [
"tauri-plugin-autostart",
"tauri-plugin-dialog",
"tauri-plugin-global-shortcut",
"tauri-plugin-notification",
"tauri-plugin-opener",
"tauri-plugin-window-state",
"tokio",
@@ -2909,6 +2912,18 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mac-notification-sys"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3"
dependencies = [
"cc",
"objc2",
"objc2-foundation",
"time",
]
[[package]]
name = "mach2"
version = "0.5.0"
@@ -3187,6 +3202,20 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "notify-rust"
version = "4.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e551a9f0db223eaf3eb156906f99f46897fd951ee66dd1cb0be14db4d36d2fa"
dependencies = [
"futures-lite",
"log",
"mac-notification-sys",
"serde",
"tauri-winrt-notification",
"zbus",
]
[[package]]
name = "notify-types"
version = "1.0.1"
@@ -3867,7 +3896,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
dependencies = [
"base64 0.22.1",
"indexmap 2.14.0",
"quick-xml",
"quick-xml 0.38.4",
"serde",
"time",
]
@@ -3914,9 +3943,9 @@ dependencies = [
[[package]]
name = "portable-atomic"
version = "1.13.0"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "portable-atomic-util"
@@ -4056,6 +4085,15 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.37.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.38.4"
@@ -5738,6 +5776,25 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "tauri-plugin-notification"
version = "2.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc"
dependencies = [
"log",
"notify-rust",
"rand 0.9.4",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"time",
"url",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.5.3"
@@ -5875,6 +5932,18 @@ dependencies = [
"toml 0.9.12+spec-1.1.0",
]
[[package]]
name = "tauri-winrt-notification"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9"
dependencies = [
"quick-xml 0.37.5",
"thiserror 2.0.18",
"windows 0.61.3",
"windows-version",
]
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -6598,9 +6667,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.108"
version = "0.2.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566"
checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89"
dependencies = [
"cfg-if",
"once_cell",
@@ -6611,23 +6680,19 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.58"
version = "0.4.68"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f"
checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8"
dependencies = [
"cfg-if",
"futures-util",
"js-sys",
"once_cell",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.108"
version = "0.2.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608"
checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -6635,9 +6700,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.108"
version = "0.2.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55"
checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -6648,9 +6713,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.108"
version = "0.2.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12"
checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129"
dependencies = [
"unicode-ident",
]
@@ -6717,9 +6782,9 @@ dependencies = [
[[package]]
name = "web-sys"
version = "0.3.85"
version = "0.3.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598"
checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d"
dependencies = [
"js-sys",
"wasm-bindgen",

10
package-lock.json generated
View File

@@ -14,6 +14,7 @@
"@tauri-apps/plugin-autostart": "^2.5.1",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2",
"lucide-svelte": "^0.577.0",
"svelte-i18n": "^4.0.1"
@@ -1630,6 +1631,15 @@
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-notification": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
"integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-opener": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz",

View File

@@ -19,6 +19,7 @@
"@tauri-apps/plugin-autostart": "^2.5.1",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2",
"lucide-svelte": "^0.577.0",
"svelte-i18n": "^4.0.1"

View File

@@ -47,6 +47,10 @@ tauri-plugin-window-state = "2"
# Handles platform differences (.desktop file on Linux, LaunchAgents plist
# on macOS, registry Run key on Windows) behind a single API.
tauri-plugin-autostart = "2"
# Phase 6 nudges: OS-native notifications via the frontend-owned nudge
# bus. Permission-request flow happens on the JS side before the first
# deliver_nudge call; Windows only delivers for installed apps.
tauri-plugin-notification = "2"
# Serialisation
serde = { version = "1", features = ["derive"] }

View File

@@ -20,6 +20,9 @@
"global-shortcut:allow-unregister",
"autostart:allow-enable",
"autostart:allow-disable",
"autostart:allow-is-enabled"
"autostart:allow-is-enabled",
"notification:allow-is-permission-granted",
"notification:allow-request-permission",
"notification:allow-notify"
]
}

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"main":{"identifier":"main","description":"Main window capability for user-initiated app control.","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-set-always-on-top","core:window:allow-minimize","core:window:allow-toggle-maximize","core:window:allow-is-maximized","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-focus","opener:default","dialog:default","global-shortcut:allow-register","global-shortcut:allow-unregister","autostart:allow-enable","autostart:allow-disable","autostart:allow-is-enabled"]},"secondary-windows":{"identifier":"secondary-windows","description":"Narrow capability for passive secondary windows.","local":true,"windows":["tasks-float","transcript-viewer","transcription-preview"],"permissions":["core:event:default","core:window:allow-start-dragging","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-focus"]}}
{"main":{"identifier":"main","description":"Main window capability for user-initiated app control.","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-set-always-on-top","core:window:allow-minimize","core:window:allow-toggle-maximize","core:window:allow-is-maximized","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-focus","opener:default","dialog:default","global-shortcut:allow-register","global-shortcut:allow-unregister","autostart:allow-enable","autostart:allow-disable","autostart:allow-is-enabled","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify"]},"secondary-windows":{"identifier":"secondary-windows","description":"Narrow capability for passive secondary windows.","local":true,"windows":["tasks-float","transcript-viewer","transcription-preview"],"permissions":["core:event:default","core:window:allow-start-dragging","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-focus"]}}

View File

@@ -2355,22 +2355,22 @@
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"type": "string",
"const": "dialog:default",
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`"
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
},
{
"description": "Enables the ask command without any pre-configured scope.",
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-ask",
"markdownDescription": "Enables the ask command without any pre-configured scope."
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the confirm command without any pre-configured scope.",
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-confirm",
"markdownDescription": "Enables the confirm command without any pre-configured scope."
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the message command without any pre-configured scope.",
@@ -2391,16 +2391,16 @@
"markdownDescription": "Enables the save command without any pre-configured scope."
},
{
"description": "Denies the ask command without any pre-configured scope.",
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-ask",
"markdownDescription": "Denies the ask command without any pre-configured scope."
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the confirm command without any pre-configured scope.",
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-confirm",
"markdownDescription": "Denies the confirm command without any pre-configured scope."
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the message command without any pre-configured scope.",
@@ -2486,6 +2486,204 @@
"const": "global-shortcut:deny-unregister-all",
"markdownDescription": "Denies the unregister_all command without any pre-configured scope."
},
{
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
"type": "string",
"const": "notification:default",
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
},
{
"description": "Enables the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-batch",
"markdownDescription": "Enables the batch command without any pre-configured scope."
},
{
"description": "Enables the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-cancel",
"markdownDescription": "Enables the cancel command without any pre-configured scope."
},
{
"description": "Enables the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-check-permissions",
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
},
{
"description": "Enables the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-create-channel",
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
},
{
"description": "Enables the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-delete-channel",
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
},
{
"description": "Enables the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-active",
"markdownDescription": "Enables the get_active command without any pre-configured scope."
},
{
"description": "Enables the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-pending",
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
},
{
"description": "Enables the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-is-permission-granted",
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
},
{
"description": "Enables the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-list-channels",
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
},
{
"description": "Enables the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-notify",
"markdownDescription": "Enables the notify command without any pre-configured scope."
},
{
"description": "Enables the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-permission-state",
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
},
{
"description": "Enables the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-action-types",
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
},
{
"description": "Enables the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-listener",
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
},
{
"description": "Enables the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-remove-active",
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
},
{
"description": "Enables the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-request-permission",
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
},
{
"description": "Enables the show command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-show",
"markdownDescription": "Enables the show command without any pre-configured scope."
},
{
"description": "Denies the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-batch",
"markdownDescription": "Denies the batch command without any pre-configured scope."
},
{
"description": "Denies the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-cancel",
"markdownDescription": "Denies the cancel command without any pre-configured scope."
},
{
"description": "Denies the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-check-permissions",
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
},
{
"description": "Denies the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-create-channel",
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
},
{
"description": "Denies the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-delete-channel",
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
},
{
"description": "Denies the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-active",
"markdownDescription": "Denies the get_active command without any pre-configured scope."
},
{
"description": "Denies the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-pending",
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
},
{
"description": "Denies the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-is-permission-granted",
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
},
{
"description": "Denies the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-list-channels",
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
},
{
"description": "Denies the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-notify",
"markdownDescription": "Denies the notify command without any pre-configured scope."
},
{
"description": "Denies the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-permission-state",
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
},
{
"description": "Denies the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-action-types",
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
},
{
"description": "Denies the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-listener",
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
},
{
"description": "Denies the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-remove-active",
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
},
{
"description": "Denies the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-request-permission",
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
},
{
"description": "Denies the show command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-show",
"markdownDescription": "Denies the show command without any pre-configured scope."
},
{
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string",
@@ -2534,60 +2732,6 @@
"const": "opener:deny-reveal-item-in-dir",
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
},
{
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
"type": "string",
"const": "updater:default",
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
},
{
"description": "Enables the check command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-check",
"markdownDescription": "Enables the check command without any pre-configured scope."
},
{
"description": "Enables the download command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-download",
"markdownDescription": "Enables the download command without any pre-configured scope."
},
{
"description": "Enables the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-download-and-install",
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
},
{
"description": "Enables the install command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-install",
"markdownDescription": "Enables the install command without any pre-configured scope."
},
{
"description": "Denies the check command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-check",
"markdownDescription": "Denies the check command without any pre-configured scope."
},
{
"description": "Denies the download command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-download",
"markdownDescription": "Denies the download command without any pre-configured scope."
},
{
"description": "Denies the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-download-and-install",
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
},
{
"description": "Denies the install command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-install",
"markdownDescription": "Denies the install command without any pre-configured scope."
},
{
"description": "This permission set configures what kind of\noperations are available from the window state plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-filename`\n- `allow-restore-state`\n- `allow-save-window-state`",
"type": "string",

View File

@@ -2355,22 +2355,22 @@
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"type": "string",
"const": "dialog:default",
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`"
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
},
{
"description": "Enables the ask command without any pre-configured scope.",
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-ask",
"markdownDescription": "Enables the ask command without any pre-configured scope."
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the confirm command without any pre-configured scope.",
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-confirm",
"markdownDescription": "Enables the confirm command without any pre-configured scope."
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the message command without any pre-configured scope.",
@@ -2391,16 +2391,16 @@
"markdownDescription": "Enables the save command without any pre-configured scope."
},
{
"description": "Denies the ask command without any pre-configured scope.",
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-ask",
"markdownDescription": "Denies the ask command without any pre-configured scope."
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the confirm command without any pre-configured scope.",
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-confirm",
"markdownDescription": "Denies the confirm command without any pre-configured scope."
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the message command without any pre-configured scope.",
@@ -2486,6 +2486,204 @@
"const": "global-shortcut:deny-unregister-all",
"markdownDescription": "Denies the unregister_all command without any pre-configured scope."
},
{
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
"type": "string",
"const": "notification:default",
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
},
{
"description": "Enables the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-batch",
"markdownDescription": "Enables the batch command without any pre-configured scope."
},
{
"description": "Enables the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-cancel",
"markdownDescription": "Enables the cancel command without any pre-configured scope."
},
{
"description": "Enables the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-check-permissions",
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
},
{
"description": "Enables the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-create-channel",
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
},
{
"description": "Enables the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-delete-channel",
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
},
{
"description": "Enables the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-active",
"markdownDescription": "Enables the get_active command without any pre-configured scope."
},
{
"description": "Enables the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-pending",
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
},
{
"description": "Enables the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-is-permission-granted",
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
},
{
"description": "Enables the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-list-channels",
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
},
{
"description": "Enables the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-notify",
"markdownDescription": "Enables the notify command without any pre-configured scope."
},
{
"description": "Enables the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-permission-state",
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
},
{
"description": "Enables the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-action-types",
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
},
{
"description": "Enables the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-listener",
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
},
{
"description": "Enables the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-remove-active",
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
},
{
"description": "Enables the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-request-permission",
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
},
{
"description": "Enables the show command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-show",
"markdownDescription": "Enables the show command without any pre-configured scope."
},
{
"description": "Denies the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-batch",
"markdownDescription": "Denies the batch command without any pre-configured scope."
},
{
"description": "Denies the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-cancel",
"markdownDescription": "Denies the cancel command without any pre-configured scope."
},
{
"description": "Denies the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-check-permissions",
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
},
{
"description": "Denies the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-create-channel",
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
},
{
"description": "Denies the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-delete-channel",
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
},
{
"description": "Denies the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-active",
"markdownDescription": "Denies the get_active command without any pre-configured scope."
},
{
"description": "Denies the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-pending",
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
},
{
"description": "Denies the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-is-permission-granted",
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
},
{
"description": "Denies the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-list-channels",
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
},
{
"description": "Denies the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-notify",
"markdownDescription": "Denies the notify command without any pre-configured scope."
},
{
"description": "Denies the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-permission-state",
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
},
{
"description": "Denies the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-action-types",
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
},
{
"description": "Denies the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-listener",
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
},
{
"description": "Denies the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-remove-active",
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
},
{
"description": "Denies the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-request-permission",
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
},
{
"description": "Denies the show command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-show",
"markdownDescription": "Denies the show command without any pre-configured scope."
},
{
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string",
@@ -2534,60 +2732,6 @@
"const": "opener:deny-reveal-item-in-dir",
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
},
{
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
"type": "string",
"const": "updater:default",
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
},
{
"description": "Enables the check command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-check",
"markdownDescription": "Enables the check command without any pre-configured scope."
},
{
"description": "Enables the download command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-download",
"markdownDescription": "Enables the download command without any pre-configured scope."
},
{
"description": "Enables the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-download-and-install",
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
},
{
"description": "Enables the install command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-install",
"markdownDescription": "Enables the install command without any pre-configured scope."
},
{
"description": "Denies the check command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-check",
"markdownDescription": "Denies the check command without any pre-configured scope."
},
{
"description": "Denies the download command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-download",
"markdownDescription": "Denies the download command without any pre-configured scope."
},
{
"description": "Denies the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-download-and-install",
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
},
{
"description": "Denies the install command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-install",
"markdownDescription": "Denies the install command without any pre-configured scope."
},
{
"description": "This permission set configures what kind of\noperations are available from the window state plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-filename`\n- `allow-restore-state`\n- `allow-save-window-state`",
"type": "string",

View File

@@ -8,6 +8,7 @@ pub mod live;
pub mod llm;
pub mod meeting;
pub mod models;
pub mod nudges;
pub mod paste;
pub mod power;
pub mod profiles;

View File

@@ -0,0 +1,63 @@
//! Phase 6 of the feature-complete roadmap: Margot soft-touch nudges.
//!
//! The nudge bus lives in the frontend (`nudgeBus.svelte.ts`) — it
//! owns trigger subscription, suppression rules, and the hourly cap.
//! This module is the thin Rust side that:
//!
//! - Guards delivery to the main window only (secondary windows like
//! the task float can't fire notifications at the user — they're
//! already beside the user's attention).
//! - Delegates to `tauri-plugin-notification` for cross-platform OS
//! delivery. The plugin handles the macOS Notification Center /
//! Linux org.freedesktop.Notifications / Windows toast plumbing.
//!
//! Permission handling happens on the JS side via the plugin's
//! `isPermissionGranted` / `requestPermission` API — before the first
//! `deliver_nudge` call, the nudge bus prompts the user. If denied,
//! subsequent calls return an error that the bus logs and swallows.
//!
//! No persistence for v1. Cooldown state is ephemeral; the roadmap's
//! nudge-audit table is deferred until a real need emerges.
use serde::Deserialize;
use tauri_plugin_notification::NotificationExt;
use crate::commands::security::ensure_main_window;
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeliverNudgeInput {
pub title: String,
pub body: String,
}
/// Deliver a single OS notification. Does not apply suppression
/// logic — the frontend nudge bus is responsible for cadence and
/// cooldown, so this command is a blunt "push it now" primitive.
///
/// Returns an error if the notification plugin refuses the call
/// (typically because permission was denied by the user at the OS
/// level). The nudge bus logs and swallows these — nudges are
/// fire-and-forget and must never surface errors to the user.
#[tauri::command]
pub fn deliver_nudge(
app: tauri::AppHandle,
window: tauri::WebviewWindow,
input: DeliverNudgeInput,
) -> Result<(), String> {
ensure_main_window(&window)?;
let title = input.title.trim();
let body = input.body.trim();
if title.is_empty() && body.is_empty() {
// A blank nudge is worse than no nudge — stay quiet.
return Ok(());
}
app.notification()
.builder()
.title(if title.is_empty() { "Corbie" } else { title })
.body(body)
.show()
.map_err(|e| format!("notification delivery failed: {e}"))
}

View File

@@ -138,6 +138,12 @@ pub fn run() {
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
None,
))
// Phase 6 nudges: OS-native notifications. The plugin exposes
// isPermissionGranted / requestPermission / sendNotification on
// the JS side; our deliver_nudge wrapper guards those calls
// against the nudge-bus suppression rules and restricts
// invocation to the main window via ensure_main_window.
.plugin(tauri_plugin_notification::init())
// Remember size + position of every window across app restarts.
// Without this, secondary windows (preview overlay, task float,
// transcript viewer) open at whatever spot the compositor picks,
@@ -321,6 +327,8 @@ pub fn run() {
// Rituals (Phase 5 roadmap)
commands::rituals::get_last_morning_triage,
commands::rituals::mark_morning_triage_shown,
// Nudges (Phase 6 roadmap)
commands::nudges::deliver_nudge,
// Profiles + profile terms (canonical SQLite-backed profile CRUD) — Task 12
commands::profiles::list_profiles_cmd,
commands::profiles::get_profile_cmd,

View File

@@ -53,6 +53,15 @@
parentTaskId,
profileId: profilesStore.activeProfileId,
});
// Phase 6 nudge-bus signal. The bus schedules a 15-min idle
// check on this parent task id — if no step or the task itself
// is completed in that window, a gentle "still with that one?"
// nudge fires.
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('kon:microstep-generated', {
detail: { parentTaskId },
}));
}
} catch (e) {
error = String(e);
} finally {
@@ -65,6 +74,14 @@
await invoke('complete_subtask_cmd', { subtaskId });
const idx = subtasks.findIndex(s => s.id === subtaskId);
if (idx >= 0) subtasks[idx] = { ...subtasks[idx], done: true };
// Phase 6 nudge-bus signal. Any completed step clears the
// micro-step-idle timer for this parent task — the breakdown
// is demonstrably getting worked, no nudge needed.
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('kon:step-completed', {
detail: { id: subtaskId, parentTaskId },
}));
}
} catch (_) {}
}

View File

@@ -1532,6 +1532,45 @@
{/if}
</div>
<!-- Nudges (Phase 6 roadmap) -->
<div class="border-b border-border-subtle">
<button
class="flex items-center justify-between w-full py-4 px-5 text-left"
onclick={() => openSection = openSection === 'nudges' ? null : 'nudges'}
>
<h3 class="font-display text-[18px] italic text-text">Nudges</h3>
<span class="text-text-tertiary text-[16px] leading-none">{openSection === 'nudges' ? '' : '+'}</span>
</button>
{#if openSection === 'nudges'}
<div class="px-5 pb-5 animate-fade-in">
<p class="text-[11px] text-text-tertiary mb-4">
Gentle, anticipatory reminders. Capped at 3 per hour. Never fires while you're looking at Corbie.
</p>
<Toggle
bind:checked={settings.nudgesEnabled}
label="Enable nudges"
description="Soft-touch notifications when a timer has been ticking while you're away, when a morning triage is waiting, or when a micro-step decomposition has sat untouched for 15 minutes."
/>
{#if settings.nudgesEnabled}
<div class="pl-1 py-1 animate-fade-in">
<Toggle
bind:checked={settings.nudgesMuted}
label="Mute for now"
description="Stops delivery without forgetting your settings. Flip back off when you want the nudges back."
/>
<Toggle
bind:checked={settings.nudgesSpeakAloud}
label="Speak nudges aloud"
description="Also reads the nudge out through your Read-aloud voice. Useful if you keep the sound off but look up sometimes."
/>
</div>
{/if}
</div>
{/if}
</div>
<!-- Read aloud (Phase 4 roadmap) -->
<div class="border-b border-border-subtle">
<button

View File

@@ -100,6 +100,11 @@ function createFocusTimerStore() {
}
function clear() {
// Broadcast cancellation so downstream listeners (Phase 6 nudge bus)
// can reset their inactivity-while-timer state. Only fires when a
// timer was actually active — dismissing the completion flash
// after natural completion doesn't re-fire the event.
const wasActive = startedAt !== null && !completionFired;
startedAt = null;
durationMs = 0;
taskId = null;
@@ -108,6 +113,9 @@ function createFocusTimerStore() {
completionFired = false;
writePersisted(null);
stopTick();
if (wasActive && typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("kon:focus-timer-cancelled"));
}
}
function start(seconds: number, opts?: { taskId?: string | null; label?: string | null }): void {

View File

@@ -0,0 +1,292 @@
// Phase 6 Margot nudge bus. Frontend-owned, subscribes to in-app
// signals Corbie already produces, applies suppression, and fans out
// to OS notification (via Rust's deliver_nudge) + optional TTS.
//
// Why frontend-only: OS-wide keyboard/window detection is fragile
// across Wayland/macOS/Windows (no sanctioned global-keyboard API on
// Wayland, accessibility permission on macOS, message-loop hook on
// Windows). The app-local signals we already have — timer state,
// task completion, micro-step creation, app focus/visibility — cover
// the Phase 6 trigger set without the platform pain.
//
// Suppression rules:
// - nudgesEnabled && !nudgesMuted
// - document has focus → never nudge (user is already looking)
// - rolling 1-hour cap of 3 nudges
// - timer-specific: no nudge in first 60 s of a running timer
//
// Trigger set v1 (in-app only):
// - inactivity_with_active_timer
// - pending_morning_triage
// - micro_step_idle
import { invoke } from "@tauri-apps/api/core";
import { hasTauriRuntime } from "$lib/utils/runtime.js";
import { settings } from "$lib/stores/page.svelte.js";
const HOUR_MS = 60 * 60 * 1000;
const INACTIVITY_TIMER_THRESHOLD_MS = 90_000;
const TIMER_WARMUP_MS = 60_000;
const MICRO_STEP_IDLE_MS = 15 * 60_000;
const MORNING_TRIAGE_CHECK_INTERVAL_MS = 5 * 60_000;
const MORNING_TRIAGE_TRIGGER_HOUR = 10;
interface TimerStartPayload {
taskId?: string;
seconds?: number;
}
interface MicroStepPayload {
parentTaskId: string;
}
let started = false;
let permissionRequested = false;
let permissionGranted = false;
// Rolling timestamps of recent nudge deliveries (ms since epoch).
// Older-than-one-hour entries are pruned lazily on each check.
const recentNudges: number[] = [];
// Per-trigger state.
let timerRunning = false;
let timerStartedAt = 0;
let timerNudgeFiredThisSession = false;
let blurredAt: number | null = null;
let blurCheckHandle: ReturnType<typeof setInterval> | null = null;
let triagePollHandle: ReturnType<typeof setInterval> | null = null;
// parentTaskId → scheduled timeout handle for "idle since step created".
// Cleared when a step or parent task gets marked done in the same session.
const microStepTimers = new Map<string, ReturnType<typeof setTimeout>>();
// --- Permission ------------------------------------------------------
async function ensurePermission(): Promise<boolean> {
if (!hasTauriRuntime()) return false;
if (permissionRequested) return permissionGranted;
permissionRequested = true;
try {
const plugin = await import("@tauri-apps/plugin-notification");
const already = await plugin.isPermissionGranted();
if (already) {
permissionGranted = true;
return true;
}
const result = await plugin.requestPermission();
permissionGranted = result === "granted";
return permissionGranted;
} catch {
permissionGranted = false;
return false;
}
}
// --- Suppression -----------------------------------------------------
function pruneRecentNudges(now: number): void {
while (recentNudges.length > 0 && now - recentNudges[0] > HOUR_MS) {
recentNudges.shift();
}
}
function canNudgeNow(now: number): boolean {
if (!settings.nudgesEnabled) return false;
if (settings.nudgesMuted) return false;
if (typeof document !== "undefined" && document.hasFocus()) return false;
pruneRecentNudges(now);
if (recentNudges.length >= 3) return false;
return true;
}
// --- Delivery --------------------------------------------------------
async function deliver(title: string, body: string): Promise<void> {
const now = Date.now();
if (!canNudgeNow(now)) return;
const ok = await ensurePermission();
if (!ok) return;
recentNudges.push(now);
try {
await invoke("deliver_nudge", { input: { title, body } });
} catch {
// Nudges are fire-and-forget — surfacing an error to the user
// would defeat the "anticipatory, not push-notification" framing.
}
if (settings.nudgesSpeakAloud) {
try {
await invoke("tts_speak", {
text: body || title,
rate: settings.ttsRate,
voice: settings.ttsVoice ?? null,
});
} catch {
// Same reasoning — never surface TTS failures through a nudge.
}
}
}
// --- Trigger: inactivity with active timer ---------------------------
function onTimerStart(event: Event) {
const detail = (event as CustomEvent<TimerStartPayload>).detail;
timerRunning = true;
timerStartedAt = Date.now();
timerNudgeFiredThisSession = false;
// If the user switched away before starting the timer, the next
// blur check (below) will pick it up.
void detail;
}
function resetTimerState() {
timerRunning = false;
timerStartedAt = 0;
timerNudgeFiredThisSession = false;
}
function onFocus() {
blurredAt = null;
}
function onBlur() {
blurredAt = Date.now();
}
function checkInactivityWithActiveTimer(now: number) {
if (!timerRunning || timerNudgeFiredThisSession) return;
if (now - timerStartedAt < TIMER_WARMUP_MS) return;
if (blurredAt === null) return;
if (now - blurredAt < INACTIVITY_TIMER_THRESHOLD_MS) return;
timerNudgeFiredThisSession = true;
void deliver(
"Still on that timer?",
"It's been ticking while you've been away. Pick up where you left off, or stop it.",
);
}
// --- Trigger: pending morning triage ---------------------------------
function todayLocalKey(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
let triageNudgeFiredOnKey: string | null = null;
async function checkPendingMorningTriage() {
if (!settings.ritualsMorning) return;
if (!hasTauriRuntime()) return;
const now = new Date();
if (now.getHours() < MORNING_TRIAGE_TRIGGER_HOUR) return;
const today = todayLocalKey();
if (triageNudgeFiredOnKey === today) return;
let lastShown: string | null = null;
try {
lastShown = await invoke<string | null>("get_last_morning_triage");
} catch {
return;
}
if (lastShown === today) return;
triageNudgeFiredOnKey = today;
void deliver(
"A few things waiting",
"When you're ready, Corbie has your morning list. No rush.",
);
}
// --- Trigger: micro-step idle ----------------------------------------
function onMicroStepGenerated(event: Event) {
const detail = (event as CustomEvent<MicroStepPayload>).detail;
if (!detail?.parentTaskId) return;
const parentTaskId = detail.parentTaskId;
clearMicroStepTimer(parentTaskId);
const handle = setTimeout(() => {
microStepTimers.delete(parentTaskId);
void deliver(
"Still with that one?",
"Your breakdown is here when you want to pick the first step.",
);
}, MICRO_STEP_IDLE_MS);
microStepTimers.set(parentTaskId, handle);
}
function clearMicroStepTimer(parentTaskId: string) {
const handle = microStepTimers.get(parentTaskId);
if (handle !== undefined) {
clearTimeout(handle);
microStepTimers.delete(parentTaskId);
}
}
function onStepOrTaskCompleted(event: Event) {
const detail = (event as CustomEvent<{ id?: string; parentTaskId?: string }>).detail;
// A completed subtask clears its parent's idle timer; a completed
// parent task clears its own idle timer (the step decomposition
// attached to that task is no longer waiting on anyone).
if (detail?.parentTaskId) clearMicroStepTimer(detail.parentTaskId);
if (detail?.id) clearMicroStepTimer(detail.id);
}
// --- Lifecycle -------------------------------------------------------
export function startNudgeBus(): void {
if (started) return;
if (typeof window === "undefined") return;
started = true;
window.addEventListener("kon:start-timer", onTimerStart);
window.addEventListener("kon:focus-timer-complete", resetTimerState);
window.addEventListener("kon:focus-timer-cancelled", resetTimerState);
window.addEventListener("kon:microstep-generated", onMicroStepGenerated);
window.addEventListener("kon:step-completed", onStepOrTaskCompleted);
window.addEventListener("kon:task-completed", onStepOrTaskCompleted);
window.addEventListener("focus", onFocus);
window.addEventListener("blur", onBlur);
// Inactivity check runs while a timer is live. Low frequency is fine
// — the nudge itself only fires once the 90 s threshold is crossed.
blurCheckHandle = setInterval(() => {
checkInactivityWithActiveTimer(Date.now());
}, 10_000);
// Morning triage check runs on an interval + one immediate probe on
// start. The Rust `get_last_morning_triage` call is cheap (one
// SQLite read), so polling every 5 min costs nothing and means we
// catch the 10:00 threshold without a scheduler.
void checkPendingMorningTriage();
triagePollHandle = setInterval(() => {
void checkPendingMorningTriage();
}, MORNING_TRIAGE_CHECK_INTERVAL_MS);
}
export function stopNudgeBus(): void {
if (!started) return;
started = false;
window.removeEventListener("kon:start-timer", onTimerStart);
window.removeEventListener("kon:focus-timer-complete", resetTimerState);
window.removeEventListener("kon:focus-timer-cancelled", resetTimerState);
window.removeEventListener("kon:microstep-generated", onMicroStepGenerated);
window.removeEventListener("kon:step-completed", onStepOrTaskCompleted);
window.removeEventListener("kon:task-completed", onStepOrTaskCompleted);
window.removeEventListener("focus", onFocus);
window.removeEventListener("blur", onBlur);
if (blurCheckHandle !== null) {
clearInterval(blurCheckHandle);
blurCheckHandle = null;
}
if (triagePollHandle !== null) {
clearInterval(triagePollHandle);
triagePollHandle = null;
}
for (const handle of microStepTimers.values()) clearTimeout(handle);
microStepTimers.clear();
resetTimerState();
blurredAt = null;
triageNudgeFiredOnKey = null;
permissionRequested = false;
}

View File

@@ -79,6 +79,9 @@ const defaults: SettingsState = {
ritualsEvening: false,
launchAtLogin: false,
ritualsPromptSeen: false,
nudgesEnabled: false,
nudgesMuted: false,
nudgesSpeakAloud: false,
};
function canUseStorage(): boolean {
@@ -453,6 +456,12 @@ export async function completeTask(id: string) {
try {
await invoke("complete_task_cmd", { id });
applyLocalTaskUpdate(id, { done: true, doneAt: new Date().toISOString() });
// Phase 6 nudge-bus signal. Fire-and-forget; the bus subscribes
// to this to clear micro-step-idle timers for the completed task
// and, later, to drive Phase 7 "after a task completes" rules.
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("kon:task-completed", { detail: { id } }));
}
} catch (err) {
toasts.error("Couldn't complete task", errorMessage(err));
}

View File

@@ -105,6 +105,18 @@ export interface SettingsState {
* prompt from re-appearing on every update-triggered reboot.
*/
ritualsPromptSeen: boolean;
/**
* Phase 6 Margot nudges. All off by default per Jake's standing rule
* about opt-in nudging. `nudgesMuted` is separate from `nudgesEnabled`
* so the user can flip a hard mute without losing their preferences.
*/
nudgesEnabled: boolean;
nudgesMuted: boolean;
/**
* When true, nudges also speak their body through Phase 4 TTS. Only
* has effect when `nudgesEnabled` is true.
*/
nudgesSpeakAloud: boolean;
}
export interface Profile {

View File

@@ -25,6 +25,7 @@
import { toasts } from "$lib/stores/toasts.svelte.js";
import { initI18n } from "$lib/i18n";
import { refreshLlmStatus } from "$lib/stores/llmStatus.svelte.js";
import { startNudgeBus, stopNudgeBus } from "$lib/stores/nudgeBus.svelte.ts";
import { page as sveltePage } from "$app/stores";
@@ -277,6 +278,12 @@
// Phase 5: subscribe to tray wind-down event.
setupWindDownListener();
// Phase 6: start the nudge bus. The bus is self-gated on
// settings.nudgesEnabled internally — starting it here is cheap
// (just event listeners + two intervals), and means users don't
// need to restart after flipping the toggle.
startNudgeBus();
// Diagnostics: capture every uncaught frontend error to error_log.
installGlobalErrorCapture();
@@ -384,6 +391,7 @@
if (unlistenWindDown) {
unlistenWindDown();
}
stopNudgeBus();
});
</script>