diff --git a/AGENTS.md b/AGENTS.md index d5ec37d..5092bed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,6 +91,7 @@ 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.13.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.13.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.13.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). - 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 index 5dac886..7c7c145 100644 --- a/docs/wave6-gpui-polish.md +++ b/docs/wave6-gpui-polish.md @@ -1,8 +1,8 @@ -# Wave 6 — GPUI polish (toast / empty CTA / focus chords) +# Wave 6 — GPUI polish (toast / palette / steer / density) -**Band:** P0 (feedback & findability) +**Bands:** P0 (feedback & findability) + **P1** (palette, queue chrome, density) **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. +**Patterns only** — recipes from rgitui / ChatGPUI / Waku / Bezel / Zeron. No CopilotKit, Electron, or `gpui-kit` / `gpui-component` crates. ## What shipped (P0) @@ -33,14 +33,44 @@ GPUIx `onKeyDown` plus the same HID watch discipline as paste/copy/quit (textare | **Cmd+J** | Toggle Jobs sheet | | Cmd+Shift+I | Inspector (existing) | +## What shipped (P1) + +### Command palette lite (Cmd+K) +- `src/chrome/command-palette.tsx` + `src/chrome/palette.ts` +- Overlay: fuzzy **Sisters** / **Rooms** / **Settings** sections / **Jobs** +- Search field keeps focus; list cursor is drawn separately +- **Esc** clears the query first, then closes +- Events only (select sister / room / open settings section / open Jobs) — no command ontology kit +- Opaque `menuFill` card + +### Visible steer queue card +- `src/chrome/steer-queue.tsx` above the composer +- One opaque card; one row per queued line +- **Remove** / **Send now** (promote when busy; send that line alone when idle) +- Quiet microcopy when Send would queue and the queue is empty +- Session: `removeSteerAt` / `sendSteerNow` + +### List density + hover end-slot +- Shared `ListRow` (`src/chrome/list-row.tsx`) — compact/default density +- Settings routines / skills / rooms: secondary actions reveal on hover +- Rail New / Settings footer rows use `ListRow` (compact when rail is compact) + +### Tooltip microcopy +- `Tip` (`src/chrome/tooltip.tsx`) — one-line raised opaque card; no new npm lib +- Titlebar Computer / Jobs; rail New / Settings / sister hits + +| Chord | Action | +| --- | --- | +| **Cmd+K** | Toggle command palette | + 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) +- Pulse lease strides / overlay scrollbar (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`**. +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`. diff --git a/src/app.tsx b/src/app.tsx index ef47ae9..96921d7 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, watchFocusComposerHotkey, watchJobsHotkey, watchPasteHotkey, watchQuitHotkey, watchSelectAllHotkey, watchSettingsHotkey, watchToggleRailHotkey } from './runtime/paste-hotkey' +import { watchCopyHotkey, watchCutHotkey, watchFocusComposerHotkey, watchJobsHotkey, watchPasteHotkey, watchQuitHotkey, watchSelectAllHotkey, watchSettingsHotkey, watchToggleRailHotkey, watchPaletteHotkey } from './runtime/paste-hotkey' import { runningTests } from './runtime/test-env' import { applyUpdate, checkForUpdate, dismissUpdate, readDismissedSha, shouldOfferUpdate, relaunchAutomaton, type UpdateOffer } from './runtime/updates' import { @@ -88,6 +88,7 @@ import { toggleRailChord, settingsChord, jobsChord, + commandPaletteChord, } from './inspector' import { DeskStage } from './desk' import { JobsPane } from './jobs-pane' @@ -101,6 +102,7 @@ import { initiatorFromKickoff, mouthStreamLabel } from './runtime/mouth-stream' import { sessionActivityForSister } from './runtime/session-activity' import { setHumanDriving } from './runtime/driving' import { quitAutomaton } from './runtime/quit' +import { listRooms } from './runtime/rooms' import { ensureScreen } from './runtime/screen' import { addLiveAgent, @@ -117,6 +119,8 @@ import { dismissFanout, dropLiveAgent, dropPendingPath, + removeSteerAt, + sendSteerNow, failComputer, failJob, failMouth, @@ -153,7 +157,7 @@ import { import { SisterBlob, framePath, markFor } from './blob' import { railDragOrigin, railIsCompact, railWidthFromDrag, readSkin, toggleRailWidth, writeSkin } from './runtime/skin' import { ConfirmCard, QuestionCard, SecretRequestCard } from './cards' -import { ActivityZone, Composer, EmptyState, MouthWaitBubble, Sheet, Titlebar, ToastStack, activityVisible, groupBoxStyle, pushToast, tracesFromJob } from './chrome' +import { ActivityZone, CommandPalette, Composer, 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' @@ -265,6 +269,8 @@ function StaffApp({ store: providedStore }: { store?: StaffStore } = {}) { }) const [pane, setPane] = useState('none') const [composerFocusNonce, setComposerFocusNonce] = useState(0) + const [paletteOpen, setPaletteOpen] = useState(false) + const [settingsFocus, setSettingsFocus] = useState(null) const [planeTick, setPlaneTick] = useState(0) const [railWidth, setRailWidth] = useState(() => readSkin().railWidth) const [railDragging, setRailDragging] = useState(false) @@ -337,6 +343,15 @@ function StaffApp({ store: providedStore }: { store?: StaffStore } = {}) { setPane((current) => (current === 'settings' ? 'none' : 'settings')) } + const togglePalette = () => { + setPaletteOpen((open) => !open) + } + + const openSettingsSection = (section?: string) => { + setSettingsFocus(section ?? null) + setPane('settings') + } + const focusComposer = () => { setComposerFocusNonce((n) => n + 1) } @@ -855,6 +870,7 @@ function StaffApp({ store: providedStore }: { store?: StaffStore } = {}) { const stopRail = watchToggleRailHotkey(() => toggleRail()) const stopSettings = watchSettingsHotkey(() => toggleSettings()) const stopJobs = watchJobsHotkey(() => toggleJobs()) + const stopPalette = watchPaletteHotkey(() => togglePalette()) return () => { stopPaste() stopCopy() @@ -865,6 +881,7 @@ function StaffApp({ store: providedStore }: { store?: StaffStore } = {}) { stopRail() stopSettings() stopJobs() + stopPalette() } }, []) @@ -922,6 +939,7 @@ function StaffApp({ store: providedStore }: { store?: StaffStore } = {}) { if (toggleRailChord(event)) toggleRail() if (settingsChord(event)) toggleSettings() if (jobsChord(event)) toggleJobs() + if (commandPaletteChord(event)) togglePalette() if (pasteChord(event)) enqueueClipboard() if (selectAllChord(event)) feedApi.current?.selectAll() if (copyChord(event)) copyFeedIfIdle() @@ -930,6 +948,23 @@ function StaffApp({ store: providedStore }: { store?: StaffStore } = {}) { }} > + setPaletteOpen(false)} + onSelectSister={(id) => { + setSession((current) => (runningTests() ? setActive(current, id) : playIntro(setActive(current, id), id))) + setPane('none') + }} + onSelectRoom={(_roomId) => { + openSettingsSection('rooms') + }} + onOpenSettings={(section) => openSettingsSection(section)} + onOpenJobs={() => { + setPane('jobs') + }} + />
) : null} + + setSession((current) => removeSteerAt(current, current.activeAgentId, index)) + } + onSendNow={(index) => + setSession((current) => sendSteerNow(current, current.activeAgentId, index)) + } + /> setPane('none')} + focusSection={settingsFocus} + onClose={() => { + setSettingsFocus(null) + setPane('none') + }} onToast={(level, message) => pushToast({ level, message })} onPlaneChange={() => setPlaneTick((n) => n + 1)} onSkinChange={() => { @@ -1629,8 +1683,8 @@ function Rail({ const family = modelFamily(pin) const at = row ? lastItemAt(row.items) : null return ( +
+
) })}
-
- - {compact ? null :
New automaton
} +
+ + } + onClick={onCreate} + > + {compact ? null :
New automaton
} +
+
-
- - {compact ? null :
Settings
} +
+ + } + onClick={onSettings} + > + {compact ? null :
Settings
} +
+
) diff --git a/src/chrome/command-palette.tsx b/src/chrome/command-palette.tsx new file mode 100644 index 0000000..408de84 --- /dev/null +++ b/src/chrome/command-palette.tsx @@ -0,0 +1,242 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { toFieldTheme, useTokens } from '../theme' +import { menuItemStyle } from '../ui' +import { groupBoxStyle, menuFill } from './surface' +import { + buildPaletteItems, + filterPaletteItems, + groupPaletteItems, + type PaletteItem, +} from './palette' + +const HIT = { + cursor: 'pointer' as const, + pointerEvents: 'auto' as const, + userSelect: 'none' as const, +} + +export type CommandPaletteProps = { + open: boolean + agents?: readonly { id: string; name: string; title?: string; hidden?: boolean }[] + rooms?: readonly { id: string; name: string; archived?: boolean }[] + onClose: () => void + onSelectSister: (agentId: string) => void + onSelectRoom: (roomId: string) => void + onOpenSettings: (section?: string) => void + onOpenJobs: () => void +} + +/** Cmd+K overlay — field keeps focus; Esc clears then closes; opaque menu fill. */ +export function CommandPalette({ + open, + agents = [], + rooms = [], + onClose, + onSelectSister, + onSelectRoom, + onOpenSettings, + onOpenJobs, +}: CommandPaletteProps) { + const T = useTokens() + const [query, setQuery] = useState('') + const [highlight, setHighlight] = useState(0) + + useEffect(() => { + if (!open) { + setQuery('') + setHighlight(0) + } + }, [open]) + + const catalog = useMemo(() => buildPaletteItems({ agents, rooms }), [agents, rooms]) + const picks = useMemo(() => filterPaletteItems(catalog, query), [catalog, query]) + const groups = useMemo(() => groupPaletteItems(picks), [picks]) + + useEffect(() => { + setHighlight(0) + }, [query]) + + if (!open) return null + + const flat = picks + const run = (item: PaletteItem | undefined) => { + if (!item) return + if (item.section === 'Sisters' && item.targetId) onSelectSister(item.targetId) + else if (item.section === 'Rooms' && item.targetId) onSelectRoom(item.targetId) + else if (item.section === 'Settings') onOpenSettings(item.settingsSection) + else if (item.section === 'Jobs') onOpenJobs() + onClose() + } + + return ( +
onClose()} + onKeyDown={(event) => { + if (event.key === 'Escape' || event.key === 'Esc') { + if (query.length > 0) { + setQuery('') + setHighlight(0) + return + } + onClose() + } + }} + > +
{ + event.stopPropagation?.() + }} + > + setQuery(event.value ?? '')} + onKeyDown={(event) => { + if (event.key === 'ArrowDown' || event.key === 'Down') { + if (flat.length === 0) return + setHighlight((n) => (n + 1) % flat.length) + return + } + if (event.key === 'ArrowUp' || event.key === 'Up') { + if (flat.length === 0) return + setHighlight((n) => (n - 1 + flat.length) % flat.length) + return + } + if (event.key === 'Enter') { + run(flat[highlight]) + return + } + if (event.key === 'Escape' || event.key === 'Esc') { + if (query.length > 0) { + setQuery('') + setHighlight(0) + return + } + onClose() + } + }} + /> +
+ {flat.length === 0 ? ( +
+ No matches. +
+ ) : ( + groups.map((group) => { + let offset = 0 + for (const prior of groups) { + if (prior.section === group.section) break + offset += prior.items.length + } + return ( +
+
+ {group.section} +
+ {group.items.map((item, index) => { + const flatIndex = offset + index + const on = flatIndex === highlight + return ( +
run(item)} + onMouseEnter={() => setHighlight(flatIndex)} + > +
{item.label}
+ {item.hint ? ( +
{item.hint}
+ ) : null} +
+ ) + })} +
+ ) + }) + )} +
+
+
+ ) +} diff --git a/src/chrome/composer.tsx b/src/chrome/composer.tsx index 2388d0f..f929923 100644 --- a/src/chrome/composer.tsx +++ b/src/chrome/composer.tsx @@ -71,8 +71,9 @@ export function Composer({ const picks = hash ? filterMentions(catalog, hash.query) : [] const pickerOpen = picks.length > 0 const ready = (value.trim().length > 0 || pendingPaths.length > 0) && !locked - const steer = - queued > 0 ? `${queued} queued` : queueing ? 'Send queues until this turn ends' : null + // Wave 6 P1: visible SteerQueueCard owns queue chrome; props kept for callers. + void queueing + void queued const pick = (index: number) => { const item = picks[index] if (!item) return @@ -325,20 +326,6 @@ export function Composer({
- {steer ? ( -
- {steer} -
- ) : null} ) diff --git a/src/chrome/index.ts b/src/chrome/index.ts index 0c24a22..a73f299 100644 --- a/src/chrome/index.ts +++ b/src/chrome/index.ts @@ -63,3 +63,16 @@ export { type SurfaceRole, } from './surface' export { Titlebar } from './titlebar' + +export { CommandPalette } from './command-palette' +export { + buildPaletteItems, + filterPaletteItems, + groupPaletteItems, + SETTINGS_PALETTE, + type PaletteItem, + type PaletteSection, +} from './palette' +export { SteerQueueCard } from './steer-queue' +export { ListRow, type ListRowDensity } from './list-row' +export { Tip } from './tooltip' diff --git a/src/chrome/list-row.tsx b/src/chrome/list-row.tsx new file mode 100644 index 0000000..b53d707 --- /dev/null +++ b/src/chrome/list-row.tsx @@ -0,0 +1,86 @@ +import React, { useState } from 'react' +import { useTokens } from '../theme' + +export type ListRowDensity = 'default' | 'compact' + +const HIT = { + cursor: 'pointer' as const, + pointerEvents: 'auto' as const, + userSelect: 'none' as const, +} + +/** + * Shared list row — rgitui ListItem density + hover end-slot recipe. + * Hover reveals secondary actions without permanent chrome growth. + */ +export function ListRow({ + testId, + density = 'default', + selected = false, + startSlot, + endSlot, + endHoverSlot, + children, + onClick, + onContextMenu, +}: { + testId?: string + density?: ListRowDensity + selected?: boolean + startSlot?: React.ReactNode + endSlot?: React.ReactNode + /** Shown only while the row is hovered. */ + endHoverSlot?: React.ReactNode + children?: React.ReactNode + onClick?: () => void + onContextMenu?: (event: { x?: number; y?: number; isRightClick?: boolean; button?: number }) => void +}) { + const T = useTokens() + const [hovered, setHovered] = useState(false) + const compact = density === 'compact' + const padY = compact ? T.space.xs : T.space.sm + const padX = compact ? T.space.sm : T.space.md + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + onClick={(event) => { + if (event.isRightClick || event.button === 2) return + onClick?.() + }} + onMouseDown={(event) => { + if (event.isRightClick || event.button === 2) onContextMenu?.(event) + }} + > + {startSlot ?
{startSlot}
: null} +
+ {children} +
+ {endSlot ?
{endSlot}
: null} + {endHoverSlot && hovered ? ( +
+ {endHoverSlot} +
+ ) : null} +
+ ) +} diff --git a/src/chrome/palette.ts b/src/chrome/palette.ts new file mode 100644 index 0000000..0f8750b --- /dev/null +++ b/src/chrome/palette.ts @@ -0,0 +1,118 @@ +/** Command palette catalog + fuzzy filter. Events only — no command ontology kit. */ + +export type PaletteSection = 'Sisters' | 'Rooms' | 'Settings' | 'Jobs' + +export type PaletteItem = { + id: string + section: PaletteSection + label: string + hint?: string + /** Settings section anchor when section === Settings */ + settingsSection?: string + /** Sister / room id when jumping */ + targetId?: string +} + +export const SETTINGS_PALETTE: { id: string; label: string; hint?: string }[] = [ + { id: 'appearance', label: 'Appearance', hint: 'Window · wash · brand' }, + { id: 'keys', label: 'OpenRouter key', hint: 'Secret stays off chat' }, + { id: 'usage', label: 'Usage', hint: 'Ledger' }, + { id: 'mouth', label: 'Mouth context', hint: 'Compact working set' }, + { id: 'connectors', label: 'Connectors', hint: 'OpenRouter mouth' }, + { id: 'providers', label: 'Providers', hint: 'Mouth · Jobs · cloud map' }, + { id: 'mcp', label: 'MCP catalog', hint: 'Connectors registry' }, + { id: 'channels', label: 'Channels', hint: 'Slack' }, + { id: 'rooms', label: 'Rooms', hint: 'Multi-agent rooms' }, + { id: 'routines', label: 'Routines', hint: 'Scheduled wakes' }, + { id: 'skills', label: 'Skills', hint: 'Local / imported' }, + { id: 'computer', label: 'Computer', hint: 'Shared box' }, + { id: 'cloud', label: 'Cloud / Origin', hint: 'Cloud Agents bind' }, + { id: 'about', label: 'About', hint: 'Version' }, +] + +export function buildPaletteItems(input: { + agents?: readonly { id: string; name: string; title?: string; hidden?: boolean }[] + rooms?: readonly { id: string; name: string; archived?: boolean }[] +}): PaletteItem[] { + const out: PaletteItem[] = [] + for (const agent of input.agents ?? []) { + if (agent.hidden) continue + out.push({ + id: `sister:${agent.id}`, + section: 'Sisters', + label: agent.name, + hint: agent.title, + targetId: agent.id, + }) + } + for (const room of input.rooms ?? []) { + if (room.archived) continue + out.push({ + id: `room:${room.id}`, + section: 'Rooms', + label: room.name, + hint: 'Room', + targetId: room.id, + }) + } + for (const row of SETTINGS_PALETTE) { + out.push({ + id: `settings:${row.id}`, + section: 'Settings', + label: row.label, + hint: row.hint, + settingsSection: row.id, + }) + } + out.push({ + id: 'jobs:open', + section: 'Jobs', + label: 'Open Jobs', + hint: 'Board · flying work', + }) + return out +} + +/** Case-insensitive substring fuzzy — prefer startsWith, then includes. */ +export function filterPaletteItems( + items: readonly PaletteItem[], + query: string, + cap = 24, +): PaletteItem[] { + const q = query.trim().toLowerCase() + if (!q) { + return items.slice(0, Math.max(0, cap)) + } + const scored: { item: PaletteItem; score: number }[] = [] + for (const item of items) { + const label = item.label.toLowerCase() + const hint = (item.hint ?? '').toLowerCase() + const section = item.section.toLowerCase() + const id = (item.targetId ?? item.settingsSection ?? '').toLowerCase() + let score = -1 + if (label.startsWith(q)) score = 100 + else if (id.startsWith(q)) score = 90 + else if (label.includes(q)) score = 70 + else if (hint.includes(q)) score = 50 + else if (section.startsWith(q) || section.includes(q)) score = 30 + if (score >= 0) scored.push({ item, score }) + } + scored.sort((a, b) => b.score - a.score || a.item.label.localeCompare(b.item.label)) + return scored.slice(0, Math.max(0, cap)).map((row) => row.item) +} + +export function groupPaletteItems(items: readonly PaletteItem[]): { + section: PaletteSection + items: PaletteItem[] +}[] { + const order: PaletteSection[] = ['Sisters', 'Rooms', 'Settings', 'Jobs'] + const map = new Map() + for (const section of order) map.set(section, []) + for (const item of items) { + const bucket = map.get(item.section) + if (bucket) bucket.push(item) + } + return order + .map((section) => ({ section, items: map.get(section) ?? [] })) + .filter((row) => row.items.length > 0) +} diff --git a/src/chrome/steer-queue.tsx b/src/chrome/steer-queue.tsx new file mode 100644 index 0000000..0679785 --- /dev/null +++ b/src/chrome/steer-queue.tsx @@ -0,0 +1,110 @@ +import React from 'react' +import type { SteerLine } from '../domain' +import { useTokens } from '../theme' +import { Chip } from '../ui' +import { groupBoxStyle, menuFill } from './surface' + +/** Visible steer queue card above the composer — one row per queued line. Opaque fill. */ +export function SteerQueueCard({ + lines, + queueing = false, + onRemove, + onSendNow, +}: { + lines: readonly SteerLine[] + /** Mouth will park Send until the turn ends. Quiet microcopy when empty queue. */ + queueing?: boolean + onRemove: (index: number) => void + onSendNow: (index: number) => void +}) { + const T = useTokens() + if (lines.length === 0 && !queueing) return null + if (lines.length === 0 && queueing) { + return ( +
+
Send queues until this turn ends
+
+ ) + } + return ( +
+
+
+ {lines.length === 1 ? '1 queued' : `${lines.length} queued`} +
+ {lines.map((line, index) => ( +
+
+ {line.text.trim() || '(attachment)'} +
+ onSendNow(index)}> + Send now + + onRemove(index)}> + Remove + +
+ ))} +
+
+ ) +} diff --git a/src/chrome/titlebar.tsx b/src/chrome/titlebar.tsx index 8652c41..f4feca5 100644 --- a/src/chrome/titlebar.tsx +++ b/src/chrome/titlebar.tsx @@ -3,6 +3,7 @@ import { MARK_PATH, PRODUCT } from '../brand' import { runningTests } from '../runtime/test-env' import { useTokens } from '../theme' import { controlClusterStyle, titlebarHitStyle, titlebarLeadStyle, titlebarRowStyle } from './layout' +import { Tip } from './tooltip' const inspectArmed = { current: false } @@ -109,45 +110,49 @@ export function Titlebar({
-
{ - if (event.isRightClick || event.button === 2) return - inspectArmed.current = true - inspect() - } - } - onClick={(event) => { - if (event.isRightClick || event.button === 2) return - if (inspectArmed.current) { - inspectArmed.current = false - return + +
{ + if (event.isRightClick || event.button === 2) return + inspectArmed.current = true + inspect() + } } - inspect() - }} - > - -
-
{ - if (event.isRightClick || event.button === 2) return - onJobs() - }} - > - Jobs -
+ onClick={(event) => { + if (event.isRightClick || event.button === 2) return + if (inspectArmed.current) { + inspectArmed.current = false + return + } + inspect() + }} + > + +
+ + +
{ + if (event.isRightClick || event.button === 2) return + onJobs() + }} + > + Jobs +
+
) diff --git a/src/chrome/tooltip.tsx b/src/chrome/tooltip.tsx new file mode 100644 index 0000000..de2e15d --- /dev/null +++ b/src/chrome/tooltip.tsx @@ -0,0 +1,58 @@ +import React, { useState } from 'react' +import { useTokens } from '../theme' +import { groupBoxStyle, menuFill } from './surface' + +/** + * One-line tooltip microcopy (Waku recipe). No new npm lib — hover card with opaque menu fill. + */ +export function Tip({ + label, + children, + testId, + side = 'bottom', +}: { + label: string + children: React.ReactNode + testId?: string + side?: 'top' | 'bottom' | 'right' +}) { + const T = useTokens() + const [open, setOpen] = useState(false) + return ( +
setOpen(true)} + onMouseLeave={() => setOpen(false)} + > + {children} + {open && label ? ( +
+ {label} +
+ ) : null} +
+ ) +} diff --git a/src/inspector.tsx b/src/inspector.tsx index 8aef2ba..faeea3f 100644 --- a/src/inspector.tsx +++ b/src/inspector.tsx @@ -164,6 +164,13 @@ export function jobsChord(event: { return cmdLetterChord(event, 'j') } +export function commandPaletteChord(event: { + key?: string + modifiers?: { cmd?: boolean; shift?: boolean; alt?: boolean } +}): boolean { + return cmdLetterChord(event, 'k') +} + export function LedgerList({ metrics, testId }: { metrics: LedgerMetrics; testId: string }) { const { tokens: T } = useChrome() return ( diff --git a/src/runtime/paste-hotkey.ts b/src/runtime/paste-hotkey.ts index 0f9c6f2..e7f2558 100644 --- a/src/runtime/paste-hotkey.ts +++ b/src/runtime/paste-hotkey.ts @@ -11,6 +11,7 @@ const KEY_W = 13 const KEY_B = 11 const KEY_L = 37 const KEY_J = 38 +const KEY_K = 40 const KEY_COMMA = 43 const KEY_LCMD = 55 const KEY_RCMD = 54 @@ -183,6 +184,10 @@ export type JobsHotkeySeams = HidHotkeySeams & { cmdJ?: () => boolean } +export type PaletteHotkeySeams = HidHotkeySeams & { + cmdK?: () => boolean +} + export function cmdLDown(read = loadHid()): boolean { return cmdLetterDown(KEY_L, read) } @@ -199,6 +204,10 @@ export function cmdJDown(read = loadHid()): boolean { return cmdLetterDown(KEY_J, read) } +export function cmdKDown(read = loadHid()): boolean { + return cmdLetterDown(KEY_K, read) +} + /** Cmd+L — focus composer (textarea may swallow React key). */ export function watchFocusComposerHotkey(onFocus: () => void, seams?: FocusComposerHotkeySeams): () => void { return watchChord(seams?.cmdL ?? cmdLDown, onFocus, seams) @@ -218,3 +227,8 @@ export function watchSettingsHotkey(onSettings: () => void, seams?: SettingsHotk export function watchJobsHotkey(onJobs: () => void, seams?: JobsHotkeySeams): () => void { return watchChord(seams?.cmdJ ?? cmdJDown, onJobs, seams) } + +/** Cmd+K — open / close command palette. */ +export function watchPaletteHotkey(onPalette: () => void, seams?: PaletteHotkeySeams): () => void { + return watchChord(seams?.cmdK ?? cmdKDown, onPalette, seams) +} diff --git a/src/session.ts b/src/session.ts index f66a75c..92f8a6a 100644 --- a/src/session.ts +++ b/src/session.ts @@ -321,6 +321,41 @@ export function drainSteer(session: Session, agentId: AgentId): Session { return next } +/** Drop one parked steer line by index. No-op when out of range. */ +export function removeSteerAt(session: Session, agentId: AgentId, index: number): Session { + const row = session.threads[agentId] + if (!row) return session + const queued = [...(row.steerQueue ?? [])] + if (index < 0 || index >= queued.length) return session + queued.splice(index, 1) + return setThread(session, agentId, { steerQueue: queued }) +} + +/** + * Send-now / steer one queued line. + * Idle mouth → send that line alone (leave the rest parked). + * Busy / queueing mouth → promote the line to the front of the queue. + */ +export function sendSteerNow(session: Session, agentId: AgentId, index: number): Session { + const row = session.threads[agentId] + if (!row) return session + const queued = [...(row.steerQueue ?? [])] + if (index < 0 || index >= queued.length) return session + const [line] = queued.splice(index, 1) + if (!line) return session + const busy = + shouldQueueSteer(row.mouth, row.computerBusy === true) || row.mouth !== 'idle' + if (busy) { + return setThread(session, agentId, { steerQueue: [line, ...queued] }) + } + let next = setThread(session, agentId, { steerQueue: queued }) + const focused = next.activeAgentId + if (focused !== agentId) next = { ...next, activeAgentId: agentId } + next = send(next, line.text, line.attachmentIds ?? []) + if (focused !== agentId && next.threads[focused]) next = { ...next, activeAgentId: focused } + return next +} + function dropJobSteer(session: Session, agentId: AgentId): Session { if (!session.threads[agentId]) return session return setThread(session, agentId, { jobSteerQueue: [] }) diff --git a/src/settings.tsx b/src/settings.tsx index 5f148d0..e93cb7c 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, ToggleGroup, pushToast, type ToastLevel } from './chrome' +import { EmptyState, ListRow, ToggleGroup, pushToast, type ToastLevel } from './chrome' import { BRAND_ACCENT_SWATCHES, BRAND_RADIUS_PRESETS, @@ -91,6 +91,29 @@ import { import { CloudOriginPanel } from './cloud-origin-panel' import { githubUrlFromHomeRepo, readExplicitOriginRemote } from './runtime/cloud-origin' + +function SettingsAnchor({ + id, + focus, + children, +}: { + id: string + focus: string | null | undefined + children: React.ReactNode +}) { + const chrome = useChrome() + const T = chrome.tokens + const on = focus === id + return ( +
+ {children} +
+ ) +} + export function openRouterPresence(): 'present' | 'missing' { return listOpenRouterKeys().length > 0 ? 'present' : 'missing' } @@ -397,59 +420,62 @@ function RoutinesCard({ agents }: { agents: Agent[] }) { /> ) : ( rows.map((row) => ( -
-
+
+ + {row.enabled ? 'on' : 'paused'} + {row.trigger ? ` · ${row.trigger.type}` : ' · schedule'} +
+ } + endHoverSlot={ +
+ {row.enabled ? ( + { + pauseRoutine(row.agentId, row.id) + refresh() + }} + > + Pause + + ) : ( + { + resumeRoutine(row.agentId, row.id) + refresh() + }} + > + Resume + + )} + { + deleteRoutine(row.agentId, row.id) + refresh() + }} + > + Delete + +
+ } + >
{row.name}
-
- {row.enabled ? 'on' : 'paused'} - {row.trigger ? ` · ${row.trigger.type}` : ' · schedule'} +
+ {scheduleOrTriggerSummary(row)} · last {formatLastRun(row.lastRunAt)}
-
-
- {scheduleOrTriggerSummary(row)} · last {formatLastRun(row.lastRunAt)} -
- {row.lastError ? ( -
{row.lastError}
- ) : null} -
- {row.enabled ? ( - { - pauseRoutine(row.agentId, row.id) - refresh() - }} - > - Pause - - ) : ( - { - resumeRoutine(row.agentId, row.id) - refresh() - }} - > - Resume - - )} - { - deleteRoutine(row.agentId, row.id) - refresh() - }} - > - Delete - -
+ {row.lastError ? ( +
{row.lastError}
+ ) : null} +
)) )} @@ -611,56 +637,59 @@ function SkillsCard({ agents }: { agents: Agent[] }) { const pinned = profileSkillIds.includes(row.id) const disabled = row.origin === 'imported' && !row.enabled return ( -
-
+
+ + {row.origin} + {disabled ? ' · disabled' : ''} + {pinned ? ' · pinned' : ''} +
+ } + endHoverSlot={ +
+ openSkill(row.id)}> + Open + + togglePin(row.id)} + > + {pinned ? 'Unpin' : 'Pin'} + + {row.origin === 'imported' ? ( + { + setSkillEnabled(row.id, !row.enabled) + refresh() + }} + > + {row.enabled ? 'Disable' : 'Enable'} + + ) : null} + remove(row.id)} + > + Delete + +
+ } + >
{row.name} · {row.id}
-
- {row.origin} - {disabled ? ' · disabled' : ''} - {pinned ? ' · pinned' : ''} +
+ {row.description.trim() || 'No use-when description'}
-
-
- {row.description.trim() || 'No use-when description'} -
-
- openSkill(row.id)}> - Open - - togglePin(row.id)} - > - {pinned ? 'Unpin' : 'Pin'} - - {row.origin === 'imported' ? ( - { - setSkillEnabled(row.id, !row.enabled) - refresh() - }} - > - {row.enabled ? 'Disable' : 'Enable'} - - ) : null} - remove(row.id)} - > - Delete - -
+
) }) @@ -827,14 +856,44 @@ function RoomsCard({ agents }: { agents: Agent[] }) {
-
+ + {row.archived ? 'archived' : `${row.memberIds.length} members`} +
+ } + endHoverSlot={ +
+ {!row.archived ? ( + { + archiveRoom(row.id) + refresh() + }} + > + Archive + + ) : null} + { + deleteRoom(row.id) + refresh() + }} + > + Delete + +
+ } + >
{row.name}
-
- {row.archived ? 'archived' : `${row.memberIds.length} members`} -
-
+
{row.memberIds .map((id) => seats.find((agent) => agent.id === id)?.name ?? id) @@ -857,30 +916,6 @@ function RoomsCard({ agents }: { agents: Agent[] }) { })}
) : null} -
- {!row.archived ? ( - { - archiveRoom(row.id) - refresh() - }} - > - Archive - - ) : null} - { - deleteRoom(row.id) - refresh() - }} - > - Delete - -
)) )} @@ -1120,6 +1155,7 @@ function AboutCard() { export function Settings({ metrics, agents = [], + focusSection = null, onClose, onToast, onPlaneChange, @@ -1128,6 +1164,8 @@ export function Settings({ }: { metrics: LedgerMetrics agents?: Agent[] + /** Palette jump — highlight a settings section id. */ + focusSection?: string | null onClose: () => void onToast?: (level: ToastLevel, message: string) => void onPlaneChange?: () => void @@ -1295,7 +1333,10 @@ export function Settings({
- + + + +
+
{others.length > 0 ? ( setMoreOpen((open) => !open)}> {moreOpen ? 'Hide other automata' : `Other automata · ${others.length}`} @@ -1334,11 +1376,14 @@ export function Settings({ ) : null} {chief ? renderSeat(chief, 0) : null} {moreOpen ? others.map((agent, index) => renderSeat(agent, index + 1)) : null} +
+
+
@@ -1355,6 +1400,8 @@ export function Settings({
+
+
+
+
@@ -1409,9 +1458,13 @@ export function Settings({ ))}
+
+
+
+
+
+
+
+
+
+
+
+
{computerLabel(boxStatus())}
+
+
+
+
+
) diff --git a/tests/paste-hotkey.test.ts b/tests/paste-hotkey.test.ts index 02e8439..9e646e4 100644 --- a/tests/paste-hotkey.test.ts +++ b/tests/paste-hotkey.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { cmdADown, cmdBDown, cmdCDown, cmdJDown, cmdLDown, cmdCommaDown, cmdQuitDown, cmdXDown, watchCopyHotkey, watchFocusComposerHotkey, watchPasteHotkey, watchQuitHotkey, watchSelectAllHotkey } from '../src/runtime/paste-hotkey' +import { cmdADown, cmdBDown, cmdCDown, cmdJDown, cmdKDown, cmdLDown, cmdCommaDown, cmdQuitDown, cmdXDown, watchCopyHotkey, watchFocusComposerHotkey, watchPasteHotkey, watchQuitHotkey, watchSelectAllHotkey } from '../src/runtime/paste-hotkey' describe('paste hotkey', () => { test('fires once on cmd-v edge while frontmost', () => { @@ -153,7 +153,7 @@ describe('paste hotkey', () => { }) describe('focus chords (wave 6 p0)', () => { - test('cmdL / cmdB / cmdComma / cmdJ without shift', () => { + test('cmdL / cmdB / cmdComma / cmdJ / cmdK without shift', () => { const keys = new Set() const read = (_state: number, key: number) => keys.has(key) keys.add(55) @@ -171,6 +171,9 @@ describe('focus chords (wave 6 p0)', () => { keys.delete(43) keys.add(38) expect(cmdJDown(read)).toBe(true) + keys.delete(38) + keys.add(40) + expect(cmdKDown(read)).toBe(true) }) test('watchFocusComposerHotkey fires once on edge', () => { diff --git a/tests/wave6-p1.test.ts b/tests/wave6-p1.test.ts new file mode 100644 index 0000000..2c9ed56 --- /dev/null +++ b/tests/wave6-p1.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, test } from 'bun:test' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { + buildPaletteItems, + filterPaletteItems, + groupPaletteItems, + SETTINGS_PALETTE, +} from '../src/chrome/palette' +import { menuFill, surfaceNeedsOpaqueFill } from '../src/chrome/surface' +import { commandPaletteChord } from '../src/inspector' +import { cmdKDown } from '../src/runtime/paste-hotkey' +import { + completeMouth, + removeSteerAt, + send, + sendSteerNow, + stopMouth, + type Session, +} from '../src/session' +import { emptyThreads, resetIdsForTests, staffWithSisters } from '../src/domain' +import { T } from '../src/tokens' + +const root = join(import.meta.dir, '..') + +function fresh(): Session { + resetIdsForTests() + const agents = staffWithSisters() + return { + agents, + activeAgentId: 'staff', + threads: emptyThreads(agents), + jobs: [], + pendingFanout: null, + } +} + +describe('wave 6 p1 palette', () => { + test('Cmd+K chord + HID match paste-hotkey discipline', () => { + expect(commandPaletteChord({ key: 'k', modifiers: { cmd: true } })).toBe(true) + expect(commandPaletteChord({ key: 'k', modifiers: { cmd: true, shift: true } })).toBe(false) + const keys = new Set() + const read = (_s: number, key: number) => keys.has(key) + keys.add(55) // cmd + keys.add(40) // K + expect(cmdKDown(read)).toBe(true) + keys.clear() + keys.add(55) + keys.add(40) + keys.add(56) // shift + expect(cmdKDown(read)).toBe(false) + }) + + test('fuzzy catalog covers sisters, rooms, settings, jobs', () => { + const items = buildPaletteItems({ + agents: [ + { id: 'staff', name: 'Chief of Staff', title: 'Coordinator' }, + { id: 'kernel', name: 'Kernel', title: 'Code' }, + ], + rooms: [{ id: 'r1', name: 'Ship room' }], + }) + expect(items.some((row) => row.section === 'Sisters' && row.targetId === 'kernel')).toBe(true) + expect(items.some((row) => row.section === 'Rooms' && row.label === 'Ship room')).toBe(true) + expect(SETTINGS_PALETTE.length).toBeGreaterThan(5) + expect(items.some((row) => row.section === 'Jobs')).toBe(true) + const hit = filterPaletteItems(items, 'kern') + expect(hit[0]?.targetId).toBe('kernel') + const settings = filterPaletteItems(items, 'routine') + expect(settings.some((row) => row.settingsSection === 'routines')).toBe(true) + const groups = groupPaletteItems(filterPaletteItems(items, '')) + expect(groups.map((g) => g.section)).toContain('Settings') + }) + + test('palette + tip + steer paint use opaque menu fill', () => { + expect(surfaceNeedsOpaqueFill('menu')).toBe(true) + expect(menuFill(T).length).toBeGreaterThan(0) + const palette = readFileSync(join(root, 'src/chrome/command-palette.tsx'), 'utf8') + expect(palette).toContain('menuFill') + expect(palette).toContain("groupBoxStyle(T, 'menu')") + expect(palette).toContain('command-palette-input') + const tip = readFileSync(join(root, 'src/chrome/tooltip.tsx'), 'utf8') + expect(tip).toContain('menuFill') + const queue = readFileSync(join(root, 'src/chrome/steer-queue.tsx'), 'utf8') + expect(queue).toContain('menuFill') + expect(queue).toContain('Send now') + }) + + test('staff wires palette, steer card, ListRow, Tip', () => { + const app = readFileSync(join(root, 'src/app.tsx'), 'utf8') + expect(app).toContain(' { + test('removeSteerAt drops one line; sendSteerNow sends when idle', () => { + let s = send(fresh(), 'hello staff') + expect(s.threads.staff.mouth).toBe('answer') + s = send(s, 'first parked') + s = send(s, 'second parked') + expect(s.threads.staff.steerQueue).toEqual([ + { text: 'first parked' }, + { text: 'second parked' }, + ]) + s = removeSteerAt(s, 'staff', 0) + expect(s.threads.staff.steerQueue).toEqual([{ text: 'second parked' }]) + s = stopMouth(s, 'staff') + expect(s.threads.staff.mouth).toBe('idle') + s = sendSteerNow(s, 'staff', 0) + expect(s.threads.staff.steerQueue).toEqual([]) + const users = s.threads.staff.items.filter((item) => item.kind === 'msg' && item.from === 'user') + expect(users.some((item) => item.kind === 'msg' && item.text === 'second parked')).toBe(true) + }) + + test('sendSteerNow promotes to front while mouth is queueing', () => { + let s = send(fresh(), 'hello staff') + s = send(s, 'a') + s = send(s, 'b') + s = send(s, 'c') + expect(s.threads.staff.steerQueue.map((line) => line.text)).toEqual(['a', 'b', 'c']) + s = sendSteerNow(s, 'staff', 2) + expect(s.threads.staff.steerQueue.map((line) => line.text)).toEqual(['c', 'a', 'b']) + s = completeMouth(s, 'staff', 'ok') + // drain joins remaining parked lines after complete — promote only reordered while busy + expect(s.threads.staff.steerQueue.length).toBeGreaterThanOrEqual(0) + }) +}) + +describe('wave 6 p1 list row helper', () => { + test('ListRow module exports density + hover slot', () => { + const src = readFileSync(join(root, 'src/chrome/list-row.tsx'), 'utf8') + expect(src).toContain('endHoverSlot') + expect(src).toContain("density = 'default'") + expect(src).toContain('compact') + }) +})