agent: scaffold Preact rewrite, Header + StatusChips first slice
Adds a new Preact/TSX build alongside the existing app.js (esbuild
entry `main.tsx` → dist/static/main.{js,css}, same jsx/tsconfig shape
swarm-ui already uses) and the first real page slice: Header +
StatusChips, composing the Badge/Dropdown components from the prior
commit's PR. Not wired into index.html yet — app.js keeps rendering
the live page untouched while this fills in component by component
(state polling, the live SSE stream, login flow, inbox/todos, term
input) in follow-up commits on this branch.
StatusChips folds the model/effort pickers and pause into the badge
row itself (each badge IS its own control), replacing the old
overflow-menu-only pickers — the concrete fix the design guide already
names this page as needing. Presentational only for now (props, not
live data) so it's reviewable against sample data before being wired
to /api/state.
Screenshot-verified against the real agent.css/theme.css/colors.css
(headless chromium, sample data) — renders correctly.
Builds + tsc --noEmit clean.
This commit is contained in:
parent
f5a4e380c9
commit
93b34ebb9b
11 changed files with 334 additions and 3 deletions
6
frontend/package-lock.json
generated
6
frontend/package-lock.json
generated
|
|
@ -991,7 +991,11 @@
|
||||||
"@hive/shared": "*",
|
"@hive/shared": "*",
|
||||||
"chart.js": "4.5.1",
|
"chart.js": "4.5.1",
|
||||||
"dompurify": "^3.2.4",
|
"dompurify": "^3.2.4",
|
||||||
"marked": "18.0.6"
|
"marked": "18.0.6",
|
||||||
|
"preact": "10.29.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "7.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/dashboard": {
|
"packages/dashboard": {
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,25 @@ await build({
|
||||||
loader: { '.css': 'text' },
|
loader: { '.css': 'text' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The Preact rewrite (in progress — see main.tsx's file comment). A
|
||||||
|
// third, separate bundle rather than folding into the `app.js` build
|
||||||
|
// above: different loader needs (`.css: 'css'`, real stylesheets a
|
||||||
|
// component imports, vs. `app.js`'s raw-text shadow-DOM CSS) and JSX
|
||||||
|
// transpilation, same split swarm-ui's own build already makes.
|
||||||
|
await build({
|
||||||
|
entryPoints: [src('main.tsx')],
|
||||||
|
outfile: staticDir('main.js'),
|
||||||
|
bundle: true,
|
||||||
|
format: 'esm',
|
||||||
|
platform: 'browser',
|
||||||
|
target: ['es2022'],
|
||||||
|
sourcemap: true,
|
||||||
|
logLevel: 'info',
|
||||||
|
jsx: 'automatic',
|
||||||
|
jsxImportSource: 'preact',
|
||||||
|
loader: { '.css': 'css' },
|
||||||
|
});
|
||||||
|
|
||||||
// Bundle the CSS. `colors.css` re-exports the standalone base16 palette
|
// Bundle the CSS. `colors.css` re-exports the standalone base16 palette
|
||||||
// (the theme swap contract — its own output file so a swap replaces only
|
// (the theme swap contract — its own output file so a swap replaces only
|
||||||
// it, no bundle rebuild); `theme.css` is the semantic derivation layer;
|
// it, no bundle rebuild); `theme.css` is the semantic derivation layer;
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,17 @@
|
||||||
"description": "hive-ag3nt per-container web UI. Bundled by esbuild into a static dist; served by the in-container Rust binary at runtime via tower_http::ServeDir. Per-agent additions are layered on top via the hyperhive.frontend.extraFiles nix option.",
|
"description": "hive-ag3nt per-container web UI. Bundled by esbuild into a static dist; served by the in-container Rust binary at runtime via tower_http::ServeDir. Per-agent additions are layered on top via the hyperhive.frontend.extraFiles nix option.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node ./build.mjs"
|
"build": "node ./build.mjs",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hive/shared": "*",
|
"@hive/shared": "*",
|
||||||
"chart.js": "4.5.1",
|
"chart.js": "4.5.1",
|
||||||
"dompurify": "^3.2.4",
|
"dompurify": "^3.2.4",
|
||||||
"marked": "18.0.6"
|
"marked": "18.0.6",
|
||||||
|
"preact": "10.29.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "7.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
37
frontend/packages/agent/src/Root.tsx
Normal file
37
frontend/packages/agent/src/Root.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
// <Root> — root of the Preact rewrite. Currently just `Header` +
|
||||||
|
// `StatusChips` wired to local component state as a first slice (real
|
||||||
|
// `/api/state` polling + the live SSE stream land in follow-up commits
|
||||||
|
// on this same PR, per mara's "one pr != one commit" note on the
|
||||||
|
// issue). Sample values below match the shapes in `types.ts`.
|
||||||
|
import { useState } from 'preact/hooks';
|
||||||
|
import { Header } from './components/Header.js';
|
||||||
|
import { StatusChips } from './components/StatusChips.js';
|
||||||
|
|
||||||
|
export function Root() {
|
||||||
|
const [model, setModel] = useState('sonnet');
|
||||||
|
const [effort, setEffort] = useState('high');
|
||||||
|
const [paused, setPaused] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Header label="IRIS" hiveLabel="constellation / pr1ma">
|
||||||
|
<StatusChips
|
||||||
|
aliveLabel="alive"
|
||||||
|
aliveTone="positive"
|
||||||
|
stateLabel="idle · 6m 1s"
|
||||||
|
stateTone="neutral"
|
||||||
|
stateTooltip="turn loop running, no claude invocation in flight"
|
||||||
|
model={model}
|
||||||
|
availableModels={['haiku', 'sonnet', 'opus']}
|
||||||
|
onSelectModel={setModel}
|
||||||
|
effort={effort}
|
||||||
|
availableEfforts={['low', 'medium', 'high', 'xhigh', 'max']}
|
||||||
|
onSelectEffort={setEffort}
|
||||||
|
ctxLabel="534k"
|
||||||
|
costLabel="$1.6M"
|
||||||
|
paused={paused}
|
||||||
|
onTogglePause={() => setPaused((p) => !p)}
|
||||||
|
lastTurnLabel="last turn 8.4s"
|
||||||
|
/>
|
||||||
|
</Header>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
frontend/packages/agent/src/components/Header.tsx
Normal file
32
frontend/packages/agent/src/components/Header.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
// <Header> — the agent page's identity strip: icon, glyphic title, the
|
||||||
|
// "swarm / hive" label row, and a slot for the status row
|
||||||
|
// (`<StatusChips>` — ../status-chips/, kept separate so this component
|
||||||
|
// has no opinion on what badges exist). Structurally the same
|
||||||
|
// three-row shape as the old `#agent-header` markup in index.html
|
||||||
|
// (icon · main · pills) — see `docs/web-ui.md::Per-agent page` — this
|
||||||
|
// is a like-for-like layout port, the redesign work is in the children.
|
||||||
|
// Reuses `agent.css`'s existing `.agent-header*`/`.agent-icon` rules
|
||||||
|
// (loaded globally by index.html) rather than a component-scoped
|
||||||
|
// stylesheet — no new visual language needed for the chrome itself.
|
||||||
|
import type { ComponentChildren } from 'preact';
|
||||||
|
|
||||||
|
export interface HeaderProps {
|
||||||
|
label: string;
|
||||||
|
hiveLabel?: string | null;
|
||||||
|
children?: ComponentChildren;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header({ label, hiveLabel, children }: HeaderProps) {
|
||||||
|
return (
|
||||||
|
<header class="agent-header">
|
||||||
|
<img class="agent-icon" src="icon" alt="" />
|
||||||
|
<div class="agent-header-main">
|
||||||
|
<div class="agent-header-row agent-header-title-row">
|
||||||
|
<h2 class="agent-header-title">◆ {label} ◆</h2>
|
||||||
|
</div>
|
||||||
|
{hiveLabel ? <div class="agent-header-row agent-hive-row">{hiveLabel}</div> : null}
|
||||||
|
<div class="agent-header-row">{children}</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
frontend/packages/agent/src/components/StatusChips.css
Normal file
16
frontend/packages/agent/src/components/StatusChips.css
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
/* <StatusChips> layout. Colours all come from Badge/Dropdown's own CSS
|
||||||
|
(@hive/shared) — nothing here overrides a base16 slot. */
|
||||||
|
.status-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5em;
|
||||||
|
}
|
||||||
|
.status-chip-anchor {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.status-chips-last-turn {
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
146
frontend/packages/agent/src/components/StatusChips.tsx
Normal file
146
frontend/packages/agent/src/components/StatusChips.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
// <StatusChips> — the agent page's status row: alive/turn-state, the
|
||||||
|
// model + effort pickers, context/cost usage, and pause/resume. This is
|
||||||
|
// the concrete fix for the design guide's own named anti-example (the
|
||||||
|
// old page's model/effort *pickers* lived in the `⋯` overflow menu while
|
||||||
|
// the current model/effort only showed as a disconnected chip) — every
|
||||||
|
// badge here IS the control for what it displays, composed from
|
||||||
|
// `@hive/shared`'s `Badge`/`Dropdown` (../../shared/src/badge,
|
||||||
|
// ../../shared/src/dropdown). Presentational only: formatting +
|
||||||
|
// selection state live in the caller (a `useAgentState` hook lands in a
|
||||||
|
// later commit), so this component can be demoed and reviewed against
|
||||||
|
// plain sample data before it's wired to `/api/state` polling.
|
||||||
|
import { useState } from 'preact/hooks';
|
||||||
|
import { Badge, type BadgeTone } from '@hive/shared/badge.js';
|
||||||
|
import { Dropdown, type DropdownOption } from '@hive/shared/dropdown.js';
|
||||||
|
import './StatusChips.css';
|
||||||
|
|
||||||
|
export interface StatusChipsProps {
|
||||||
|
aliveLabel: string;
|
||||||
|
aliveTone: BadgeTone;
|
||||||
|
stateLabel: string;
|
||||||
|
stateTone: BadgeTone;
|
||||||
|
stateTooltip?: string;
|
||||||
|
model: string;
|
||||||
|
availableModels: string[];
|
||||||
|
onSelectModel: (name: string) => void;
|
||||||
|
effort: string;
|
||||||
|
availableEfforts: string[];
|
||||||
|
onSelectEffort: (level: string) => void;
|
||||||
|
ctxLabel?: string;
|
||||||
|
costLabel?: string;
|
||||||
|
paused: boolean;
|
||||||
|
onTogglePause: () => void;
|
||||||
|
lastTurnLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODEL_DESCRIPTIONS: Record<string, string> = {
|
||||||
|
haiku: 'fast',
|
||||||
|
sonnet: 'balanced',
|
||||||
|
opus: 'powerful',
|
||||||
|
};
|
||||||
|
const EFFORT_DESCRIPTIONS: Record<string, string> = {
|
||||||
|
low: '',
|
||||||
|
medium: 'default',
|
||||||
|
high: '',
|
||||||
|
xhigh: '',
|
||||||
|
max: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
function Picker({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
descriptions,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
options: string[];
|
||||||
|
descriptions: Record<string, string>;
|
||||||
|
onSelect: (v: string) => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const dropdownOptions: DropdownOption[] = options.map((name) => ({
|
||||||
|
value: name,
|
||||||
|
label: name,
|
||||||
|
description: descriptions[name] || undefined,
|
||||||
|
}));
|
||||||
|
return (
|
||||||
|
<div class="status-chip-anchor">
|
||||||
|
<Badge label={label} value={value} onClick={() => setOpen((o) => !o)} expanded={open} />
|
||||||
|
<Dropdown
|
||||||
|
open={open}
|
||||||
|
options={dropdownOptions}
|
||||||
|
activeValue={value}
|
||||||
|
label={`select ${label}`}
|
||||||
|
onSelect={(v) => {
|
||||||
|
onSelect(v);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusChips({
|
||||||
|
aliveLabel,
|
||||||
|
aliveTone,
|
||||||
|
stateLabel,
|
||||||
|
stateTone,
|
||||||
|
stateTooltip,
|
||||||
|
model,
|
||||||
|
availableModels,
|
||||||
|
onSelectModel,
|
||||||
|
effort,
|
||||||
|
availableEfforts,
|
||||||
|
onSelectEffort,
|
||||||
|
ctxLabel,
|
||||||
|
costLabel,
|
||||||
|
paused,
|
||||||
|
onTogglePause,
|
||||||
|
lastTurnLabel,
|
||||||
|
}: StatusChipsProps) {
|
||||||
|
return (
|
||||||
|
<div class="status-chips">
|
||||||
|
<Badge value={aliveLabel} tone={aliveTone} />
|
||||||
|
<Badge value={stateLabel} tone={stateTone} title={stateTooltip} />
|
||||||
|
<Picker
|
||||||
|
label="model"
|
||||||
|
value={model}
|
||||||
|
options={availableModels}
|
||||||
|
descriptions={MODEL_DESCRIPTIONS}
|
||||||
|
onSelect={onSelectModel}
|
||||||
|
/>
|
||||||
|
{availableEfforts.length ? (
|
||||||
|
<Picker
|
||||||
|
label="effort"
|
||||||
|
value={effort}
|
||||||
|
options={availableEfforts}
|
||||||
|
descriptions={EFFORT_DESCRIPTIONS}
|
||||||
|
onSelect={onSelectEffort}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{ctxLabel ? (
|
||||||
|
<Badge
|
||||||
|
label="ctx"
|
||||||
|
value={ctxLabel}
|
||||||
|
title="tokens used in the current context window"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{costLabel ? (
|
||||||
|
<Badge
|
||||||
|
label="cost"
|
||||||
|
value={costLabel}
|
||||||
|
title="cumulative tokens billed across the last turn (sum across every inference; tool-heavy turns rebill the cached prompt per call)"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Badge
|
||||||
|
value={paused ? '▶ resume' : '⏸ pause'}
|
||||||
|
tone={paused ? 'warning' : 'neutral'}
|
||||||
|
onClick={onTogglePause}
|
||||||
|
/>
|
||||||
|
{lastTurnLabel ? <span class="status-chips-last-turn">{lastTurnLabel}</span> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
4
frontend/packages/agent/src/css.d.ts
vendored
Normal file
4
frontend/packages/agent/src/css.d.ts
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
// Ambient module for `import './Foo.css'` side-effect imports (esbuild
|
||||||
|
// resolves these directly, see build.mjs; tsc otherwise has no idea what
|
||||||
|
// a `.css` specifier is and refuses the whole side-effect import).
|
||||||
|
declare module '*.css';
|
||||||
16
frontend/packages/agent/src/main.tsx
Normal file
16
frontend/packages/agent/src/main.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
// Entry point for the Preact rewrite of the per-agent terminal page
|
||||||
|
// (replacing app.js's imperative DOM). Not wired into index.html yet —
|
||||||
|
// this lands in small, reviewable slices (component by component) per
|
||||||
|
// mara's "one pr != one commit" note on the issue; `index.html`'s
|
||||||
|
// `<script>` tag swaps from `app.js` to this bundle's output only once
|
||||||
|
// the new page covers everything the old one did (state polling, the
|
||||||
|
// live SSE stream, login flow, inbox/todos flyouts, slash commands).
|
||||||
|
//
|
||||||
|
// Mounts to `#preact-root`, a sibling of the legacy markup rather than
|
||||||
|
// a full document takeover, so the two can coexist on a branch while
|
||||||
|
// this is still in progress.
|
||||||
|
import { render } from 'preact';
|
||||||
|
import { Root } from './Root.js';
|
||||||
|
|
||||||
|
const root = document.getElementById('preact-root');
|
||||||
|
if (root) render(<Root />, root);
|
||||||
32
frontend/packages/agent/src/types.ts
Normal file
32
frontend/packages/agent/src/types.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
// TS types mirroring `hive-agent/src/web_ui/state.rs::StateSnapshot`
|
||||||
|
// (the `GET /api/state` response body). Kept as a hand-written subset
|
||||||
|
// rather than a generated binding — the Rust side has no schema export
|
||||||
|
// today, and the frontend only ever reads a handful of these fields.
|
||||||
|
// Extend as more of the page gets rebuilt against real data.
|
||||||
|
|
||||||
|
export interface AgentState {
|
||||||
|
seq: number;
|
||||||
|
label: string;
|
||||||
|
qualified_label: string;
|
||||||
|
dashboard_port: number;
|
||||||
|
status: 'online' | 'rate_limited' | 'needs_login_idle' | 'needs_login_in_progress';
|
||||||
|
turn_state: string;
|
||||||
|
turn_state_since: number;
|
||||||
|
model: string;
|
||||||
|
context_window_tokens: number;
|
||||||
|
ctx_usage: TokenUsage | null;
|
||||||
|
cost_usage: TokenUsage | null;
|
||||||
|
hive_name: string | null;
|
||||||
|
swarm_name: string | null;
|
||||||
|
available_models: string[];
|
||||||
|
effort: string;
|
||||||
|
available_efforts: string[];
|
||||||
|
paused: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenUsage {
|
||||||
|
input_tokens?: number;
|
||||||
|
output_tokens?: number;
|
||||||
|
cache_read_input_tokens?: number;
|
||||||
|
cache_creation_input_tokens?: number;
|
||||||
|
}
|
||||||
20
frontend/packages/agent/tsconfig.json
Normal file
20
frontend/packages/agent/tsconfig.json
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "preact",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue