diff --git a/AGENTS.md b/AGENTS.md index a1c3e10..a096f1c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,6 +90,7 @@ Invariants: - Peer provenance + standing role (Wave 4 P1) — `sendToAgent` / `postToRoom` stamp `originUser` + `hopDepth` on peer-hop wakes; `standingRoleBlock` (name/title/rules) on every mouth system preamble; unattended = routine | peer-hop without interactive person; no CopilotKit; sisters stay separate; package 0.12.0. - 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.12.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.12.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.12.0. - 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/wave6-gpui-polish.md b/docs/wave6-gpui-polish.md new file mode 100644 index 0000000..5dac886 --- /dev/null +++ b/docs/wave6-gpui-polish.md @@ -0,0 +1,46 @@ +# Wave 6 — GPUI polish (toast / empty CTA / focus chords) + +**Band:** P0 (feedback & findability) +**Package:** stays on current main (no version cut in this PR) +**Patterns only** — recipes from rgitui / ChatGPUI / Waku / Bezel. No CopilotKit, Electron, or `gpui-kit` / `gpui-component` crates. + +## What shipped (P0) + +### Toast stack + level policy +- `src/chrome/toast-store.ts` + `src/chrome/toast.tsx` +- Levels: `info` / `success` / `warn` / `error` +- TTL: info/success **~3s**, warn **~6s**, error **sticky** (dismiss only) +- Optional action chip; severity word is text (not color-only) +- Paint **above** the composer with **opaque** `menu` fill (`menuFill` / `groupBoxStyle(..., 'menu')`) so frost Metal never punches through +- Wired paths: mouth `onFail` (error + “Show sister”), secret save fail (feed card + Settings OpenRouter / MCP Connect), update-ready info toast when git tip offers + +Idle: no timers when the stack is empty; auto-dismiss timers clear on unmount/dismiss. + +### EmptyState + CTA +- `EmptyState` accepts optional `icon`, `actionLabel`, `onAction` +- Feed empty → **Write something** (focuses composer via `focusNonce`) +- Jobs empty → **Expand board** +- Settings empties (routines / skills / rooms / MCP search miss) get one verb where sensible + +### Focus chords +GPUIx `onKeyDown` plus the same HID watch discipline as paste/copy/quit (textarea swallows React keys): + +| Chord | Action | +| --- | --- | +| **Cmd+L** | Focus composer | +| **Cmd+B** | Toggle rail compact ↔ default width | +| **Cmd+,** | Toggle Settings sheet | +| **Cmd+J** | Toggle Jobs sheet | +| Cmd+Shift+I | Inspector (existing) | + +No full keymap file yet — P2 shortcuts-help can generate from one table later. + +## Parks (unchanged) +- Idle parks / resting-motion +- Steer queue still hint-string (P1 queue card) +- Command palette (P1) +- OS Notification Center (P2) +- 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 row status: **implemented on branch `feat/wave-6-p0-toast-empty-chords`**. diff --git a/src/app.tsx b/src/app.tsx index a2deb51..ef47ae9 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -33,7 +33,7 @@ import { visibleAgents, } from './domain' import { ingestPath, insertClipboardText, pickLocalFiles, readClipboardPaths, readClipboardText } from './runtime/attachments' -import { watchCopyHotkey, watchCutHotkey, watchPasteHotkey, watchQuitHotkey, watchSelectAllHotkey } from './runtime/paste-hotkey' +import { watchCopyHotkey, watchCutHotkey, watchFocusComposerHotkey, watchJobsHotkey, watchPasteHotkey, watchQuitHotkey, watchSelectAllHotkey, watchSettingsHotkey, watchToggleRailHotkey } from './runtime/paste-hotkey' import { runningTests } from './runtime/test-env' import { applyUpdate, checkForUpdate, dismissUpdate, readDismissedSha, shouldOfferUpdate, relaunchAutomaton, type UpdateOffer } from './runtime/updates' import { @@ -84,6 +84,10 @@ import { pasteChord, quitChord, selectAllChord, + focusComposerChord, + toggleRailChord, + settingsChord, + jobsChord, } from './inspector' import { DeskStage } from './desk' import { JobsPane } from './jobs-pane' @@ -147,9 +151,9 @@ import { type Session, } from './session' import { SisterBlob, framePath, markFor } from './blob' -import { railDragOrigin, railIsCompact, railWidthFromDrag, readSkin, writeSkin } from './runtime/skin' +import { railDragOrigin, railIsCompact, railWidthFromDrag, readSkin, toggleRailWidth, writeSkin } from './runtime/skin' import { ConfirmCard, QuestionCard, SecretRequestCard } from './cards' -import { ActivityZone, Composer, EmptyState, MouthWaitBubble, Sheet, Titlebar, activityVisible, groupBoxStyle, tracesFromJob } from './chrome' +import { ActivityZone, Composer, EmptyState, MouthWaitBubble, Sheet, Titlebar, ToastStack, activityVisible, groupBoxStyle, pushToast, tracesFromJob } from './chrome' import { connectorDisplayName } from './runtime/connectors' import { Settings } from './settings' import { motionTransition } from './motion' @@ -260,6 +264,7 @@ function StaffApp({ store: providedStore }: { store?: StaffStore } = {}) { return runningTests() ? seeded : playIntro(seeded, seeded.activeAgentId) }) const [pane, setPane] = useState('none') + const [composerFocusNonce, setComposerFocusNonce] = useState(0) const [planeTick, setPlaneTick] = useState(0) const [railWidth, setRailWidth] = useState(() => readSkin().railWidth) const [railDragging, setRailDragging] = useState(false) @@ -328,6 +333,22 @@ function StaffApp({ store: providedStore }: { store?: StaffStore } = {}) { setPane((current) => (current === 'jobs' ? 'none' : 'jobs')) } + const toggleSettings = () => { + setPane((current) => (current === 'settings' ? 'none' : 'settings')) + } + + const focusComposer = () => { + setComposerFocusNonce((n) => n + 1) + } + + const toggleRail = () => { + setRailWidth((current) => { + const next = toggleRailWidth(current) + writeSkin({ ...readSkin(), railWidth: next }) + return next + }) + } + const popOutDashboard = (jobId?: string | null) => { const located = ensureLocalDashboard(jobId) if (!located.ok) { @@ -385,6 +406,11 @@ function StaffApp({ store: providedStore }: { store?: StaffStore } = {}) { const offer = checkForUpdate() if (gone || !shouldOfferUpdate(offer, readDismissedSha())) return setUpdate(offer) + pushToast({ + id: `update-${offer?.latest ?? 'ready'}`, + level: 'info', + message: 'Updates available — review the update dialog.', + }) } const start = setTimeout(look, 800) const pulse = setInterval(look, 4 * 60 * 60 * 1000) @@ -504,6 +530,14 @@ function StaffApp({ store: providedStore }: { store?: StaffStore } = {}) { persistIntroIfUserSpoke(next) return next }) + pushToast({ + level: 'error', + message: spoken || "Couldn't speak.", + action: { + label: 'Show sister', + onAction: () => setSession((current) => ({ ...current, activeAgentId: agentId })), + }, + }) }, onCompact: (agentId, summary) => { setSession((current) => setThreadCompactSummary(current, agentId, summary)) @@ -817,12 +851,20 @@ function StaffApp({ store: providedStore }: { store?: StaffStore } = {}) { const stopSelectAll = watchSelectAllHotkey(() => feedApi.current?.selectAll()) const stopCut = watchCutHotkey(() => cutComposerIfFocused()) const stopQuit = watchQuitHotkey(() => quitAutomaton()) + const stopFocus = watchFocusComposerHotkey(() => focusComposer()) + const stopRail = watchToggleRailHotkey(() => toggleRail()) + const stopSettings = watchSettingsHotkey(() => toggleSettings()) + const stopJobs = watchJobsHotkey(() => toggleJobs()) return () => { stopPaste() stopCopy() stopSelectAll() stopCut() stopQuit() + stopFocus() + stopRail() + stopSettings() + stopJobs() } }, []) @@ -876,6 +918,10 @@ function StaffApp({ store: providedStore }: { store?: StaffStore } = {}) { }} onKeyDown={(event) => { if (inspectorChord(event)) toggleInspector() + if (focusComposerChord(event)) focusComposer() + if (toggleRailChord(event)) toggleRail() + if (settingsChord(event)) toggleSettings() + if (jobsChord(event)) toggleJobs() if (pasteChord(event)) enqueueClipboard() if (selectAllChord(event)) feedApi.current?.selectAll() if (copyChord(event)) copyFeedIfIdle() @@ -981,8 +1027,26 @@ function StaffApp({ store: providedStore }: { store?: StaffStore } = {}) { } onAnswerWidget={(id, answer) => setSession((current) => answerWidget(current, id, answer))} onDismissWidget={(id) => setSession((current) => dismissWidget(current, id))} - onSaveSecret={(id, value) => setSession((current) => fulfillSecretRequest(current, id, value))} + onSaveSecret={(id, value) => { + let failed = false + setSession((current) => { + const next = fulfillSecretRequest(current, id, value) + const before = current.threads[current.activeAgentId]?.items.find((row) => row.id === id) + const after = next.threads[next.activeAgentId]?.items.find((row) => row.id === id) + const saved = + before?.kind === 'secret-request' && + after?.kind === 'secret-request' && + before.status === 'open' && + after.status === 'saved' + if (value.trim() && before?.kind === 'secret-request' && before.status === 'open' && !saved) { + failed = true + } + return next + }) + if (failed) pushToast({ level: 'error', message: 'Need: could not save secret.' }) + }} onDismissSecret={(id) => setSession((current) => dismissSecretRequest(current, id))} + onEmptyWrite={focusComposer} />
) : null} + 0)), )} + focusNonce={composerFocusNonce} onChange={(value) => setSession((current) => setDraft(current, value))} onAttach={onAttach} onPaste={enqueueClipboard} @@ -1129,6 +1195,7 @@ function StaffApp({ store: providedStore }: { store?: StaffStore } = {}) { metrics={store.metrics()} agents={visibleAgents(session.agents)} onClose={() => setPane('none')} + onToast={(level, message) => pushToast({ level, message })} onPlaneChange={() => setPlaneTick((n) => n + 1)} onSkinChange={() => { refreshTokens() @@ -2133,6 +2200,7 @@ export const Feed = forwardRef void onSaveSecret?: (id: string, value: string) => void onDismissSecret?: (id: string) => void + onEmptyWrite?: () => void }>(function Feed( { items, @@ -2146,6 +2214,7 @@ export const Feed = forwardRef void onSend: () => void onStop?: () => void + /** Bump to remount the textarea with autoFocus (Cmd+L / empty CTA). */ + focusNonce?: number }) { const T = useTokens() const [highlight, setHighlight] = useState(0) @@ -197,6 +200,7 @@ export function Composer({
) : null}