diff --git a/AGENTS.md b/AGENTS.md index c49d854..bd7a1fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,7 +91,8 @@ Invariants: - Session Activity strip (Wave 4 P2) — per-sister ephemeral commands/files next to feed from mouth-stream / action ledger (no second audit DB); writes show path·size only; `docs/activity.md`; no CopilotKit; package 0.14.0. - Peer chase / head keep-alive (Wave 5 P0) — `offerSisterHop` / `sendToAgent` park `pendingHops` envelopes; head working while hops are out; assess injects `expecting` and may hop again if unmet; empty/fail sister auto-wakes a notice; `docs/peer-chase.md`; no CopilotKit / `@ag-ui/*`; sisters stay separate; package 0.14.0. - GPUI polish P0 (Wave 6) — toast stack + level policy (`src/chrome/toast*.tsx` / toast-store); EmptyState icon/CTA; focus chords Cmd+L / Cmd+B / Cmd+, / Cmd+J (HID + gpuix); `docs/wave6-gpui-polish.md`; no CopilotKit / gpui-kit; idle parks intact; package 0.14.0. -- GPUI polish P1 (Wave 6) — Cmd+K command palette lite (sisters / rooms / settings sections / Jobs); visible steer-queue card (remove / send-now); shared `ListRow` density + hover end-slot; `Tip` microcopy on titlebar / rail / Jobs; opaque menuFill overlays; idle parks intact; package stays 0.13.0 (no version cut in P1). +- GPUI polish P1 (Wave 6) — Cmd+K command palette lite (sisters / rooms / settings sections / Jobs); visible steer-queue card (remove / send-now); shared `ListRow` density + hover end-slot; `Tip` microcopy on titlebar / rail / Jobs; opaque menuFill overlays; idle parks intact; package 0.14.0. +- GPUI polish P2 (Wave 6) — optional Mac OS notify (background mouth complete / needs approval; Quiet + `AUTOMATON_DISABLE_NOTIFICATIONS`); pulse lease strides (MouthWait / Activity slow cadence on expensive surfaces); feed edge-fade spike; Shortcuts help from live `KEYMAP`; `docs/wave6-gpui-polish.md` + idle-cpu honesty; no CopilotKit / gpui-kit; no version cut in P2. - Bezel light appearance (Wave 2 P2) — designed light (Settings → Window → Appearance; not invert of graphite); Brand-complete Settings (remaining CARD/FIELD via useChrome; update modal scrim); ToggleGroup, EmptyState, Sheet extracted. Still vendored `@gpuix/react` (Wave 3.2 is 0.6.2 + matched native). Package 0.8.0. ## Safety diff --git a/docs/idle-cpu.md b/docs/idle-cpu.md index de05176..6c30a77 100644 --- a/docs/idle-cpu.md +++ b/docs/idle-cpu.md @@ -20,6 +20,15 @@ Package / Info.plist are **0.7.0** (Wave 2 P2 band cut). Honesty: do not claim a forever-0% process. Paced GPUIX baseline is ~**1.5%** (vs ~73% with a `setImmediate` spin). Selected Staff may still glance; idle sisters freeze. Report both settle and deep-idle samples. +## Pulse stride honesty (Wave 6 P2) + +Loaders that must pulse (MouthWaitBubble, Activity empty dots) use gpuix +`motion` opacity repeats — **not** a second JS `setInterval` clock. Cadence is +tiered: default **1.2s** period; **2.4s** (`pulse_lease_slow` recipe) when the +feed is long (≥40 rows) or Activity is open, so expensive subtree rebuilds +cost less per second. When the mouth is idle the bubble unmounts and the +pulse stops. Do not claim forever-0% while a turn is streaming. + ## Park inventory | Id | Where | What parks | @@ -29,7 +38,9 @@ Honesty: do not claim a forever-0% process. Paced GPUIX baseline is ~**1.5%** (v | `stream-commit` | `src/runtime/feed-pin.ts` | 120ms STREAM_COMMIT coalesce + `FEED_TAIL` pin | | `row-fingerprint` | `src/runtime/feed-row.ts` | Fingerprints avoid wholesale rebuilds on last-line growth | | `frame-pace` | vendored `@gpuix/react` `startFrameLoop` | 8ms paced AppKit pump (PulseClock lease = spring `onFrame` listeners empty → GPUI can park) | -| `activity-takeover` | `src/chrome/activity.ts` | Thinking/tool zone paints only while streaming or user-held; no interval / MotionDiv | +| `activity-takeover` | `src/chrome/activity.ts` | Thinking/tool zone paints only while streaming or user-held; streaming dots may pulse on a leased stride (Wave 6 P2) | +| `pulse-stride` | `src/motion/pulse.ts` | MouthWait / Activity opacity pulse: 1.2s default, 2.4s slow on expensive surfaces; unmount parks | +| `os-notify-focus` | `src/runtime/os-notify.ts` | Focus-poll interval arms only after a background banner parks a sister; clears on claim | Code mirror: `idleParkInventory()` in `src/runtime/idle-health.ts`. diff --git a/docs/wave6-gpui-polish.md b/docs/wave6-gpui-polish.md index 7c7c145..77727d4 100644 --- a/docs/wave6-gpui-polish.md +++ b/docs/wave6-gpui-polish.md @@ -1,6 +1,6 @@ -# Wave 6 — GPUI polish (toast / palette / steer / density) +# Wave 6 — GPUI polish (toast / palette / notify / pulse / shortcuts) -**Bands:** P0 (feedback & findability) + **P1** (palette, queue chrome, density) +**Bands:** P0 (feedback) + P1 (palette / queue / density) + **P2** (background awareness & long-thread chrome) **Package:** stays on current main (no version cut in this PR) **Patterns only** — recipes from rgitui / ChatGPUI / Waku / Bezel / Zeron. No CopilotKit, Electron, or `gpui-kit` / `gpui-component` crates. @@ -63,14 +63,38 @@ GPUIx `onKeyDown` plus the same HID watch discipline as paste/copy/quit (textare | --- | --- | | **Cmd+K** | Toggle command palette | -No full keymap file yet — P2 shortcuts-help can generate from one table later. +## What shipped (P2) -## Parks (unchanged) -- Idle parks / resting-motion -- OS Notification Center (P2) -- Pulse lease strides / overlay scrollbar (P2) +### OS notification (Mac) +- `src/runtime/os-notify.ts` — optional **Settings → Window → Background notify** (`skin.osNotifyBackground`) +- Banner when a mouth completes or host approval parks **while unfocused** +- **Quiet** = setting Off (default); kill-switch `AUTOMATON_DISABLE_NOTIFICATIONS` +- Failures swallowed; `display notification` via osascript (no new deps) +- Parks sister id; when Automaton becomes frontmost, selects that sister (click routing for plain banners is limited) + +### Pulse lease strides +- `src/motion/pulse.ts` — default **1.2s** / slow **2.4s** (Waku `pulse_lease` / `pulse_lease_slow` recipe) +- `MouthWaitBubble` + Activity streaming dots take slow stride when feed ≥40 or Activity open +- Unmount parks — no JS clock; see `docs/idle-cpu.md` + +### Overlay edge fade (spike) +- `src/chrome/edge-fade.tsx` — feed wrapped in `EdgeFadeFrame` +- Simple two-stop linear-gradient opacity ramp (gpuix); no EdgeFade crate +- Does **not** change opaque menu/toast fills (frost punch-through still owned there) + +### Shortcuts help +- `src/chrome/keymap.ts` **`KEYMAP`** — one live table +- `ShortcutsHelp` in Settings (+ palette section **Shortcuts**) +- Generated from `KEYMAP`, not a stale markdown list + +### Parked +- Message tick rail (P2.5) — skipped (not trivial / no measured pain) + +## Parks (unchanged spirit) +- Idle parks / resting-motion + pulse-stride / os-notify-focus inventory entries +- Opaque overlays for toasts / palette / queue / tips - No `bezel` / `gpui-kit` deps ## Audit See `/workspace/automaton-wave6-gpui-polish-audit.md` (box) and `artifacts/automaton-wave6-gpui-polish-audit.md` when copied. -P0: shipped v0.13.0. P1: branch `feat/wave-6-p1-palette-steer-density`. +P0: v0.13.0. P1: v0.14.0. P2: branch `feat/wave-6-p2-notify-pulse-shortcuts` (no version cut). diff --git a/src/app.tsx b/src/app.tsx index 96921d7..8949fb3 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -156,11 +156,16 @@ import { } from './session' import { SisterBlob, framePath, markFor } from './blob' import { railDragOrigin, railIsCompact, railWidthFromDrag, readSkin, toggleRailWidth, writeSkin } from './runtime/skin' +import { + claimNotifyFocusIfFrontmost, + peekPendingNotifyFocus, + postOsNotify, +} from './runtime/os-notify' import { ConfirmCard, QuestionCard, SecretRequestCard } from './cards' -import { ActivityZone, CommandPalette, Composer, EmptyState, ListRow, MouthWaitBubble, Sheet, SteerQueueCard, Tip, Titlebar, ToastStack, activityVisible, groupBoxStyle, pushToast, tracesFromJob } from './chrome' +import { ActivityZone, CommandPalette, Composer, EdgeFadeFrame, EmptyState, ListRow, MouthWaitBubble, Sheet, SteerQueueCard, Tip, Titlebar, ToastStack, activityVisible, groupBoxStyle, pushToast, tracesFromJob } from './chrome' import { connectorDisplayName } from './runtime/connectors' import { Settings } from './settings' -import { motionTransition } from './motion' +import { motionTransition, pulseStrideFor } from './motion' import { TokenProvider, toChatTheme, useTokenEnv, useTokens } from './theme' import { T } from './tokens' import { PRODUCT } from './brand' @@ -283,6 +288,7 @@ function StaffApp({ store: providedStore }: { store?: StaffStore } = {}) { const [updateNote, setUpdateNote] = useState('') const [installedVersion] = useState(() => readInstalledVersion()) const [pendingSend, setPendingSend] = useState(null) + const [notifyFocusEpoch, setNotifyFocusEpoch] = useState(0) const { renderer } = useGpuix() const railDrag = useRef<{ startX: number; startWidth: number } | null>(null) const railPoint = useRef(null) @@ -508,6 +514,23 @@ function StaffApp({ store: providedStore }: { store?: StaffStore } = {}) { persistIntroIfUserSpoke(next) return next }) + try { + const name = session.agents.find((a) => a.id === agentId)?.name ?? 'Sister' + if ( + postOsNotify( + { + title: name, + body: spoken.trim() ? spoken.trim().slice(0, 180) : 'Done.', + agentId, + }, + { enabled: readSkin().osNotifyBackground }, + ) + ) { + setNotifyFocusEpoch((n) => n + 1) + } + } catch { + /* swallow */ + } if (routing?.expectReply && routing.platform === 'slack' && spoken.trim()) { const dest = routing.slackChannel const threadTs = routing.threadTs @@ -563,6 +586,66 @@ function StaffApp({ store: providedStore }: { store?: StaffStore } = {}) { }) }, [store, mouthEpoch]) + // Wave 6 P2 — OS notify when host approval parks while unfocused. + const pendingApprovalCount = session.pendingApprovals?.length ?? 0 + const pendingApprovalRef = useRef(pendingApprovalCount) + useEffect(() => { + const prev = pendingApprovalRef.current + pendingApprovalRef.current = pendingApprovalCount + if (runningTests()) return + if (pendingApprovalCount <= prev) return + try { + const row = session.pendingApprovals?.[pendingApprovalCount - 1] + const worker = (session.computerWorkers ?? []).find((w) => w.id === row?.workerId) + const agentId = worker?.ownerAgentId ?? session.activeAgentId + const name = session.agents.find((a) => a.id === agentId)?.name ?? 'Sister' + if ( + postOsNotify( + { + title: name, + body: row?.action?.trim() ? `Needs approval: ${row.action.trim().slice(0, 140)}` : 'Needs approval.', + agentId, + }, + { enabled: readSkin().osNotifyBackground }, + ) + ) { + setNotifyFocusEpoch((n) => n + 1) + } + } catch { + /* swallow */ + } + }, [pendingApprovalCount, session.pendingApprovals, session.computerWorkers, session.agents, session.activeAgentId]) + + // Apply parked notify focus when we become frontmost — interval only after a banner parks a sister. + useEffect(() => { + if (runningTests() || notifyFocusEpoch <= 0) return + let timer: ReturnType | null = null + const tick = () => { + const id = claimNotifyFocusIfFrontmost() + if (id) { + setSession((current) => ({ ...current, activeAgentId: id })) + if (timer) { + clearInterval(timer) + timer = null + } + } + } + timer = setInterval(() => { + if (!peekPendingNotifyFocus()) { + if (timer) { + clearInterval(timer) + timer = null + } + return + } + tick() + }, 2000) + tick() + return () => { + if (timer) clearInterval(timer) + } + }, [notifyFocusEpoch]) + useEffect(() => { if (runningTests()) return for (const job of dispatchableJobs(session)) { @@ -1048,6 +1131,7 @@ function StaffApp({ store: providedStore }: { store?: StaffStore } = {}) { minWidth: 0, }} > + 0} testId="feed-edge-fade"> setSession((current) => dismissSecretRequest(current, id))} onEmptyWrite={focusComposer} /> +
) })} - {streaming ? : null} + {streaming ? ( + + ) : null} {activityOpen ? ( ) : null} diff --git a/src/chrome/activity-zone.tsx b/src/chrome/activity-zone.tsx index f1bd0b3..59b638c 100644 --- a/src/chrome/activity-zone.tsx +++ b/src/chrome/activity-zone.tsx @@ -1,5 +1,7 @@ import React from 'react' +import { motion } from '@gpuix/react' import { thinkingDots } from '../domain' +import { PULSE_CURVE, pulseDuration, type PulseStride } from '../motion/pulse' import type { SessionActivityRow } from '../runtime/session-activity' import { activityHeaderSummary } from '../runtime/session-activity' import { useTokens } from '../theme' @@ -12,7 +14,8 @@ import { groupBoxStyle } from './surface' * Mouth wait is MouthWaitBubble (bubble slot), not this bar. * Auto-opens while streaming with steps; header press takes over. Session commands/files * (paths/sizes only for writes) stay as a collapsed strip after the turn — - * derived from mouth-stream / ledger, not a second audit DB. No MotionDiv. + * derived from mouth-stream / ledger, not a second audit DB. Idle parks; streaming + * dots may pulse on a leased stride (Wave 6 P2). */ export function ActivityZone({ streaming, @@ -20,6 +23,7 @@ export function ActivityZone({ onHeld, traces = [], sessionActivity = [], + pulseStride = 'default', }: { streaming: boolean held: boolean | null @@ -27,6 +31,8 @@ export function ActivityZone({ traces?: readonly StepTrace[] /** Ephemeral commands/files this sister session (Wave 4 P2). */ sessionActivity?: readonly SessionActivityRow[] + /** Wave 6 P2 — slow stride when the row paint is expensive. */ + pulseStride?: PulseStride }) { const T = useTokens() const state: ActivityTakeover = { @@ -86,7 +92,18 @@ export function ActivityZone({
) : null} {folded.length === 0 && sessionActivity.length === 0 ? ( -
{streaming ? thinkingDots(3) : 'Parked'}
+ streaming ? ( + + {thinkingDots(3)} + + ) : ( +
Parked
+ ) ) : ( folded.flatMap((group) => group.items.map((row, index) => ( diff --git a/src/chrome/edge-fade.tsx b/src/chrome/edge-fade.tsx new file mode 100644 index 0000000..b36dbd8 --- /dev/null +++ b/src/chrome/edge-fade.tsx @@ -0,0 +1,99 @@ +import React from 'react' +import { useTokens } from '../theme' + +/** Strip alpha from #RRGGBB or #RRGGBBAA → #RRGGBB. */ +export function rgbHex(color: string): string { + const raw = color.replace('#', '').trim() + if (raw.length >= 6) return `#${raw.slice(0, 6)}` + return color.startsWith('#') ? color : `#${color}` +} + +/** Same RGB with 00 alpha — two-stop fade end. */ +export function transparentHex(color: string): string { + return `${rgbHex(color)}00` +} + +export function edgeFadeBackground(color: string, edge: 'top' | 'bottom') { + const solid = rgbHex(color) + const clear = transparentHex(color) + // CSS angle: 0 = up, 180 = down. Top band fades downward; bottom upward. + return { + type: 'linear-gradient' as const, + angle: edge === 'top' ? 180 : 0, + stops: [ + { color: solid, position: 0 }, + { color: clear, position: 1 }, + ] as [{ color: string; position: number }, { color: string; position: number }], + } +} + +/** + * Feed / rail edge-fade spike (Wave 6 P2). + * Simple opacity ramp via gpuix two-stop linear-gradient — no EdgeFade crate. + * Does not touch menu/toast fills (frost punch-through stays owned by opaque overlays). + */ +export function EdgeFadeFrame({ + children, + band = 28, + top = true, + bottom = true, + color, + testId = 'edge-fade', + show = true, +}: { + children: React.ReactNode + band?: number + top?: boolean + bottom?: boolean + /** Paint color for the opaque stop (defaults to canvas). */ + color?: string + testId?: string + /** Spike gate — hide when empty / no overflow. */ + show?: boolean +}) { + const T = useTokens() + const fill = color ?? T.canvas + return ( +
+ {children} + {show && top ? ( +
+ ) : null} + {show && bottom ? ( +
+ ) : null} +
+ ) +} diff --git a/src/chrome/index.ts b/src/chrome/index.ts index a73f299..25457b4 100644 --- a/src/chrome/index.ts +++ b/src/chrome/index.ts @@ -76,3 +76,7 @@ export { export { SteerQueueCard } from './steer-queue' export { ListRow, type ListRowDensity } from './list-row' export { Tip } from './tooltip' + +export { EdgeFadeFrame, edgeFadeBackground, rgbHex, transparentHex } from './edge-fade' +export { KEYMAP, keymapByGroup, keymapChord, type KeymapEntry } from './keymap' +export { ShortcutsHelp } from './shortcuts-help' diff --git a/src/chrome/keymap.ts b/src/chrome/keymap.ts new file mode 100644 index 0000000..acb507d --- /dev/null +++ b/src/chrome/keymap.ts @@ -0,0 +1,42 @@ +/** + * Live keyboard chord catalog (Wave 6 P2). + * + * One table feeds Settings / palette Shortcuts help — never a stale markdown + * list. Runtime matchers stay in `inspector.tsx` + `paste-hotkey.ts`; keep + * labels here in sync when adding a chord. + */ + +export type KeymapEntry = { + id: string + chord: string + action: string + /** Settings / palette grouping */ + group: 'Focus' | 'Chrome' | 'Edit' | 'App' +} + +export const KEYMAP: readonly KeymapEntry[] = [ + { id: 'focus-composer', chord: 'Cmd+L', action: 'Focus composer', group: 'Focus' }, + { id: 'toggle-rail', chord: 'Cmd+B', action: 'Toggle rail compact', group: 'Chrome' }, + { id: 'settings', chord: 'Cmd+,', action: 'Toggle Settings', group: 'Chrome' }, + { id: 'jobs', chord: 'Cmd+J', action: 'Toggle Jobs', group: 'Chrome' }, + { id: 'palette', chord: 'Cmd+K', action: 'Toggle command palette', group: 'Chrome' }, + { id: 'inspector', chord: 'Cmd+Shift+I', action: 'Toggle Inspector', group: 'Chrome' }, + { id: 'copy', chord: 'Cmd+C', action: 'Copy selection', group: 'Edit' }, + { id: 'paste', chord: 'Cmd+V', action: 'Paste', group: 'Edit' }, + { id: 'cut', chord: 'Cmd+X', action: 'Cut', group: 'Edit' }, + { id: 'select-all', chord: 'Cmd+A', action: 'Select all (feed)', group: 'Edit' }, + { id: 'quit', chord: 'Cmd+Q', action: 'Quit', group: 'App' }, + { id: 'close', chord: 'Cmd+W', action: 'Quit (close)', group: 'App' }, +] as const + +export function keymapByGroup(): { group: KeymapEntry['group']; entries: KeymapEntry[] }[] { + const order: KeymapEntry['group'][] = ['Focus', 'Chrome', 'Edit', 'App'] + return order.map((group) => ({ + group, + entries: KEYMAP.filter((row) => row.group === group), + })) +} + +export function keymapChord(id: string): string | undefined { + return KEYMAP.find((row) => row.id === id)?.chord +} diff --git a/src/chrome/mouth-wait.tsx b/src/chrome/mouth-wait.tsx index 710e52d..7cf62dc 100644 --- a/src/chrome/mouth-wait.tsx +++ b/src/chrome/mouth-wait.tsx @@ -1,14 +1,17 @@ import React from 'react' import { motion } from '@gpuix/react' import { thinkingDots } from '../domain' +import { PULSE_CURVE, pulseDuration, type PulseStride } from '../motion/pulse' import { useTokens } from '../theme' /** * Compact mouth wait in the assistant bubble slot — not a full-width Thinking bar. - * Soft opacity pulse via gpuix motion (no JS clock). + * Soft opacity pulse via gpuix motion (no JS clock). Slow stride when the feed + * paint is expensive (Wave 6 P2 pulse lease). Unmount parks. */ -export function MouthWaitBubble() { +export function MouthWaitBubble({ stride = 'default' }: { stride?: PulseStride }) { const T = useTokens() + const duration = pulseDuration(stride) return (
+
Shortcuts
+
+ Live chords from one keymap table — stays in sync with HID / gpuix handlers. +
+ {groups.map(({ group, entries }) => ( +
+
{group}
+ {entries.map((row) => ( +
+
{row.action}
+
{row.chord}
+
+ ))} +
+ ))} +
+ ) +} diff --git a/src/motion/index.ts b/src/motion/index.ts index b05c3a9..bbcf26b 100644 --- a/src/motion/index.ts +++ b/src/motion/index.ts @@ -10,3 +10,13 @@ export { type SpringSpec, } from './specs' export { motionTransition, type NamedTransition } from './transition' + +export { + PULSE_CURVE, + PULSE_STRIDE_S, + PULSE_STRIDE_SLOW_S, + expensivePulseSurface, + pulseDuration, + pulseStrideFor, + type PulseStride, +} from './pulse' diff --git a/src/motion/pulse.ts b/src/motion/pulse.ts new file mode 100644 index 0000000..f3439cf --- /dev/null +++ b/src/motion/pulse.ts @@ -0,0 +1,37 @@ +/** + * Pulse lease strides (Wave 6 P2) — Waku `pulse_lease` / `pulse_lease_slow` recipe. + * + * MouthWaitBubble / Activity dots share these cadences. Expensive surfaces + * (long feed, Activity open) use the slow stride so rebuild cost stays priced + * per tick. Unmount parks — no JS clock; gpuix motion repeats only while mounted. + */ + +/** Default opacity pulse period (seconds) — ~prior MouthWaitBubble cadence. */ +export const PULSE_STRIDE_S = 1.2 + +/** Half-rate stride for expensive paint (~15fps-feel vs ~30). */ +export const PULSE_STRIDE_SLOW_S = 2.4 + +export type PulseStride = 'default' | 'slow' + +/** Shared ease — keep call sites off raw 'easeInOut' string (MotionSpec catalog lint). */ +export const PULSE_CURVE = 'easeInOut' as const + +export function pulseDuration(stride: PulseStride = 'default'): number { + return stride === 'slow' ? PULSE_STRIDE_SLOW_S : PULSE_STRIDE_S +} + +/** Long feed or open Activity → slow lease. */ +export function expensivePulseSurface(input: { + feedLen?: number + activityOpen?: boolean +}): boolean { + return (input.feedLen ?? 0) >= 40 || Boolean(input.activityOpen) +} + +export function pulseStrideFor(input: { + feedLen?: number + activityOpen?: boolean +}): PulseStride { + return expensivePulseSurface(input) ? 'slow' : 'default' +} diff --git a/src/runtime/idle-health.ts b/src/runtime/idle-health.ts index ce94739..930e376 100644 --- a/src/runtime/idle-health.ts +++ b/src/runtime/idle-health.ts @@ -52,7 +52,19 @@ export function idleParkInventory(): IdlePark[] { id: 'activity-takeover', path: 'src/chrome/activity.ts + src/chrome/activity-zone.tsx', summary: - 'Thinking/tool disclosure paints only while streaming or user-held; no MotionDiv / no interval ticks', + 'Thinking/tool disclosure paints only while streaming or user-held; dots pulse only while streaming (Wave 6 P2 leased stride)', + }, + { + id: 'pulse-stride', + path: 'src/motion/pulse.ts + MouthWaitBubble / ActivityZone', + summary: + 'Default 1.2s opacity pulse; slow 2.4s stride when feed is long or Activity open; unmount parks (no JS clock)', + }, + { + id: 'os-notify-focus', + path: 'src/runtime/os-notify.ts', + summary: + 'Background OS notify focus poll arms only after a banner parks a sister; clears on claim — no idle interval', }, ] } diff --git a/src/runtime/os-notify.ts b/src/runtime/os-notify.ts new file mode 100644 index 0000000..bc4cb6f --- /dev/null +++ b/src/runtime/os-notify.ts @@ -0,0 +1,140 @@ +/** + * Mac OS Notification Center banners (Wave 6 P2). + * + * Optional: Settings → Window → Background notify. Kill-switch: + * `AUTOMATON_DISABLE_NOTIFICATIONS`. Failures are swallowed — a missing + * notifier must never bother the mouth / approval path. + * + * Click routing: plain `display notification` cannot hand us a click. We + * park the sister id and bring Automaton frontmost when the user returns + * (or immediately after post when we can focus ourselves). + */ +import { spawn, spawnSync } from 'node:child_process' +import { isFrontmost } from './paste-hotkey' +import { runningTests } from './test-env' + +export const OS_NOTIFY_DISABLE_ENV = 'AUTOMATON_DISABLE_NOTIFICATIONS' + +export type OsNotifyInput = { + title: string + body: string + /** Sister to focus when the user returns / we activate. */ + agentId?: string +} + +export type OsNotifySeams = { + enabled?: boolean + frontmost?: () => boolean + post?: (title: string, body: string) => void + focusSelf?: () => boolean + now?: () => number +} + +let pendingFocusAgentId: string | null = null + +export function resetOsNotifyForTests(): void { + pendingFocusAgentId = null +} + +export function peekPendingNotifyFocus(): string | null { + return pendingFocusAgentId +} + +export function takePendingNotifyFocus(): string | null { + const id = pendingFocusAgentId + pendingFocusAgentId = null + return id +} + +export function parkPendingNotifyFocus(agentId: string | undefined): void { + if (agentId && agentId.trim()) pendingFocusAgentId = agentId.trim() +} + +/** Kill-switch or Quiet (setting off) → no banner. */ +export function osNotifyAllowed(enabled: boolean): boolean { + if (!enabled) return false + if (process.env[OS_NOTIFY_DISABLE_ENV]) return false + if (runningTests()) return false + return true +} + +export function shouldPostBackgroundNotify( + enabled: boolean, + seams: Pick = {}, +): boolean { + if (!osNotifyAllowed(enabled)) return false + const frontmost = seams.frontmost ?? (() => isFrontmost()) + try { + if (frontmost()) return false + } catch { + return false + } + return true +} + +function applescriptEscape(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"') +} + +function defaultPost(title: string, body: string): void { + if (process.platform !== 'darwin') return + const script = `display notification "${applescriptEscape(body)}" with title "${applescriptEscape(title)}"` + try { + const child = spawn('osascript', ['-e', script], { + stdio: 'ignore', + detached: true, + }) + child.unref() + } catch { + // swallow + } +} + +/** Bring this Automaton process forward (same path as host Chrome focus). */ +export function focusAutomaton(pid = process.pid): boolean { + if (runningTests() || process.platform !== 'darwin' || pid <= 1) return false + try { + const result = spawnSync( + 'osascript', + [ + '-e', + `tell application "System Events" to set frontmost of (first process whose unix id is ${Math.floor(pid)}) to true`, + ], + { encoding: 'utf8', timeout: 8000 }, + ) + return result.status === 0 + } catch { + return false + } +} + +/** Post a banner when allowed. Parks sister focus; never throws. */ +export function postOsNotify(input: OsNotifyInput, seams: OsNotifySeams = {}): boolean { + try { + const enabled = seams.enabled === true + if (!shouldPostBackgroundNotify(enabled, seams)) return false + const title = input.title.trim() || 'Automaton' + const body = input.body.trim() || 'Done.' + parkPendingNotifyFocus(input.agentId) + const post = seams.post ?? defaultPost + post(title, body) + return true + } catch { + return false + } +} + +/** + * If a banner parked a sister and we are (or just became) frontmost, return + * that id once. Caller selects the sister. No timer when nothing is parked. + */ +export function claimNotifyFocusIfFrontmost(seams: Pick = {}): string | null { + if (!pendingFocusAgentId) return null + const frontmost = seams.frontmost ?? (() => isFrontmost()) + try { + if (!frontmost()) return null + } catch { + return null + } + return takePendingNotifyFocus() +} diff --git a/src/runtime/skin.ts b/src/runtime/skin.ts index c6541eb..afc5557 100644 --- a/src/runtime/skin.ts +++ b/src/runtime/skin.ts @@ -21,6 +21,8 @@ export type Skin = { frostWash: number appearance: Appearance brand: Brand + /** Wave 6 P2 — OS notify when background mouth completes / needs approval. Off = Quiet. */ + osNotifyBackground: boolean } export const DEFAULT_FROST_WASH = 12 @@ -31,6 +33,7 @@ const DEFAULT_SKIN: Skin = { frostWash: DEFAULT_FROST_WASH, appearance: 'dark', brand: { ...DEFAULT_BRAND }, + osNotifyBackground: false, } export function skinPath(home = automatonHome()): string { @@ -81,6 +84,7 @@ export function parseSkin(raw: unknown): Skin { frostWash: clampFrostWash(typeof row.frostWash === 'number' ? row.frostWash : DEFAULT_FROST_WASH), appearance: parseAppearance(row.appearance), brand: parseBrand(row.brand), + osNotifyBackground: row.osNotifyBackground === true, } } diff --git a/src/settings.tsx b/src/settings.tsx index e93cb7c..a0b2eea 100644 --- a/src/settings.tsx +++ b/src/settings.tsx @@ -31,7 +31,7 @@ import { boxStatus, computerLabel } from './runtime/box' import { aboutVersionLines } from './runtime/version' import { mouthModelFor, seatModel, writeSeatBinding } from './runtime/plane' import { clampFrostWash, patchSkin, readSkin, type Skin, type WindowMode } from './runtime/skin' -import { EmptyState, ListRow, ToggleGroup, pushToast, type ToastLevel } from './chrome' +import { EmptyState, ListRow, ShortcutsHelp, ToggleGroup, pushToast, type ToastLevel } from './chrome' import { BRAND_ACCENT_SWATCHES, BRAND_RADIUS_PRESETS, @@ -240,6 +240,19 @@ function WindowCard({ {skin.windowMode === 'frosted' ? ( pick({ frostWash })} /> ) : null} +
Background notify
+
+ Banner when a mouth finishes or needs approval while Automaton is unfocused. Off = Quiet. Kill-switch: AUTOMATON_DISABLE_NOTIFICATIONS. +
+ + testId="settings-os-notify" + value={skin.osNotifyBackground ? 'on' : 'off'} + onChange={(id) => pick({ osNotifyBackground: id === 'on' })} + options={[ + { id: 'on', label: 'On', testId: 'settings-os-notify-on' }, + { id: 'off', label: 'Quiet', testId: 'settings-os-notify-off' }, + ]} + />
Brand
Tint
@@ -1336,6 +1349,9 @@ export function Settings({ + + +
{ 'row-fingerprint', 'frame-pace', 'activity-takeover', + 'pulse-stride', + 'os-notify-focus', ]) expect(STREAM_COMMIT_MS).toBe(120) expect(IDLE_SPRING_SETTLE_HARD_MS).toBe(SETTLE_HARD_MS) @@ -70,7 +72,7 @@ describe('doctor idle CPU', () => { const report = doctorIdleCpu({ forceSkip: true }) expect(report.status).toBe('skip') expect(report.note).toContain('docs/idle-cpu.md') - expect(report.parks.length).toBe(6) + expect(report.parks.length).toBe(8) }) test('WARNs when a live sample is hot', () => { diff --git a/tests/skin.test.ts b/tests/skin.test.ts index b347035..ef90ed0 100644 --- a/tests/skin.test.ts +++ b/tests/skin.test.ts @@ -43,6 +43,7 @@ describe('graphite skin', () => { frostWash: 12, appearance: 'dark', brand: DEFAULT_BRAND, + osNotifyBackground: false, }) expect(parseSkin({ railWidth: 72, accent: 'violet' })).toEqual({ railWidth: 72, @@ -50,6 +51,7 @@ describe('graphite skin', () => { frostWash: 12, appearance: 'dark', brand: DEFAULT_BRAND, + osNotifyBackground: false, }) }) @@ -63,6 +65,7 @@ describe('graphite skin', () => { frostWash: 12, appearance: 'dark', brand: DEFAULT_BRAND, + osNotifyBackground: false, }) rmSync(home, { recursive: true, force: true }) }) @@ -82,6 +85,12 @@ describe('graphite skin', () => { expect(JSON.stringify(T)).not.toContain('#E2795B') }) + test('osNotifyBackground defaults Quiet and opts in only on true', () => { + expect(parseSkin({}).osNotifyBackground).toBe(false) + expect(parseSkin({ osNotifyBackground: true }).osNotifyBackground).toBe(true) + expect(parseSkin({ osNotifyBackground: 'yes' }).osNotifyBackground).toBe(false) + }) + test('window mode and wash clamp, solid chrome is opaque', () => { expect(parseWindowMode('solid')).toBe('solid') expect(parseWindowMode('nope')).toBe('frosted') diff --git a/tests/wave6-p2.test.ts b/tests/wave6-p2.test.ts new file mode 100644 index 0000000..c15027a --- /dev/null +++ b/tests/wave6-p2.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, test } from 'bun:test' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { edgeFadeBackground, rgbHex, transparentHex } from '../src/chrome/edge-fade' +import { KEYMAP, keymapByGroup, keymapChord } from '../src/chrome/keymap' +import { SETTINGS_PALETTE } from '../src/chrome/palette' +import { menuFill, surfaceNeedsOpaqueFill } from '../src/chrome/surface' +import { + expensivePulseSurface, + pulseDuration, + pulseStrideFor, + PULSE_STRIDE_S, + PULSE_STRIDE_SLOW_S, +} from '../src/motion/pulse' +import { + claimNotifyFocusIfFrontmost, + OS_NOTIFY_DISABLE_ENV, + osNotifyAllowed, + parkPendingNotifyFocus, + peekPendingNotifyFocus, + postOsNotify, + resetOsNotifyForTests, + shouldPostBackgroundNotify, + takePendingNotifyFocus, +} from '../src/runtime/os-notify' +import { idleParkInventory } from '../src/runtime/idle-health' +import { parseSkin } from '../src/runtime/skin' +import { T } from '../src/tokens' + +const root = join(import.meta.dir, '..') + +describe('wave 6 p2 os notify', () => { + test('Quiet + kill-switch + frontmost gate; failures swallowed', () => { + resetOsNotifyForTests() + expect(osNotifyAllowed(false)).toBe(false) + expect(shouldPostBackgroundNotify(false, { frontmost: () => false })).toBe(false) + expect(shouldPostBackgroundNotify(true, { frontmost: () => true })).toBe(false) + + const prev = process.env[OS_NOTIFY_DISABLE_ENV] + process.env[OS_NOTIFY_DISABLE_ENV] = '1' + expect(osNotifyAllowed(true)).toBe(false) + if (prev == null) delete process.env[OS_NOTIFY_DISABLE_ENV] + else process.env[OS_NOTIFY_DISABLE_ENV] = prev + + // runningTests() → allowed false even when enabled + expect(osNotifyAllowed(true)).toBe(false) + + let posted = 0 + // Force path as if not in tests by using custom seams that skip frontmost + post + // postOsNotify still respects runningTests via osNotifyAllowed — expect false + expect( + postOsNotify( + { title: 'Kernel', body: 'Done.', agentId: 'kernel' }, + { enabled: true, frontmost: () => false, post: () => { + posted += 1 + } }, + ), + ).toBe(false) + expect(posted).toBe(0) + }) + + test('pending focus parks sister id and claims when frontmost', () => { + resetOsNotifyForTests() + // Manually park (post is test-gated) + parkPendingNotifyFocus('kernel') + expect(peekPendingNotifyFocus()).toBe('kernel') + expect(claimNotifyFocusIfFrontmost({ frontmost: () => false })).toBe(null) + expect(peekPendingNotifyFocus()).toBe('kernel') + expect(claimNotifyFocusIfFrontmost({ frontmost: () => true })).toBe('kernel') + expect(takePendingNotifyFocus()).toBe(null) + }) + + test('skin + settings + app wire Background notify', () => { + expect(parseSkin({}).osNotifyBackground).toBe(false) + expect(parseSkin({ osNotifyBackground: true }).osNotifyBackground).toBe(true) + const settings = readFileSync(join(root, 'src/settings.tsx'), 'utf8') + expect(settings).toContain('settings-os-notify') + expect(settings).toContain('Quiet') + expect(settings).toContain('AUTOMATON_DISABLE_NOTIFICATIONS') + const app = readFileSync(join(root, 'src/app.tsx'), 'utf8') + expect(app).toContain('postOsNotify') + expect(app).toContain('claimNotifyFocusIfFrontmost') + expect(app).toContain('osNotifyBackground') + }) +}) + +describe('wave 6 p2 pulse lease strides', () => { + test('default vs slow duration; expensive surface heuristic', () => { + expect(pulseDuration('default')).toBe(PULSE_STRIDE_S) + expect(pulseDuration('slow')).toBe(PULSE_STRIDE_SLOW_S) + expect(expensivePulseSurface({ feedLen: 10 })).toBe(false) + expect(expensivePulseSurface({ feedLen: 40 })).toBe(true) + expect(expensivePulseSurface({ activityOpen: true })).toBe(true) + expect(pulseStrideFor({ feedLen: 5 })).toBe('default') + expect(pulseStrideFor({ feedLen: 50, activityOpen: true })).toBe('slow') + }) + + test('MouthWaitBubble + ActivityZone take stride; parks documented', () => { + const mouth = readFileSync(join(root, 'src/chrome/mouth-wait.tsx'), 'utf8') + expect(mouth).toContain('pulseDuration') + expect(mouth).toContain('stride') + const activity = readFileSync(join(root, 'src/chrome/activity-zone.tsx'), 'utf8') + expect(activity).toContain('pulseStride') + expect(activity).toContain('activity-pulse') + const parks = idleParkInventory().map((row) => row.id) + expect(parks).toContain('pulse-stride') + expect(parks).toContain('os-notify-focus') + }) +}) + +describe('wave 6 p2 edge fade spike', () => { + test('two-stop gradient helpers + feed wrap; opaque overlays untouched', () => { + expect(rgbHex('#10101009')).toBe('#101010') + expect(transparentHex('#ABCDEFaa')).toBe('#ABCDEF00') + const top = edgeFadeBackground('#101010', 'top') + expect(top.type).toBe('linear-gradient') + expect(top.angle).toBe(180) + expect(top.stops[0].color).toBe('#101010') + expect(top.stops[1].color).toBe('#10101000') + const app = readFileSync(join(root, 'src/app.tsx'), 'utf8') + expect(app).toContain('EdgeFadeFrame') + expect(app).toContain('feed-edge-fade') + expect(surfaceNeedsOpaqueFill('menu')).toBe(true) + expect(menuFill(T).length).toBeGreaterThan(0) + }) +}) + +describe('wave 6 p2 shortcuts help', () => { + test('KEYMAP drives help — not stale markdown alone', () => { + expect(KEYMAP.length).toBeGreaterThanOrEqual(10) + expect(keymapChord('palette')).toBe('Cmd+K') + expect(keymapChord('focus-composer')).toBe('Cmd+L') + const groups = keymapByGroup() + expect(groups.map((g) => g.group)).toEqual(['Focus', 'Chrome', 'Edit', 'App']) + expect(SETTINGS_PALETTE.some((row) => row.id === 'shortcuts')).toBe(true) + const settings = readFileSync(join(root, 'src/settings.tsx'), 'utf8') + expect(settings).toContain('ShortcutsHelp') + expect(settings).toContain('id="shortcuts"') + const help = readFileSync(join(root, 'src/chrome/shortcuts-help.tsx'), 'utf8') + expect(help).toContain('keymapByGroup') + const docs = readFileSync(join(root, 'docs/wave6-gpui-polish.md'), 'utf8') + expect(docs).toContain('KEYMAP') + }) +})