diff --git a/apps/x/apps/main/src/meeting-popup.ts b/apps/x/apps/main/src/meeting-popup.ts index 8d33fec1..5c5f7d53 100644 --- a/apps/x/apps/main/src/meeting-popup.ts +++ b/apps/x/apps/main/src/meeting-popup.ts @@ -18,7 +18,10 @@ import type { DetectedMeeting } from "@x/core/dist/meetings/detector.js"; // `transparent: true` reliably and paint a grey backing slab instead. const POPUP_WIDTH = 376; const POPUP_HEIGHT = 48; -const AUTO_DISMISS_MS = 45_000; +// The popup renderer owns the real 45s countdown (it pauses on hover and +// draws the progress line); this is only a crash-safety net so a wedged +// renderer can't leave the popup on screen forever. +const FALLBACK_DISMISS_MS = 180_000; // Display names, Granola-style ("Chrome", not "Google Chrome"). const SHORT_APP_NAMES: Record = { @@ -153,5 +156,5 @@ export function showMeetingPopup(meeting: DetectedMeeting): void { win.loadURL("http://localhost:5173/#meeting-detected"); } - dismissTimer = setTimeout(() => closeMeetingPopup(), AUTO_DISMISS_MS); + dismissTimer = setTimeout(() => closeMeetingPopup(), FALLBACK_DISMISS_MS); } diff --git a/apps/x/apps/renderer/src/components/meeting-detected-popup.tsx b/apps/x/apps/renderer/src/components/meeting-detected-popup.tsx index eca03a43..3333a17c 100644 --- a/apps/x/apps/renderer/src/components/meeting-detected-popup.tsx +++ b/apps/x/apps/renderer/src/components/meeting-detected-popup.tsx @@ -1,6 +1,11 @@ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { X } from 'lucide-react' +// How long the popup stays up before dismissing itself. The countdown (and +// its progress line) pauses while the pointer is over the popup — main only +// keeps a much longer crash-safety fallback. +const AUTO_DISMISS_MS = 30_000 + type PopupPayload = { title: string message: string @@ -50,13 +55,57 @@ export function MeetingDetectedPopup() { return cleanup }, []) - const act = (action: 'take-notes' | 'dismiss') => { + const act = useCallback((action: 'take-notes' | 'dismiss') => { if (isPreview) { console.log(`[preview] action: ${action}`) return } void window.ipc.invoke('meetingDetect:action', { action }).catch(() => {}) - } + }, []) + + // Subtle arrival chime, synthesized (no asset, no process spawn): a soft + // sine ding gliding up a fourth, ~0.35s, low gain. Once per popup. + const chimedRef = useRef(false) + useEffect(() => { + if (!payload || chimedRef.current || isPreview) return + chimedRef.current = true + try { + const ctx = new AudioContext() + const osc = ctx.createOscillator() + const gain = ctx.createGain() + osc.type = 'sine' + osc.frequency.setValueAtTime(880, ctx.currentTime) + osc.frequency.exponentialRampToValueAtTime(1174.66, ctx.currentTime + 0.08) + gain.gain.setValueAtTime(0.0001, ctx.currentTime) + gain.gain.exponentialRampToValueAtTime(0.06, ctx.currentTime + 0.02) + gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.35) + osc.connect(gain) + gain.connect(ctx.destination) + osc.start() + osc.stop(ctx.currentTime + 0.4) + osc.onended = () => void ctx.close() + } catch { /* audio is best-effort */ } + }, [payload]) + + // Auto-dismiss countdown, rendered as the progress line at the bottom. + // Hovering pauses it (elapsed only accrues while the pointer is away). + const [remainingFrac, setRemainingFrac] = useState(1) + const hoveredRef = useRef(false) + useEffect(() => { + const TICK_MS = 100 + let elapsed = 0 + const timer = setInterval(() => { + if (hoveredRef.current) return + elapsed += TICK_MS + const frac = Math.max(0, 1 - elapsed / AUTO_DISMISS_MS) + setRemainingFrac(frac) + if (frac <= 0) { + clearInterval(timer) + act('dismiss') + } + }, TICK_MS) + return () => clearInterval(timer) + }, [act]) // The Electron window IS the card (376×48, card background, native macOS // rounded corners + shadow — no transparency, which panels don't honor). @@ -69,14 +118,16 @@ export function MeetingDetectedPopup() { isPreview ? 'rounded-xl shadow-[0_8px_28px_rgba(0,0,0,0.45)]' : 'h-screen w-screen' }`} style={isPreview ? { width: 376, height: 48 } : undefined} + onMouseEnter={() => { hoveredRef.current = true }} + onMouseLeave={() => { hoveredRef.current = false }} > - {/* Close — slides in over the left edge on hover */} + {/* Close — top-left corner, revealed on hover */}
@@ -96,6 +147,12 @@ export function MeetingDetectedPopup() { Take notes + + {/* Time-left line: drains toward dismissal, frozen while hovered */} +
) diff --git a/apps/x/packages/core/src/meetings/detector.ts b/apps/x/packages/core/src/meetings/detector.ts index 36a82b1f..079801c4 100644 --- a/apps/x/packages/core/src/meetings/detector.ts +++ b/apps/x/packages/core/src/meetings/detector.ts @@ -31,7 +31,9 @@ import { isNotificationCategoryEnabled } from "../config/notification_config.js" * means the call ended. */ -const POLL_INTERVAL_MS = 2_000; +// 1s so call-end lands within ~2s of hang-up; all tick work is in-memory +// (the helper pushes owner updates, nothing is spawned). +const POLL_INTERVAL_MS = 1_000; // Mic must be in use continuously this long before we prompt — filters out // Siri, dictation bursts, and app mic-permission probes. const MIC_DEBOUNCE_MS = 5_000; @@ -43,7 +45,7 @@ const CALENDAR_MERGE_LEAD_MS = 15 * 60_000; // While recording, the meeting app must be off the mic this long before we // call the meeting over. Kept short so notes arrive right after hang-up; // only guards against sub-poll device churn, not full reconnects. -const CALL_END_GRACE_MS = 3_000; +const CALL_END_GRACE_MS = 1_000; const CALENDAR_SYNC_DIR = path.join(WorkDir, "calendar_sync"); const HELPER_MAX_RESTARTS = 3;