feat(nudges): Phase 6 — Margot soft-touch nudges via frontend nudge bus
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:
@@ -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 {
|
||||
|
||||
292
src/lib/stores/nudgeBus.svelte.ts
Normal file
292
src/lib/stores/nudgeBus.svelte.ts
Normal 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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user