From 190d810762e35df69ff6f10049ec97d34fbee744 Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 28 Aug 2026 02:45:34 +0200 Subject: [PATCH] agent: TodosPanel/InboxPanel flyouts + header pills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Preact `SidePanel` drawer — deliberately not the shared `` shadow-DOM custom element, same rationale as StatusChips' Badge/Dropdown. Drops that element's drag-to-resize + localStorage width persistence for this first slice (fixed width via CSS); reuses agent.css's existing `.agent-inbox`/`.inbox-*`/ `.side-panel-empty` content classes verbatim, only the drawer chrome itself is new (SidePanel.css). Kept mounted regardless of open/closed state (toggles the `open` prop) rather than conditionally rendered, so the slide/fade CSS transitions actually fire on both open and close. - InboxPanel: renders `state.inbox` (already polled by useAgentState) + a "mark all read" action — plain `fetch()` to the dashboard's `/api/agent/{name}/mark-all-read` (JSON response, same as app.js; unlike pause/resume this one doesn't need a form-submit workaround). - TodosPanel: new `useTodos` hook (same 4s poll cadence as app.js's `refreshTodos`) + bulk select/mark-done via `api/todos/mark-done`. - Both destructive-but-recoverable actions use a new `useConfirmClick` two-click-arm hook instead of the old shadow-DOM `themedConfirm` modal — lighter weight, no backdrop/dialog machinery needed for a "did you mean to click that" nudge. - HeaderPill: the inbox/todos count triggers in the header's right cluster; Header.tsx gained a `pills` slot for them (mirrors the old markup's `.agent-header-pills` third column). - types.ts: added `InboxRow`/`TodoRow`, mirroring `hive_sh4re::inbox::{InboxRow, LooseEnd}` (todo variant only — `/api/todos` never returns the others). Screenshot-verified against a mock server exercising both panels. --- frontend/packages/agent/src/Root.tsx | 43 +++++++- .../packages/agent/src/components/Header.tsx | 7 +- .../agent/src/components/HeaderPill.tsx | 26 +++++ .../agent/src/components/InboxPanel.tsx | 86 +++++++++++++++ .../agent/src/components/SidePanel.css | 80 ++++++++++++++ .../agent/src/components/SidePanel.tsx | 55 +++++++++ .../agent/src/components/TodosPanel.tsx | 104 ++++++++++++++++++ frontend/packages/agent/src/hooks/useTodos.ts | 48 ++++++++ .../packages/agent/src/lib/useConfirmClick.ts | 35 ++++++ frontend/packages/agent/src/types.ts | 24 ++++ 10 files changed, 505 insertions(+), 3 deletions(-) create mode 100644 frontend/packages/agent/src/components/HeaderPill.tsx create mode 100644 frontend/packages/agent/src/components/InboxPanel.tsx create mode 100644 frontend/packages/agent/src/components/SidePanel.css create mode 100644 frontend/packages/agent/src/components/SidePanel.tsx create mode 100644 frontend/packages/agent/src/components/TodosPanel.tsx create mode 100644 frontend/packages/agent/src/hooks/useTodos.ts create mode 100644 frontend/packages/agent/src/lib/useConfirmClick.ts diff --git a/frontend/packages/agent/src/Root.tsx b/frontend/packages/agent/src/Root.tsx index 9834b85d..ba3934e9 100644 --- a/frontend/packages/agent/src/Root.tsx +++ b/frontend/packages/agent/src/Root.tsx @@ -2,17 +2,25 @@ // to the real `/api/state` snapshot via `useAgentState`. Still a slice, // not the whole page — see main.tsx's file comment for what's left // (the live SSE stream, login flow, inbox/todos, term input, overflow). +import { useState } from 'preact/hooks'; import type { BadgeTone } from '@hive/shared/badge.js'; import { Header } from './components/Header.js'; import { StatusChips } from './components/StatusChips.js'; import { LiveStream } from './components/LiveStream.js'; +import { HeaderPill } from './components/HeaderPill.js'; +import { SidePanel } from './components/SidePanel.js'; +import { InboxPanel } from './components/InboxPanel.js'; +import { TodosPanel } from './components/TodosPanel.js'; import { useAgentState } from './hooks/useAgentState.js'; +import { useTodos } from './hooks/useTodos.js'; import { fmtAge, fmtTokens } from './lib/format.js'; import { resolveDashboardBase } from './lib/dashboardBase.js'; import { submitPauseResume } from './lib/pauseAction.js'; import { postModel, postEffort } from './lib/modelEffort.js'; import type { TokenUsage } from './types.js'; +type OpenPanel = 'inbox' | 'todos' | null; + const ALIVE_LABELS: Record = { online: { glyph: '●', text: 'alive', tone: 'positive' }, rate_limited: { glyph: '⊘', text: 'rate limited', tone: 'warning' }, @@ -41,11 +49,40 @@ function tokenTotal(u: TokenUsage | null): number | null { export function Root() { const { state, refresh } = useAgentState(); + const { todos, refresh: refreshTodos } = useTodos(); + const [openPanel, setOpenPanel] = useState(null); + + const pills = ( + <> + setOpenPanel('inbox')} /> + setOpenPanel('todos')} /> + + ); + // Kept mounted regardless of `openPanel` (open/closed toggles just the + // `open` prop) rather than conditionally rendering the whole + // component — SidePanel's slide/fade CSS transitions only fire on a + // class change after mount, not on an already-open initial render, so + // mount-on-open would skip both the open AND the close animation. + const panelTitle = openPanel === 'inbox' ? `inbox · ${state?.inbox.length ?? 0}` : openPanel === 'todos' ? `todos · ${todos.length}` : ''; + const panel = ( + setOpenPanel(null)}> + {openPanel === 'inbox' ? ( + + ) : openPanel === 'todos' ? ( + + ) : null} + + ); if (!state) { return ( <> -
+
+ {panel} ); } @@ -81,7 +119,7 @@ export function Root() { return ( <> -
+
+ {panel} ); } diff --git a/frontend/packages/agent/src/components/Header.tsx b/frontend/packages/agent/src/components/Header.tsx index f5ecadfd..ad85b2d9 100644 --- a/frontend/packages/agent/src/components/Header.tsx +++ b/frontend/packages/agent/src/components/Header.tsx @@ -14,9 +14,13 @@ export interface HeaderProps { label: string; hiveLabel?: string | null; children?: ComponentChildren; + /** Right-cluster flyout triggers (inbox/todos pills today, the + * overflow menu button lands here in a later commit) — mirrors the + * old markup's `.agent-header-pills` third column. */ + pills?: ComponentChildren; } -export function Header({ label, hiveLabel, children }: HeaderProps) { +export function Header({ label, hiveLabel, children, pills }: HeaderProps) { return (
@@ -27,6 +31,7 @@ export function Header({ label, hiveLabel, children }: HeaderProps) { {hiveLabel ?
{hiveLabel}
: null}
{children}
+ {pills ?
{pills}
: null}
); } diff --git a/frontend/packages/agent/src/components/HeaderPill.tsx b/frontend/packages/agent/src/components/HeaderPill.tsx new file mode 100644 index 00000000..1d3f7e9f --- /dev/null +++ b/frontend/packages/agent/src/components/HeaderPill.tsx @@ -0,0 +1,26 @@ +// — the inbox/todos flyout triggers in the header's right +// cluster. Reuses agent.css's existing `.hive-pill.header-pill…` rules +// (loaded globally) — same "layout is a like-for-like port, the +// component model is the change" approach as Header.tsx. Hidden +// (renders nothing) at count 0, matching the old page's `pill.hidden = +// count === 0`. +export interface HeaderPillProps { + kind: 'inbox' | 'todos'; + icon: string; + label: string; + count: number; + onClick: () => void; +} + +export function HeaderPill({ kind, icon, label, count, onClick }: HeaderPillProps) { + if (count === 0) return null; + return ( + + ); +} diff --git a/frontend/packages/agent/src/components/InboxPanel.tsx b/frontend/packages/agent/src/components/InboxPanel.tsx new file mode 100644 index 00000000..188534f6 --- /dev/null +++ b/frontend/packages/agent/src/components/InboxPanel.tsx @@ -0,0 +1,86 @@ +// — content rendered inside for the inbox +// flyout. "mark all read" POSTs to the *dashboard's* origin (not this +// agent's own backend) via a plain `fetch()`, same as app.js's +// `buildInboxMarkAllRow` — unlike pause/resume this one's fine as a +// fetch even when the two are genuinely cross-origin, because +// `hive-c0re`'s `/api/agent/{name}/mark-all-read` route replies with +// JSON, not a redirect; in the common gateway-fronted deployment +// `dashboardBase` already resolves to `location.origin` anyway (see +// dashboardBase.ts), so it's same-origin in practice. +import { useState } from 'preact/hooks'; +import { useConfirmClick } from '../lib/useConfirmClick.js'; +import type { InboxRow } from '../types.js'; + +export interface InboxPanelProps { + rows: InboxRow[]; + label: string; + dashboardBase: string; + onCleared: () => void; +} + +function fmtTs(unixSeconds: number): string { + return new Date(unixSeconds * 1000).toISOString().replace('T', ' ').slice(5, 19); +} + +export function InboxPanel({ rows, label, dashboardBase, onCleared }: InboxPanelProps) { + const [status, setStatus] = useState(''); + const [busy, setBusy] = useState(false); + + async function markAllRead() { + if (!dashboardBase || !label) { + setStatus('dashboard url / label unknown'); + return; + } + setBusy(true); + setStatus('clearing…'); + try { + const resp = await fetch(`${dashboardBase}api/agent/${encodeURIComponent(label)}/mark-all-read`, { + method: 'POST', + }); + if (resp.ok) { + const data = await resp.json().catch(() => ({})); + const n = Number((data as { marked?: number }).marked) || 0; + setStatus(`✓ marked ${n} as read`); + onCleared(); + } else { + setStatus(`failed: http ${resp.status}`); + } + } catch (err) { + setStatus(`failed: ${err instanceof Error ? err.message : String(err)}`); + } finally { + setBusy(false); + } + } + + const { armed, trigger } = useConfirmClick(markAllRead); + + if (!rows.length) { + return

inbox empty.

; + } + + return ( +
+
+ + {status} +
+
    + {rows.map((m) => ( +
  • + {m.in_reply_to != null && ↳ reply · } + {fmtTs(m.at)} {m.from}{' '} + {m.body} +
  • + ))} +
+
+ ); +} diff --git a/frontend/packages/agent/src/components/SidePanel.css b/frontend/packages/agent/src/components/SidePanel.css new file mode 100644 index 00000000..cbf84d57 --- /dev/null +++ b/frontend/packages/agent/src/components/SidePanel.css @@ -0,0 +1,80 @@ +/* Drawer chrome for — backdrop, drawer box, header, close + button. Content styling (.agent-inbox, .inbox-*, .side-panel-empty) + already ships globally via agent.css; the two rules at the bottom + here re-target agent.css's old `hive-side-panel .agent-inbox …` + flatten-when-inside-a-panel overrides at this component's own class + instead, since this is a plain Preact tree, not a custom element. */ +.agent-side-panel-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 60; + opacity: 0; + pointer-events: none; + transition: opacity 160ms ease; +} +.agent-side-panel-backdrop.open { + opacity: 1; + pointer-events: auto; +} +.agent-side-panel-drawer { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: min(26em, 96vw); + background: var(--bg-elev); + border-left: 1px solid var(--border); + box-shadow: -8px 0 24px rgba(0, 0, 0, 0.4); + z-index: 61; + display: flex; + flex-direction: column; + transform: translateX(100%); + transition: transform 200ms ease; +} +.agent-side-panel-drawer.open { + transform: translateX(0); +} +.agent-side-panel-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.6em 0.8em; + border-bottom: 1px solid var(--border); + color: var(--fg); +} +.agent-side-panel-title { + font-weight: bold; + letter-spacing: 0.03em; +} +.agent-side-panel-close { + background: none; + border: 0; + color: var(--muted); + font-size: 1.1em; + line-height: 1; + cursor: pointer; + padding: 0.3em 0.5em; + min-width: 2.75em; + min-height: 2.75em; +} +.agent-side-panel-close:hover { + color: var(--fg); +} +.agent-side-panel-body { + flex: 1; + overflow-y: auto; + padding: 0.8em; +} +.agent-side-panel-body .agent-inbox { + margin: 0; + font-size: inherit; + color: var(--fg); +} +.agent-side-panel-body .agent-inbox ul { + background: transparent; + border-left: 0; + padding: 0; + max-height: none; + overflow: visible; +} diff --git a/frontend/packages/agent/src/components/SidePanel.tsx b/frontend/packages/agent/src/components/SidePanel.tsx new file mode 100644 index 00000000..3f7ec327 --- /dev/null +++ b/frontend/packages/agent/src/components/SidePanel.tsx @@ -0,0 +1,55 @@ +// — the slide-in drawer for the inbox/todos flyouts. +// Deliberately a new Preact component, not the shared +// `` shadow-DOM custom element (@hive/shared/ +// side-panel.js) — same rationale as StatusChips' Badge/Dropdown: +// mara's ask was real Preact components for this rewrite, not a port +// of the old widget family. Drops that element's drag-to-resize + +// localStorage width persistence for this first slice (fixed width via +// CSS, see SidePanel.css) — a real simplification, not an oversight; +// revisit if a fixed width proves cramped in practice. +// +// Content styling (`.agent-inbox`, `.inbox-*`, `.side-panel-empty`) +// already ships globally via `agent.css` and is reused verbatim by +// InboxPanel/TodosPanel — only the drawer chrome itself is new here. +import { useEffect } from 'preact/hooks'; +import type { ComponentChildren } from 'preact'; +import './SidePanel.css'; + +export interface SidePanelProps { + open: boolean; + title: string; + onClose: () => void; + children?: ComponentChildren; +} + +export function SidePanel({ open, title, onClose, children }: SidePanelProps) { + useEffect(() => { + if (!open) return; + function onKey(e: KeyboardEvent) { + if (e.key === 'Escape') onClose(); + } + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [open, onClose]); + + return ( + <> +