Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ and versions follow [Semantic Versioning](https://semver.org/).
turning off the alarm and opening a garage door or gate ask even when approvals are off. Tasks can
start when a device changes, like "when the front door unlocks". Quick commands now work for lights too: "turn off
the living room lights" happens at once, with no model, when it is clear which lights you mean (#175).
- **Alarms, "what's next on my calendar", and reminders on your phone.** "Wake me at 7" and "set an alarm for 6:30"
now work at once, without the model, like timers: Sentient says exactly when it will ring ("Alarm set for 7:00 AM
tomorrow."), and when it does, the window shows a banner with **Stop** and plays a chime until you stop it. "What
alarms do I have" and "turn off the gym alarm" work too. "What's next on my calendar" (or "what's my next meeting")
answers straight from your connected Google Calendar, also without the model. And a new switch on the Channels page,
**Reminders and alarms on your phone too**, sends the reminders and alarms you set on your computer to your paired
Telegram, Discord or WhatsApp chat as well.
- **A safety check for skills and MCP servers from outside.** Skills brought over from Hermes, and skills you add
with the new **Add from a link** button in Skills (a SKILL.md, or a GitHub folder that holds one), wait for your
review with a plain safety report: where it came from, and whether it tries to send your data to a fixed address,
Expand Down
46 changes: 46 additions & 0 deletions desktop/src/components/shell/AlarmBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/** #359 A ringing alarm: a banner with Stop while the chime plays (stores/alarm.ts). */
import { IconAlarm, IconVolume, IconVolumeOff } from '@tabler/icons-react'
import { AnimatePresence, motion } from 'motion/react'
import { Button } from '@/components/ui'
import { useAlarm } from '@/stores/alarm'

export function AlarmBanner() {
const ringing = useAlarm((s) => s.ringing)
const playing = useAlarm((s) => s.playing)
const stop = useAlarm((s) => s.stop)

return (
<AnimatePresence initial={false}>
{ringing && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="shrink-0 overflow-hidden border-b border-accent/25 bg-accent/10"
role="alert"
>
<div className="flex items-center gap-3 px-4 py-2.5 text-sm text-fg">
<motion.span
animate={{ rotate: [0, -14, 14, -10, 10, 0] }}
transition={{ duration: 0.7, repeat: Infinity, repeatDelay: 0.7 }}
className="flex text-accent-text"
>
<IconAlarm size={20} />
</motion.span>
<span className="min-w-0 flex-1">
<span className="font-medium">{ringing.title}</span>{' '}
<span className="text-fg-muted">{ringing.message}</span>
</span>
<span className="flex items-center gap-1 text-xs text-fg-muted">
{playing ? <IconVolume size={14} /> : <IconVolumeOff size={14} />}
{playing ? 'Ringing' : "Can't play sound"}
</span>
<Button size="sm" variant="primary" onClick={stop} autoFocus>
Stop
</Button>
</div>
</motion.div>
)}
</AnimatePresence>
)
}
2 changes: 2 additions & 0 deletions desktop/src/components/shell/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useActivityPing } from '@/hooks/useActivityPing'
import { getBridge } from '@/lib/bridge'
import { Button, Spinner } from '@/components/ui'
import { useConnection } from '@/stores/connection'
import { AlarmBanner } from './AlarmBanner'
import { CommandPalette } from './CommandPalette'
import { StoppedBanner } from './StopControls'
import { Sidebar } from './Sidebar'
Expand Down Expand Up @@ -41,6 +42,7 @@ export function AppShell() {
<Sidebar />
<main className="relative flex min-w-0 flex-1 flex-col overflow-hidden rounded-tl-xl border-l border-t border-border bg-surface">
<EngineBanner />
<AlarmBanner />
<StoppedBanner />
<div className="min-h-0 flex-1">
<Outlet />
Expand Down
4 changes: 4 additions & 0 deletions desktop/src/features/channels/MessagingApps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { errorMessage, isNotImplemented } from '@/lib/api'
import type { Channel, ChannelPairing, PairedChat } from '@/lib/types'
import { cn, relativeTime } from '@/lib/utils'
import { previewMode } from '@/features/devices/preview'
import { SchemaForm } from '@/features/settings/SchemaForm'
import { useChannelActions, useChannels } from './hooks'
import { ChannelTile } from './meta'
import { WhatsAppLinkDialog } from './WhatsAppLink'
Expand Down Expand Up @@ -75,6 +76,9 @@ export function MessagingApps() {
<ChannelCard key={c.id} channel={c} onConnect={() => setConnect(c)} onPair={() => setPair(c)} />
))}
</div>
<div className="mt-6 max-w-2xl">
<SchemaForm section="channels" title="Reminders" include={['channels.deliver_reminders']} />
</div>
<WhatsAppLinkDialog channel={connect?.id === 'whatsapp' ? connect : null} onClose={() => setConnect(null)} />
<ConnectChannelDialog
channel={connect?.id === 'whatsapp' ? null : connect}
Expand Down
11 changes: 9 additions & 2 deletions desktop/src/features/settings/SchemaForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,15 @@ export const LABELS: Record<string, FieldLabel> = {
'chat.show_thinking': { label: 'Show thinking', description: "Show the model's reasoning in a collapsible block." },
'chat.auto_title': { label: 'Name chats automatically' },
'chat.quick_commands': {
label: 'Quick timers and reminders',
description: 'Simple requests like "set a timer for 10 minutes" or "remind me at 6 pm to call Ravi" happen at once, without the model. English for now.'
label: 'Quick timers, reminders and alarms',
description:
'Simple requests like "set a timer for 10 minutes", "remind me at 6 pm to call Ravi", "wake me at 7" or "what\'s next on my calendar" happen at once, without the model. English for now.'
},
// channels (#359)
'channels.deliver_reminders': {
label: 'Reminders and alarms on your phone too',
description:
'Reminders and alarms you set on this computer or by voice also go off in your paired chats that get notifications. Ones you set in a chat always go off in that chat.'
},
// memory
'memory.facts_top_k': { label: 'Facts recalled per message', unit: 'facts' },
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/lib/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import { upsertSubagent } from '@/features/chat/subagents'
import { deviceKeys, removeDevice, upsertDevice } from '@/features/devices/hooks'
import { useChat } from '@/stores/chat'
import { useNotificationStore } from '@/stores/notifications'
import { isAlarm, useAlarm } from '@/stores/alarm'
import { useUI } from '@/stores/ui'
import { getBridge } from './bridge'
import { approvalTarget, settledApprovalRun } from './nativeApproval'
Expand Down Expand Up @@ -125,6 +126,7 @@ export function installDomainEvents(qc: QueryClient): () => void {
)
useNotificationStore.getState().push(n)
if (n.kind === 'brief') refreshBrief()
if (isAlarm(n)) useAlarm.getState().ring(n) // #359: it rings until Stop
const title = n.title || 'Sentient'
toast(title, {
description: plain(n.message),
Expand Down
4 changes: 2 additions & 2 deletions desktop/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2935,7 +2935,7 @@ export interface FoldersUpdated {
}

// ---------------------------------------------------------------------------- §23 timers and reminders (#150)
export type ReminderKind = 'timer' | 'reminder'
export type ReminderKind = 'timer' | 'reminder' | 'alarm'
export type ReminderStatus = 'scheduled' | 'fired' | 'cancelled'

export interface Reminder {
Expand All @@ -2944,7 +2944,7 @@ export interface Reminder {
/** What to remind about ("call Ravi"); '' for a timer. */
message: string
connector: '' | 'to' | 'about' | 'that'
/** A timer's name ("pasta"). */
/** A timer's or an alarm's name ("pasta", "gym"). */
label: string
/** A timer's length, or a reminder set "in 20 minutes". */
duration_s: number | null
Expand Down
90 changes: 90 additions & 0 deletions desktop/src/stores/alarm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* A ringing alarm (#359). An alarm's `reminder` notification (`payload.event = "alarm"`) starts a chime made with
* Web Audio, so no sound file ships with the app. It rings until Stop, or for `MAX_RING_MS` at most.
*/
import { create } from 'zustand'
import type { Notification } from '@/lib/types'

const MAX_RING_MS = 3 * 60_000
const PATTERN_MS = 1400

export interface RingingAlarm {
id: string
title: string
message: string
startedAt: number
}

interface AlarmState {
ringing: RingingAlarm | null
/** True while the sound is really playing (the audio output started). */
playing: boolean
ring: (n: Notification) => void
stop: () => void
}

let ctx: AudioContext | null = null
let beat: number | undefined
let limit: number | undefined

/** One "beep beep" of two short tones. */
function chime(audio: AudioContext): void {
const now = audio.currentTime
for (const [offset, freq] of [[0, 880], [0.22, 1175]] as const) {
const osc = audio.createOscillator()
const gain = audio.createGain()
osc.type = 'sine'
osc.frequency.value = freq
gain.gain.setValueAtTime(0.0001, now + offset)
gain.gain.exponentialRampToValueAtTime(0.35, now + offset + 0.02)
gain.gain.exponentialRampToValueAtTime(0.0001, now + offset + 0.18)
osc.connect(gain).connect(audio.destination)
osc.start(now + offset)
osc.stop(now + offset + 0.2)
}
}

function silence(): void {
window.clearInterval(beat)
window.clearTimeout(limit)
beat = limit = undefined
const audio = ctx
ctx = null
if (audio) void audio.close().catch(() => undefined)
}

export function isAlarm(n: Notification): boolean {
return n.kind === 'reminder' && n.payload?.event === 'alarm'
}

export const useAlarm = create<AlarmState>((set, get) => ({
ringing: null,
playing: false,
ring: (n) => {
silence()
set({ ringing: { id: n.id, title: n.title || 'Alarm', message: n.message, startedAt: Date.now() }, playing: false })
try {
const audio = new AudioContext()
ctx = audio
const start = () => {
if (ctx !== audio) return
chime(audio)
beat = window.setInterval(() => chime(audio), PATTERN_MS)
set({ playing: audio.state === 'running' })
audio.onstatechange = () => {
if (ctx === audio) set({ playing: audio.state === 'running' })
}
}
void audio.resume().then(start, start)
} catch {
set({ playing: false }) // no audio output: the card and the notification still show
}
limit = window.setTimeout(() => {
if (get().ringing?.id === n.id) get().stop()
}, MAX_RING_MS)
},
stop: () => {
silence()
set({ ringing: null, playing: false })
}
}))
Loading
Loading