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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion docs/idle-cpu.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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`.

Expand Down
40 changes: 32 additions & 8 deletions docs/wave6-gpui-polish.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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).
94 changes: 91 additions & 3 deletions src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -283,6 +288,7 @@ function StaffApp({ store: providedStore }: { store?: StaffStore } = {}) {
const [updateNote, setUpdateNote] = useState('')
const [installedVersion] = useState(() => readInstalledVersion())
const [pendingSend, setPendingSend] = useState<PendingSendView | null>(null)
const [notifyFocusEpoch, setNotifyFocusEpoch] = useState(0)
const { renderer } = useGpuix()
const railDrag = useRef<{ startX: number; startWidth: number } | null>(null)
const railPoint = useRef<number | null>(null)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<typeof setInterval> | 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)) {
Expand Down Expand Up @@ -1048,6 +1131,7 @@ function StaffApp({ store: providedStore }: { store?: StaffStore } = {}) {
minWidth: 0,
}}
>
<EdgeFadeFrame show={feedItems.length > 0} testId="feed-edge-fade">
<Feed
ref={feedApi}
key={session.activeAgentId}
Expand Down Expand Up @@ -1083,6 +1167,7 @@ function StaffApp({ store: providedStore }: { store?: StaffStore } = {}) {
onDismissSecret={(id) => setSession((current) => dismissSecretRequest(current, id))}
onEmptyWrite={focusComposer}
/>
</EdgeFadeFrame>
<div
testId="dock"
style={{
Expand Down Expand Up @@ -2504,14 +2589,17 @@ export const Feed = forwardRef<FeedApi, {
/>
)
})}
{streaming ? <MouthWaitBubble /> : null}
{streaming ? (
<MouthWaitBubble stride={pulseStrideFor({ feedLen: items.length, activityOpen })} />
) : null}
{activityOpen ? (
<ActivityZone
streaming={streaming}
held={activityHeld}
onHeld={setActivityHeld}
traces={traces}
sessionActivity={sessionActivity}
pulseStride={pulseStrideFor({ feedLen: items.length, activityOpen: true })}
/>
) : null}
</virtual-list>
Expand Down
21 changes: 19 additions & 2 deletions src/chrome/activity-zone.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -12,21 +14,25 @@ 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,
held,
onHeld,
traces = [],
sessionActivity = [],
pulseStride = 'default',
}: {
streaming: boolean
held: boolean | null
onHeld: (held: boolean | null) => void
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 = {
Expand Down Expand Up @@ -86,7 +92,18 @@ export function ActivityZone({
</div>
) : null}
{folded.length === 0 && sessionActivity.length === 0 ? (
<div style={{ fontSize: T.type.sm, color: T.ghost }}>{streaming ? thinkingDots(3) : 'Parked'}</div>
streaming ? (
<motion.div
testId="activity-pulse"
animate={{ opacity: [0.4, 1, 0.4] }}
transition={{ duration: pulseDuration(pulseStride), repeat: Infinity, ease: PULSE_CURVE }}
style={{ fontSize: T.type.sm, color: T.ghost }}
>
{thinkingDots(3)}
</motion.div>
) : (
<div style={{ fontSize: T.type.sm, color: T.ghost }}>Parked</div>
)
) : (
folded.flatMap((group) =>
group.items.map((row, index) => (
Expand Down
99 changes: 99 additions & 0 deletions src/chrome/edge-fade.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
testId={testId}
style={{
position: 'relative',
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
minHeight: 0,
minWidth: 0,
}}
>
{children}
{show && top ? (
<div
testId={`${testId}-top`}
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: band,
pointerEvents: 'none',
background: edgeFadeBackground(fill, 'top'),
}}
/>
) : null}
{show && bottom ? (
<div
testId={`${testId}-bottom`}
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: band,
pointerEvents: 'none',
background: edgeFadeBackground(fill, 'bottom'),
}}
/>
) : null}
</div>
)
}
Loading
Loading