feat(nudges): B3.2 — notifications digest mode

settings.nudgeMode (Off / Immediate / Digest) replaces the binary
nudgesEnabled toggle in the Settings UI; the field stays in storage
mirrored from nudgeMode for back-compat readers. Digest mode queues
trigger events (deduped by key) into a localStorage-persisted buffer
and delivers them as a single aggregated notification at user-chosen
times of day (1–3 slots). Body shows up to 3 lines with a "...and
N more" truncation tail and honours nudgesSpeakAloud. Switching from
digest to off clears the queue; digest to immediate replays.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-26 19:45:11 +01:00
parent 2668401104
commit 423c0caca4
2 changed files with 414 additions and 18 deletions

View File

@@ -24,6 +24,7 @@
// that just mutates the page-local llmLoaded bool. The store // that just mutates the page-local llmLoaded bool. The store
// version drives the sidebar chip (brief item #31). // version drives the sidebar chip (brief item #31).
import { refreshLlmStatus as refreshGlobalLlmStatus, markError as markGlobalLlmError, markLoading as markGlobalLlmLoading } from "$lib/stores/llmStatus.svelte.js"; import { refreshLlmStatus as refreshGlobalLlmStatus, markError as markGlobalLlmError, markLoading as markGlobalLlmLoading } from "$lib/stores/llmStatus.svelte.js";
import { handleNudgeModeChange } from "$lib/stores/nudgeBus.svelte.ts";
const prefs = getPreferences(); const prefs = getPreferences();
@@ -841,6 +842,68 @@
} }
}); });
// B3.2 — nudge mode picker. SegmentedButton works on string options;
// settings.nudgeMode is the lowercase enum. Mirror via display labels
// and call the bus' mode-switch hygiene hook on each transition.
const NUDGE_MODE_LABELS = ["Off", "Immediate", "Digest"] as const;
function modeFromLabel(label: string): "off" | "immediate" | "digest" {
if (label === "Immediate") return "immediate";
if (label === "Digest") return "digest";
return "off";
}
function labelFromMode(mode: "off" | "immediate" | "digest"): string {
if (mode === "immediate") return "Immediate";
if (mode === "digest") return "Digest";
return "Off";
}
let nudgeModeLabel = $state(labelFromMode(settings.nudgeMode ?? "off"));
$effect(() => {
const next = modeFromLabel(nudgeModeLabel);
const prev = settings.nudgeMode;
if (prev !== next) {
// Update both fields together: the back-compat boolean stays in
// lockstep so older readers (and the legacy fallback inside
// nudgeBus) see a consistent value.
settings.nudgeMode = next;
settings.nudgesEnabled = next !== "off";
saveSettings();
// Run mode-switch hygiene (clear queue / replay queue) only
// when prev was a real value, not the initial undefined that
// can hit during the very first $effect tick.
if (prev === "off" || prev === "immediate" || prev === "digest") {
void handleNudgeModeChange(prev, next);
}
}
});
$effect(() => {
const label = labelFromMode(settings.nudgeMode ?? "off");
if (nudgeModeLabel !== label) nudgeModeLabel = label;
});
// B3.2 — digest-times editor. The user picks 1-3 HH:MM slots; we
// validate strictly (no "8:00", no "24:00") before writing.
let digestTimeDraft = $state("08:00");
function isValidHHMM(value: string): boolean {
return /^([01]\d|2[0-3]):([0-5]\d)$/.test(value);
}
function addDigestTime() {
const v = digestTimeDraft;
if (!isValidHHMM(v)) return;
const list = Array.isArray(settings.nudgeDigestTimes) ? [...settings.nudgeDigestTimes] : [];
if (list.length >= 3) return;
if (list.includes(v)) return;
list.push(v);
list.sort();
settings.nudgeDigestTimes = list;
saveSettings();
}
function removeDigestTime(t: string) {
const list = Array.isArray(settings.nudgeDigestTimes) ? [...settings.nudgeDigestTimes] : [];
if (list.length <= 1) return;
settings.nudgeDigestTimes = list.filter((x) => x !== t);
saveSettings();
}
async function downloadModel(size) { async function downloadModel(size) {
downloadingModel = size; downloadingModel = size;
downloadProgress = 0; downloadProgress = 0;
@@ -1732,13 +1795,62 @@
Gentle, anticipatory reminders. Capped at 3 per hour. Never fires while you're looking at Corbie. Gentle, anticipatory reminders. Capped at 3 per hour. Never fires while you're looking at Corbie.
</p> </p>
<Toggle <!-- B3.2 — Off / Immediate / Digest. The chosen mode drives
bind:checked={settings.nudgesEnabled} nudgeBus.canNudgeNow + the digest queue/scheduler. The
label="Enable nudges" legacy nudgesEnabled boolean is mirrored from this
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." picker for back-compat readers. -->
/> <div class="flex items-center justify-between gap-3 mb-2">
<p class="text-[12px] text-text-secondary">Delivery</p>
<SegmentedButton options={[...NUDGE_MODE_LABELS]} bind:value={nudgeModeLabel} size="small" />
</div>
<p class="text-[11px] text-text-tertiary mb-3">
{#if nudgeModeLabel === "Off"}
Nudges are off. Triggers won't fire and nothing is queued.
{:else if nudgeModeLabel === "Immediate"}
Nudges fire when the trigger happens — capped at 3 per hour, never while Corbie has focus.
{:else}
Triggers are batched into a single notification at your chosen time(s) below.
{/if}
</p>
{#if settings.nudgesEnabled} {#if nudgeModeLabel === "Digest"}
<div class="mt-3 mb-2 pl-1 animate-fade-in">
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Digest times</p>
<div class="flex flex-wrap gap-2 mb-3">
{#each (settings.nudgeDigestTimes ?? []) as t}
<span class="inline-flex items-center gap-1.5 bg-bg-elevated rounded-full px-2.5 py-1 text-[12px] text-text">
{t}
<button
class="text-text-tertiary hover:text-text leading-none text-[14px] disabled:opacity-30 disabled:cursor-not-allowed"
onclick={() => removeDigestTime(t)}
disabled={(settings.nudgeDigestTimes?.length ?? 0) <= 1}
aria-label="Remove {t}"
>×</button>
</span>
{/each}
</div>
<div class="flex items-center gap-2">
<input
type="time"
bind:value={digestTimeDraft}
class="bg-bg-elevated border border-border-subtle rounded-md px-2 py-1 text-[12px] text-text"
/>
<button
class="text-[12px] px-3 py-1 rounded-md bg-accent text-bg disabled:opacity-40 disabled:cursor-not-allowed"
onclick={addDigestTime}
disabled={(settings.nudgeDigestTimes?.length ?? 0) >= 3 || !isValidHHMM(digestTimeDraft) || (settings.nudgeDigestTimes ?? []).includes(digestTimeDraft)}
>Add</button>
</div>
{#if (settings.nudgeDigestTimes?.length ?? 0) <= 1}
<p class="text-[11px] text-text-tertiary mt-2">At least one time required for digest.</p>
{/if}
{#if (settings.nudgeDigestTimes?.length ?? 0) >= 3}
<p class="text-[11px] text-text-tertiary mt-2">Maximum of three times. Remove one to add another.</p>
{/if}
</div>
{/if}
{#if nudgeModeLabel !== "Off"}
<div class="pl-1 py-1 animate-fade-in"> <div class="pl-1 py-1 animate-fade-in">
<Toggle <Toggle
bind:checked={settings.nudgesMuted} bind:checked={settings.nudgesMuted}

View File

@@ -9,8 +9,10 @@
// task completion, micro-step creation, app focus/visibility — cover // task completion, micro-step creation, app focus/visibility — cover
// the Phase 6 trigger set without the platform pain. // the Phase 6 trigger set without the platform pain.
// //
// Suppression rules: // Suppression rules (immediate mode):
// - nudgesEnabled && !nudgesMuted // - settings.nudgeMode === "immediate" (else: off → drop, digest → queue)
// - !nudgesMuted (mute always wins, including over digest)
// - !inFreshStartWindow (B3.1)
// - document has focus → never nudge (user is already looking) // - document has focus → never nudge (user is already looking)
// - rolling 1-hour cap of 3 nudges // - rolling 1-hour cap of 3 nudges
// - timer-specific: no nudge in first 60 s of a running timer // - timer-specific: no nudge in first 60 s of a running timer
@@ -19,6 +21,12 @@
// - inactivity_with_active_timer // - inactivity_with_active_timer
// - pending_morning_triage // - pending_morning_triage
// - micro_step_idle // - micro_step_idle
//
// B3.2 — Digest mode: triggers still fire, but instead of calling
// deliver() they push to a localStorage-persisted queue (deduped by
// key). On a 5-minute interval the bus checks the user's configured
// digest times-of-day; when one is reached for today and not yet
// fulfilled, it aggregates the queue into one notification.
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { hasTauriRuntime } from "$lib/utils/runtime.js"; import { hasTauriRuntime } from "$lib/utils/runtime.js";
@@ -26,12 +34,17 @@ import { settings } from "$lib/stores/page.svelte.js";
import { inFreshStartWindow } from "$lib/utils/freshStart.js"; import { inFreshStartWindow } from "$lib/utils/freshStart.js";
const HOUR_MS = 60 * 60 * 1000; const HOUR_MS = 60 * 60 * 1000;
const DAY_MS = 24 * HOUR_MS;
const INACTIVITY_TIMER_THRESHOLD_MS = 90_000; const INACTIVITY_TIMER_THRESHOLD_MS = 90_000;
const TIMER_WARMUP_MS = 60_000; const TIMER_WARMUP_MS = 60_000;
const MICRO_STEP_IDLE_MS = 15 * 60_000; const MICRO_STEP_IDLE_MS = 15 * 60_000;
const MORNING_TRIAGE_CHECK_INTERVAL_MS = 5 * 60_000; const MORNING_TRIAGE_CHECK_INTERVAL_MS = 5 * 60_000;
const MORNING_TRIAGE_TRIGGER_HOUR = 10; const MORNING_TRIAGE_TRIGGER_HOUR = 10;
const DIGEST_QUEUE_KEY = "kon.nudge.digest.v1";
const DIGEST_LAST_DELIVERY_KEY = "kon.nudge.digest.lastDelivery.v1";
const DIGEST_CHECK_INTERVAL_MS = 5 * 60_000;
interface TimerStartPayload { interface TimerStartPayload {
taskId?: string; taskId?: string;
seconds?: number; seconds?: number;
@@ -41,6 +54,13 @@ interface MicroStepPayload {
parentTaskId: string; parentTaskId: string;
} }
interface DigestQueueItem {
key: string;
title: string;
body: string;
queuedAt: number;
}
let started = false; let started = false;
let permissionRequested = false; let permissionRequested = false;
let permissionGranted = false; let permissionGranted = false;
@@ -57,11 +77,23 @@ let blurredAt: number | null = null;
let blurCheckHandle: ReturnType<typeof setInterval> | null = null; let blurCheckHandle: ReturnType<typeof setInterval> | null = null;
let triagePollHandle: ReturnType<typeof setInterval> | null = null; let triagePollHandle: ReturnType<typeof setInterval> | null = null;
let digestCheckHandle: ReturnType<typeof setInterval> | null = null;
// parentTaskId → scheduled timeout handle for "idle since step created". // parentTaskId → scheduled timeout handle for "idle since step created".
// Cleared when a step or parent task gets marked done in the same session. // Cleared when a step or parent task gets marked done in the same session.
const microStepTimers = new Map<string, ReturnType<typeof setTimeout>>(); const microStepTimers = new Map<string, ReturnType<typeof setTimeout>>();
// --- Mode helpers ----------------------------------------------------
// `nudgeMode` is the source of truth; if for any reason it's missing
// (older blob that hasn't passed through loadSettings yet) fall back
// to the legacy boolean. This mirrors the migration in page.svelte.ts.
function getNudgeMode(): "off" | "immediate" | "digest" {
const mode = settings.nudgeMode;
if (mode === "off" || mode === "immediate" || mode === "digest") return mode;
return settings.nudgesEnabled ? "immediate" : "off";
}
// --- Permission ------------------------------------------------------ // --- Permission ------------------------------------------------------
async function ensurePermission(): Promise<boolean> { async function ensurePermission(): Promise<boolean> {
@@ -93,8 +125,14 @@ function pruneRecentNudges(now: number): void {
} }
function canNudgeNow(now: number): boolean { function canNudgeNow(now: number): boolean {
if (!settings.nudgesEnabled) return false; const mode = getNudgeMode();
// Off path: no nudges, ever (including from a digest replay).
if (mode === "off") return false;
// Mute always wins, even over digest delivery.
if (settings.nudgesMuted) return false; if (settings.nudgesMuted) return false;
// Digest mode never fires the immediate path; the digest scheduler
// calls deliverDigestPayload() directly which bypasses canNudgeNow.
if (mode === "digest") return false;
// B3.1 — fresh-start window stays quiet on every trigger. The window // B3.1 — fresh-start window stays quiet on every trigger. The window
// is the user explicitly being told "this week starts fresh"; an OS // is the user explicitly being told "this week starts fresh"; an OS
// notification an hour later would directly contradict that frame. // notification an hour later would directly contradict that frame.
@@ -105,7 +143,7 @@ function canNudgeNow(now: number): boolean {
return true; return true;
} }
// --- Delivery -------------------------------------------------------- // --- Delivery (immediate) -------------------------------------------
async function deliver(title: string, body: string): Promise<void> { async function deliver(title: string, body: string): Promise<void> {
const now = Date.now(); const now = Date.now();
@@ -132,6 +170,241 @@ async function deliver(title: string, body: string): Promise<void> {
} }
} }
// --- Digest queue (B3.2) --------------------------------------------
function loadDigestQueue(): DigestQueueItem[] {
if (typeof localStorage === "undefined") return [];
const raw = localStorage.getItem(DIGEST_QUEUE_KEY);
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter(
(item): item is DigestQueueItem =>
item
&& typeof item.key === "string"
&& typeof item.title === "string"
&& typeof item.body === "string"
&& typeof item.queuedAt === "number",
);
} catch {
return [];
}
}
function saveDigestQueue(queue: DigestQueueItem[]): void {
if (typeof localStorage === "undefined") return;
try {
if (queue.length === 0) {
localStorage.removeItem(DIGEST_QUEUE_KEY);
} else {
localStorage.setItem(DIGEST_QUEUE_KEY, JSON.stringify(queue));
}
} catch {
// Storage full / private mode — silently drop. The next trigger
// will try again; we don't want to surface an error here.
}
}
function pruneDigestQueue(queue: DigestQueueItem[], now: number): DigestQueueItem[] {
return queue.filter((item) => now - item.queuedAt < DAY_MS);
}
function enqueueDigestItem(key: string, title: string, body: string): void {
const now = Date.now();
const queue = pruneDigestQueue(loadDigestQueue(), now);
// Dedupe by key: most recent wins (replace the prior entry's text +
// timestamp). Keeps the digest body free of the same trigger fired
// twice between delivery slots.
const filtered = queue.filter((item) => item.key !== key);
filtered.push({ key, title, body, queuedAt: now });
saveDigestQueue(filtered);
}
/**
* Trigger entry point. Inspects the current nudge mode and either
* delivers immediately (preserving the existing suppression chain) or
* pushes the item onto the digest queue. Off-mode drops silently.
*/
function enqueueOrDeliver(key: string, title: string, body: string): void {
const mode = getNudgeMode();
if (mode === "off") return;
if (mode === "digest") {
enqueueDigestItem(key, title, body);
return;
}
void deliver(title, body);
}
// --- Digest delivery ------------------------------------------------
interface LastDeliveryMap {
[slotKey: string]: true;
}
function loadLastDelivery(): LastDeliveryMap {
if (typeof localStorage === "undefined") return {};
const raw = localStorage.getItem(DIGEST_LAST_DELIVERY_KEY);
if (!raw) return {};
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as LastDeliveryMap;
}
return {};
} catch {
return {};
}
}
function saveLastDelivery(map: LastDeliveryMap): void {
if (typeof localStorage === "undefined") return;
try {
localStorage.setItem(DIGEST_LAST_DELIVERY_KEY, JSON.stringify(map));
} catch {
// Same reasoning as saveDigestQueue.
}
}
// Drop entries whose date prefix is older than 7 days. Keeps the map
// from growing unbounded across long-running installs.
function pruneLastDelivery(map: LastDeliveryMap, now: number): LastDeliveryMap {
const cutoff = now - 7 * DAY_MS;
const out: LastDeliveryMap = {};
for (const key of Object.keys(map)) {
// key shape: "YYYY-MM-DD@HH:MM"
const datePart = key.split("@")[0];
if (!datePart) continue;
const parsed = Date.parse(datePart + "T00:00:00");
if (Number.isNaN(parsed)) continue;
if (parsed >= cutoff) out[key] = true;
}
return out;
}
function todayLocalKey(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function isValidHHMM(value: string): boolean {
// Strict HH:MM, 24-hour, with leading zeros required. 00:0023:59.
// Anti-requirement: never accept 24:00 or non-padded forms like "8:00".
const m = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(value);
return m !== null;
}
function scheduledTimeForToday(hhmm: string, now: Date): Date | null {
if (!isValidHHMM(hhmm)) return null;
const [hh, mm] = hhmm.split(":").map(Number);
return new Date(now.getFullYear(), now.getMonth(), now.getDate(), hh, mm, 0, 0);
}
function buildDigestBody(items: DigestQueueItem[]): { title: string; body: string } {
const N = items.length;
const title = `Kon — ${N} signal${N === 1 ? "" : "s"}`;
const lines = items.slice(0, 3).map((item) => `${item.title}`);
if (N > 3) lines.push(`…and ${N - 3} more.`);
return { title, body: lines.join("\n") };
}
async function deliverDigestPayload(items: DigestQueueItem[]): Promise<void> {
if (items.length === 0) return;
// Mute is the only suppression that applies to digest delivery —
// focus and the hourly cap don't apply because the user explicitly
// chose this delivery time. Mode-off is enforced by the caller.
if (settings.nudgesMuted) return;
const ok = await ensurePermission();
if (!ok) return;
const { title, body } = buildDigestBody(items);
try {
await invoke("deliver_nudge", { input: { title, body } });
} catch {
// fire-and-forget
}
if (settings.nudgesSpeakAloud) {
try {
await invoke("tts_speak", {
text: body || title,
rate: settings.ttsRate,
voice: settings.ttsVoice ?? null,
});
} catch {
// fire-and-forget
}
}
}
async function checkAndDeliverDigests(): Promise<void> {
if (getNudgeMode() !== "digest") return;
const slots = Array.isArray(settings.nudgeDigestTimes) ? settings.nudgeDigestTimes : [];
if (slots.length === 0) return;
const now = new Date();
const nowMs = now.getTime();
const dayKey = todayLocalKey();
let delivery = pruneLastDelivery(loadLastDelivery(), nowMs);
let dirty = false;
for (const slot of slots) {
if (!isValidHHMM(slot)) continue;
const scheduled = scheduledTimeForToday(slot, now);
if (!scheduled) continue;
if (now < scheduled) continue;
const slotKey = `${dayKey}@${slot}`;
if (delivery[slotKey]) continue;
// Refresh the queue inside the loop in case an earlier slot in
// the same tick already consumed it (unlikely with 5-min cadence
// but cheap to be safe).
const queue = pruneDigestQueue(loadDigestQueue(), nowMs);
if (queue.length === 0) {
// Stamp anyway so we don't keep re-checking an empty slot all
// day — the user picked the time, not the content.
delivery[slotKey] = true;
dirty = true;
continue;
}
await deliverDigestPayload(queue);
saveDigestQueue([]);
delivery[slotKey] = true;
dirty = true;
}
if (dirty) saveLastDelivery(delivery);
}
// --- Mode-switch hygiene (B3.2) -------------------------------------
/**
* Called from the Settings UI when the user changes `nudgeMode`. The
* Settings page is responsible for writing both `nudgeMode` and the
* back-compat `nudgesEnabled` boolean; this function only handles the
* queue / replay side-effects.
*/
export async function handleNudgeModeChange(
prev: "off" | "immediate" | "digest",
next: "off" | "immediate" | "digest",
): Promise<void> {
if (prev === next) return;
if (prev === "digest" && next === "off") {
// Bin the queue — the user explicitly turned nudges off. Anything
// still in there would be a surprise next time digest is re-enabled.
saveDigestQueue([]);
return;
}
if (prev === "digest" && next === "immediate") {
// Replay each queued item through the immediate path. Each call
// re-checks the suppression chain (focus, hourly cap, fresh-start),
// so a flood of replays still respects the cap.
const queue = pruneDigestQueue(loadDigestQueue(), Date.now());
saveDigestQueue([]);
for (const item of queue) {
// eslint-disable-next-line no-await-in-loop
await deliver(item.title, item.body);
}
return;
}
// immediate→digest, off→*, immediate→off: nothing to clean up here.
// (off→immediate / off→digest don't have a queue worth replaying.)
}
// --- Trigger: inactivity with active timer --------------------------- // --- Trigger: inactivity with active timer ---------------------------
function onTimerStart(event: Event) { function onTimerStart(event: Event) {
@@ -164,7 +437,8 @@ function checkInactivityWithActiveTimer(now: number) {
if (blurredAt === null) return; if (blurredAt === null) return;
if (now - blurredAt < INACTIVITY_TIMER_THRESHOLD_MS) return; if (now - blurredAt < INACTIVITY_TIMER_THRESHOLD_MS) return;
timerNudgeFiredThisSession = true; timerNudgeFiredThisSession = true;
void deliver( enqueueOrDeliver(
"timer-inactivity",
"Timer's still running.", "Timer's still running.",
"It's been ticking while you've been away. Pick up where you left off, or stop it.", "It's been ticking while you've been away. Pick up where you left off, or stop it.",
); );
@@ -172,11 +446,6 @@ function checkInactivityWithActiveTimer(now: number) {
// --- Trigger: pending morning triage --------------------------------- // --- 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; let triageNudgeFiredOnKey: string | null = null;
async function checkPendingMorningTriage() { async function checkPendingMorningTriage() {
@@ -194,7 +463,8 @@ async function checkPendingMorningTriage() {
} }
if (lastShown === today) return; if (lastShown === today) return;
triageNudgeFiredOnKey = today; triageNudgeFiredOnKey = today;
void deliver( enqueueOrDeliver(
"morning-triage",
"A few things waiting", "A few things waiting",
"When you're ready, Corbie has your morning list. No rush.", "When you're ready, Corbie has your morning list. No rush.",
); );
@@ -209,7 +479,8 @@ function onMicroStepGenerated(event: Event) {
clearMicroStepTimer(parentTaskId); clearMicroStepTimer(parentTaskId);
const handle = setTimeout(() => { const handle = setTimeout(() => {
microStepTimers.delete(parentTaskId); microStepTimers.delete(parentTaskId);
void deliver( enqueueOrDeliver(
`microstep-idle:${parentTaskId}`,
"Still with that one?", "Still with that one?",
"Your breakdown is here when you want to pick the first step.", "Your breakdown is here when you want to pick the first step.",
); );
@@ -264,6 +535,15 @@ export function startNudgeBus(): void {
triagePollHandle = setInterval(() => { triagePollHandle = setInterval(() => {
void checkPendingMorningTriage(); void checkPendingMorningTriage();
}, MORNING_TRIAGE_CHECK_INTERVAL_MS); }, MORNING_TRIAGE_CHECK_INTERVAL_MS);
// B3.2 — digest scheduling. Prune stale items at boot, then check
// immediately and on a 5-min cadence. The check is a no-op outside
// digest mode, so leaving the interval running is cheap.
saveDigestQueue(pruneDigestQueue(loadDigestQueue(), Date.now()));
void checkAndDeliverDigests();
digestCheckHandle = setInterval(() => {
void checkAndDeliverDigests();
}, DIGEST_CHECK_INTERVAL_MS);
} }
export function stopNudgeBus(): void { export function stopNudgeBus(): void {
@@ -287,6 +567,10 @@ export function stopNudgeBus(): void {
clearInterval(triagePollHandle); clearInterval(triagePollHandle);
triagePollHandle = null; triagePollHandle = null;
} }
if (digestCheckHandle !== null) {
clearInterval(digestCheckHandle);
digestCheckHandle = null;
}
for (const handle of microStepTimers.values()) clearTimeout(handle); for (const handle of microStepTimers.values()) clearTimeout(handle);
microStepTimers.clear(); microStepTimers.clear();