Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ and versions follow [Semantic Versioning](https://semver.org/).
## [Unreleased]

### Added
- **Watches that only speak up when something important changed:** a check script can now **Tell me when something
important changes**. Each check is compared with the last one without any AI, so an unchanged page costs nothing
and stays silent; only when something changed does your fast AI model decide whether it matters, and then you get
one notification that says what changed. Every watch also keeps a quiet list of its recent checks ("Checked,
nothing new", "Something changed. You were told") under Runs.
- **Fuzzy times for repeating tasks:** a daily or weekly task can run "in the morning", "afternoon", "evening" or "at
night" instead of at an exact time; Sentient picks a time inside that part of the day. Say it in your request
("every weekday morning") or pick it in the schedule editor.
- **Sources under answers:** when Sentient used the web, your email, a file or a connected app to answer, small
chips under the reply show what it used: the page, the email's subject, the file or the app, next to the memories
it had in mind. Click a chip to see the part Sentient read and open the page, email or file. Answers that used
Expand Down
76 changes: 76 additions & 0 deletions desktop/src/features/tasks/detail/CheckHistory.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/** Script jobs (docs/API.md §16): the quiet history of recent checks, newest first. */
import { IconAlertCircle, IconArrowsDiff, IconBellRinging, IconCircleCheck, IconFlag, IconMinus, IconRadar, type Icon } from '@tabler/icons-react'
import type { ScriptCheck, ScriptCheckOutcome, TaskScript } from '@/lib/types'
import { cn, parseDate } from '@/lib/utils'
import { SectionHeading } from '../parts'
import { formatInZone } from '../schedule'

type Tone = 'quiet' | 'news' | 'danger'

function describe(outcome: ScriptCheckOutcome, then: TaskScript['then']): { icon: Icon; text: string; tone: Tone } {
const told = then === 'run' ? 'Sentient ran the task' : 'You were told'
switch (outcome) {
case 'baseline':
return { icon: IconFlag, text: 'First check, saved as the starting point', tone: 'quiet' }
case 'nothing_new':
return { icon: IconCircleCheck, text: 'Checked, nothing new', tone: 'quiet' }
case 'quiet':
return { icon: IconCircleCheck, text: 'Checked, all quiet', tone: 'quiet' }
case 'minor':
return { icon: IconMinus, text: 'Something changed, but not enough to tell you', tone: 'quiet' }
case 'changed':
return { icon: IconArrowsDiff, text: `Something changed. ${told}`, tone: 'news' }
case 'alert':
return { icon: IconBellRinging, text: `Raised an alert. ${told}`, tone: 'news' }
case 'reported':
return { icon: IconBellRinging, text: `Reported. ${told}`, tone: 'news' }
case 'failed':
return { icon: IconAlertCircle, text: 'The check didn’t work', tone: 'danger' }
default:
return { icon: IconCircleCheck, text: 'Checked', tone: 'quiet' }
}
}

function when(iso: string | undefined, tz: string): string {
const d = parseDate(iso)
return d ? `${formatInZone(d, tz, { month: 'short', day: 'numeric' })}, ${formatInZone(d, tz, { hour: 'numeric', minute: '2-digit' })}` : ''
}

export function CheckHistory({ script, tz }: { script: TaskScript; tz: string }) {
const checks: ScriptCheck[] = [...(script.checks ?? [])].reverse()
if (!checks.length) return null
const total = checks.reduce((n, c) => n + (c.count ?? 1), 0)
return (
<section className="space-y-2.5">
<SectionHeading icon={<IconRadar />} title="Checks" count={total} description="Newest first. Quiet checks don’t send you anything." />
<ol className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface">
{checks.map((c, i) => {
const d = describe(c.outcome, script.then)
const count = c.count ?? 1
return (
<li key={`${c.at}-${i}`} className={cn('flex items-start gap-2.5 px-3.5 py-2', d.tone === 'quiet' && 'bg-sunken/30')}>
<d.icon size={15} className={cn('mt-0.5 shrink-0', d.tone === 'news' ? 'text-accent-text' : d.tone === 'danger' ? 'text-danger' : 'text-fg-faint')} />
<div className="min-w-0 flex-1">
<div className={cn('text-sm', d.tone === 'quiet' ? 'text-fg-subtle' : d.tone === 'danger' ? 'text-danger' : 'font-medium text-fg')}>
{d.text}
{count > 1 && <span className="text-fg-faint"> · {count} times</span>}
</div>
{c.note && <div className={cn('mt-0.5 text-xs', d.tone === 'quiet' ? 'text-fg-faint' : 'text-fg-muted')}>{c.note}</div>}
</div>
<span className="shrink-0 text-right text-2xs tabular-nums text-fg-subtle">
{count > 1 && c.last_at ? (
<>
{when(c.at, tz)}
<span className="block">to {when(c.last_at, tz)}</span>
</>
) : (
when(c.at, tz)
)}
</span>
</li>
)
})}
</ol>
</section>
)
}
43 changes: 25 additions & 18 deletions desktop/src/features/tasks/detail/RunHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,11 @@ import { getBridge } from '@/lib/bridge'
import type { MemorySource, Run, Task, TaskRunResult } from '@/lib/types'
import { cn, formatDuration, parseDate, relativeTime, truncate } from '@/lib/utils'
import { useNow } from '../hooks'
import { retryOf, runDurationMs } from '../meta'
import { retryOf, runDurationMs, scriptOf } from '../meta'
import { LiveDot, RunStatusBadge, SectionHeading } from '../parts'
import { formatInZone, sourceLabel } from '../schedule'
import { ToolIcon, toolIdentity, useToolNames } from '../tools'
import { CheckHistory } from './CheckHistory'
import { RunLog } from './RunLog'
import { RunQuestion } from './RunQuestion'

Expand All @@ -51,6 +52,9 @@ export function RunHistory({
cancelling: boolean
}) {
const runs = [...task.runs].reverse()
const script = scriptOf(task)
const checks = script?.checks?.length ? <CheckHistory script={script} tz={tz} /> : null
if (!runs.length && checks) return checks
if (!runs.length) {
return (
<EmptyState
Expand All @@ -62,23 +66,26 @@ export function RunHistory({
)
}
return (
<section className="space-y-2.5">
<SectionHeading icon={<IconHistory />} title="Runs" count={runs.length} description="Newest first" />
<div className="space-y-2">
{runs.map((run, i) => (
<RunCard
key={run.run_id}
task={task}
run={run}
number={runs.length - i}
tz={tz}
defaultOpen={focusRunId ? focusRunId === run.run_id : i === 0}
onCancel={() => onCancelRun(run.run_id)}
cancelling={cancelling}
/>
))}
</div>
</section>
<div className="space-y-6">
{checks}
<section className="space-y-2.5">
<SectionHeading icon={<IconHistory />} title="Runs" count={runs.length} description="Newest first" />
<div className="space-y-2">
{runs.map((run, i) => (
<RunCard
key={run.run_id}
task={task}
run={run}
number={runs.length - i}
tz={tz}
defaultOpen={focusRunId ? focusRunId === run.run_id : i === 0}
onCancel={() => onCancelRun(run.run_id)}
cancelling={cancelling}
/>
))}
</div>
</section>
</div>
)
}

Expand Down
19 changes: 16 additions & 3 deletions desktop/src/features/tasks/detail/ScheduleEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ import { useNavigate } from 'react-router'
import { Alert, Button, Combobox, IconButton, Input, SegmentedControl, Select, Textarea } from '@/components/ui'
import { useIntegrations } from '@/hooks/integrations'
import { useHooks } from '@/hooks/automations'
import type { OnceSchedule, RecurringSchedule, TaskSchedule, TriggeredSchedule, Weekday } from '@/lib/types'
import type { OnceSchedule, RecurringSchedule, ScheduleWindow, TaskSchedule, TriggeredSchedule, Weekday } from '@/lib/types'
import { cn, listTimezones } from '@/lib/utils'
import {
FIELD_LABELS,
RULE_OPS,
SOURCE_FIELDS,
SOURCE_LABELS,
WEEKDAYS,
WINDOW_LABELS,
filterRules,
intervalMinutes,
isIntervalSchedule,
Expand Down Expand Up @@ -147,6 +148,11 @@ function ZonePicker({ value, onChange }: { value: string; onChange: (z: string)
)
}

const WHEN_OPTIONS = [
{ value: 'exact', label: 'At a set time' },
...(Object.entries(WINDOW_LABELS) as Array<[ScheduleWindow, { label: string }]>).map(([value, w]) => ({ value, label: w.label }))
]

const INTERVAL_PICKS = [5, 15, 30, 60, 180, 360, 720, 1440]

function RecurringFields({ schedule, tz, onChange }: { schedule: RecurringSchedule; tz: string; onChange: (s: TaskSchedule) => void }) {
Expand Down Expand Up @@ -243,12 +249,19 @@ function RecurringDayFields({
const next = days.includes(d) ? days.filter((x) => x !== d) : [...days, d]
onChange({ ...schedule, frequency: 'weekly', days: WEEKDAYS.filter((w) => next.includes(w)) })
}
const setWhen = (v: string) => {
const { window: _drop, ...rest } = schedule
void _drop
onChange(v in WINDOW_LABELS ? { ...rest, window: v as ScheduleWindow } : rest)
}
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<RecurringPresets preset={preset} onChange={setPreset} />
<span className="text-sm text-fg-subtle">at</span>
<Input type="time" size="sm" value={schedule.time} onChange={(e) => onChange({ ...schedule, time: e.target.value || '09:00' })} wrapperClassName="w-32" aria-label="Time" />
<Select size="sm" className="w-60" aria-label="Time of day" value={schedule.window ?? 'exact'} onValueChange={setWhen} options={WHEN_OPTIONS} />
{!schedule.window && (
<Input type="time" size="sm" value={schedule.time} onChange={(e) => onChange({ ...schedule, time: e.target.value || '09:00' })} wrapperClassName="w-32" aria-label="Time" />
)}
<ZonePicker value={tz} onChange={(z) => onChange({ ...schedule, timezone: z })} />
</div>
{schedule.frequency === 'weekly' && (
Expand Down
9 changes: 6 additions & 3 deletions desktop/src/features/tasks/detail/ScriptJob.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
IconRepeat,
IconRocket,
IconShieldCheck,
IconSparkles,
IconX,
type Icon
} from '@tabler/icons-react'
Expand Down Expand Up @@ -43,7 +44,8 @@ interface Choice<T extends string> {
}

const CONDITIONS: Array<Choice<ScriptCondition>> = [
{ value: 'changed', title: 'Tell me when something changes', body: 'Each check is compared with the one before.', icon: IconArrowsDiff },
{ value: 'noteworthy', title: 'Tell me when something important changes', body: 'Each check is compared with the one before. When something changed, AI decides if it matters.', icon: IconSparkles },
{ value: 'changed', title: 'Tell me when anything changes', body: 'Each check is compared with the one before.', icon: IconArrowsDiff },
{ value: 'alert', title: 'Tell me when the script raises an alert', body: 'The script decides, for example when a price drops below your target.', icon: IconBellRinging },
{ value: 'every_run', title: 'Report every run', body: 'You hear about each check that prints something, even if it is the same as last time.', icon: IconRepeat }
]
Expand Down Expand Up @@ -361,8 +363,9 @@ function describeOutcome(r: SandboxResult, s: TaskScript): { accent: boolean; ic
return { accent: false, icon: IconCircleCheck, title: 'This would be the first check', body: 'I’ll remember the result and tell you when a later check is different.' }
}
const same = JSON.stringify(r.result ?? null) === JSON.stringify(s.last_result)
return same
? { accent: false, icon: IconCircleCheck, title: 'Same as last time', body: 'Nothing changed, so you wouldn’t hear from me.' }
if (same) return { accent: false, icon: IconCircleCheck, title: 'Same as last time', body: 'Nothing changed, so you wouldn’t hear from me.' }
return s.condition === 'noteworthy'
? { accent: true, icon: IconSparkles, title: 'Something changed since the last check', body: `AI would decide whether it matters. If it does, ${then.charAt(0).toLowerCase() + then.slice(1)}.` }
: { accent: true, icon: IconArrowsDiff, title: 'Something changed since the last check', body: `So ${then}.` }
}

Expand Down
14 changes: 12 additions & 2 deletions desktop/src/features/tasks/schedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* Mirrors sentient/tasks/schedule.py: naive `run_at` values are wall-clock times in the
* schedule's timezone, recurring times are HH:MM in that zone, weekly uses day names.
*/
import type { IntervalSchedule, RecurringSchedule, Task, TaskSchedule, TriggeredSchedule, Weekday } from '@/lib/types'
import type { IntervalSchedule, RecurringSchedule, ScheduleWindow, Task, TaskSchedule, TriggeredSchedule, Weekday } from '@/lib/types'
import { detectTimezone, humanize, parseDate } from '@/lib/utils'

export function isIntervalSchedule(s: unknown): s is IntervalSchedule {
Expand Down Expand Up @@ -254,6 +254,8 @@ export function describeSchedule(schedule: TaskSchedule | null | undefined, opts
if (isIntervalSchedule(schedule)) return { text: everyMinutesText(intervalMinutes(schedule)) }
if (schedule.type === 'recurring') {
const lead = schedule.frequency === 'daily' ? 'Every day' : describeDays(normalizeDays(schedule.days))
const window = schedule.window ? WINDOW_LABELS[schedule.window] : undefined
if (window) return { text: `${lead} ${window.phrase}`, zone: zoneLabel(schedule.timezone) }
return { text: `${lead} at ${formatClock(schedule.time)}`, zone: zoneLabel(schedule.timezone) }
}
const t = schedule as TriggeredSchedule
Expand All @@ -263,6 +265,14 @@ export function describeSchedule(schedule: TaskSchedule | null | undefined, opts
return { text: `When ${eventLabel(t.source, t.event)} in ${sourceLabel(t.source)}`, detail }
}

/** Fuzzy windows (§4): the engine picks a minute inside the range each time. Mirrors `WINDOWS` in schedule.py. */
export const WINDOW_LABELS: Record<ScheduleWindow, { label: string; phrase: string; start: string }> = {
morning: { label: 'In the morning (7 to 10 am)', phrase: 'in the morning, between 7 and 10 am', start: '07:00' },
afternoon: { label: 'In the afternoon (12 to 4 pm)', phrase: 'in the afternoon, between 12 and 4 pm', start: '12:00' },
evening: { label: 'In the evening (5 to 8 pm)', phrase: 'in the evening, between 5 and 8 pm', start: '17:00' },
night: { label: 'At night (8 to 11 pm)', phrase: 'at night, between 8 and 11 pm', start: '20:00' }
}

/** "Every 15 minutes", "Every hour", "Every 6 hours". */
export function everyMinutesText(minutes: number): string {
if (minutes % 1440 === 0) return minutes === 1440 ? 'Once a day' : `Every ${minutes / 1440} days`
Expand Down Expand Up @@ -429,7 +439,7 @@ export function recurringOccurrences(schedule: RecurringSchedule, from: Date, to
return out
}
const tz = schedule.timezone ?? 'auto'
const { hour, minute } = parseClock(schedule.time)
const { hour, minute } = parseClock(schedule.window ? WINDOW_LABELS[schedule.window].start : schedule.time)
const days = schedule.frequency === 'daily' ? WEEKDAYS : normalizeDays(schedule.days).length ? normalizeDays(schedule.days) : (['Monday'] as Weekday[])
const wanted = new Set(days.map((d) => WEEKDAYS.indexOf(d)))
const out: Date[] = []
Expand Down
16 changes: 15 additions & 1 deletion desktop/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1231,12 +1231,15 @@ export interface RecurringSchedule {
days?: Weekday[] | string[]
time: string
interval_minutes?: number
/** Daily/weekly only: run at some time in this part of the day instead of at `time` (§4 "Fuzzy windows"). */
window?: ScheduleWindow
timezone?: string
catch_up?: CatchUpPolicy
}
export type ScheduleWindow = 'morning' | 'afternoon' | 'evening' | 'night'

// §16 script jobs
export type ScriptCondition = 'changed' | 'alert' | 'every_run'
export type ScriptCondition = 'changed' | 'noteworthy' | 'alert' | 'every_run'
export type ScriptThen = 'notify' | 'run'

export interface TaskScript {
Expand All @@ -1246,6 +1249,17 @@ export interface TaskScript {
last_result: unknown
last_run_at: ISODate | null
last_error: string | null
/** Recent checks, oldest first (§16). Older engines leave it out. */
checks?: ScriptCheck[] | null
}
export type ScriptCheckOutcome = 'baseline' | 'nothing_new' | 'changed' | 'minor' | 'alert' | 'quiet' | 'reported' | 'failed'
export interface ScriptCheck {
at: ISODate
outcome: ScriptCheckOutcome
note?: string
/** A row of the same quiet outcome folds into one entry. */
count?: number
last_at?: ISODate
}
export interface TriggeredSchedule {
type: 'triggered'
Expand Down
Loading
Loading