Compare commits
43 changed files with 17 additions and 2697 deletions
|
|
@ -1 +1 @@
|
|||
sha256-9sh4G6A00NNi5YKaMQ1v0vZQj+gmGxYPhYZhlqweApM=
|
||||
sha256-8mYI9b1nuQ8YNFkW/ezJwO5Qw6NJTDG+kspYozs/5cI=
|
||||
6
frontend/package-lock.json
generated
6
frontend/package-lock.json
generated
|
|
@ -991,11 +991,7 @@
|
|||
"@hive/shared": "*",
|
||||
"chart.js": "4.5.1",
|
||||
"dompurify": "^3.2.4",
|
||||
"marked": "18.0.6",
|
||||
"preact": "10.29.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "7.0.2"
|
||||
"marked": "18.0.6"
|
||||
}
|
||||
},
|
||||
"packages/dashboard": {
|
||||
|
|
|
|||
|
|
@ -47,25 +47,6 @@ await build({
|
|||
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
|
||||
// (the theme swap contract — its own output file so a swap replaces only
|
||||
// it, no bundle rebuild); `theme.css` is the semantic derivation layer;
|
||||
|
|
|
|||
|
|
@ -5,17 +5,12 @@
|
|||
"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",
|
||||
"scripts": {
|
||||
"build": "node ./build.mjs",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"build": "node ./build.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hive/shared": "*",
|
||||
"chart.js": "4.5.1",
|
||||
"dompurify": "^3.2.4",
|
||||
"marked": "18.0.6",
|
||||
"preact": "10.29.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "7.0.2"
|
||||
"marked": "18.0.6"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,200 +0,0 @@
|
|||
// <Root> — root of the Preact rewrite. Wires `Header` + `StatusChips`
|
||||
// to the real `/api/state` snapshot via `useAgentState`. Covers
|
||||
// everything `app.js` did, including the `needs_login_idle`/
|
||||
// `needs_login_in_progress` recovery flow (`LoginFlow`) — an agent with
|
||||
// no credentials has no other web-UI path back online, so this isn't
|
||||
// optional polish.
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import type { BadgeTone } from '@hive/shared/badge.js';
|
||||
import { Header } from './components/Header.js';
|
||||
import { MetaNav } from './components/MetaNav.js';
|
||||
import { StatusChips } from './components/StatusChips.js';
|
||||
import { LiveStream, type LiveStreamHandle } 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 { TermInput } from './components/TermInput.js';
|
||||
import { OverflowMenu } from './components/OverflowMenu.js';
|
||||
import { LoginFlow } from './components/LoginFlow.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<string, { glyph: string; text: string; tone: BadgeTone }> = {
|
||||
online: { glyph: '●', text: 'alive', tone: 'positive' },
|
||||
rate_limited: { glyph: '⊘', text: 'rate limited', tone: 'warning' },
|
||||
needs_login_idle: { glyph: '◌', text: 'needs login', tone: 'warning' },
|
||||
needs_login_in_progress: { glyph: '◌', text: 'logging in', tone: 'warning' },
|
||||
};
|
||||
const STATE_LABELS: Record<string, { glyph: string; text: string; tone: BadgeTone }> = {
|
||||
idle: { glyph: '💤', text: 'idle', tone: 'neutral' },
|
||||
thinking: { glyph: '🧠', text: 'thinking', tone: 'accent' },
|
||||
compacting: { glyph: '📦', text: 'compacting', tone: 'warning' },
|
||||
};
|
||||
const STATE_TOOLTIPS: Record<string, string> = {
|
||||
offline: 'harness unreachable or claude not logged in',
|
||||
idle: 'turn loop running, no claude invocation in flight',
|
||||
thinking: 'claude is executing the current turn',
|
||||
compacting: "operator-triggered /compact running on the persistent session",
|
||||
};
|
||||
|
||||
// `total` here matches app.js's `renderOneUsage` exactly: input + both
|
||||
// cache buckets, output_tokens deliberately excluded (it's shown in the
|
||||
// title breakdown, not folded into the headline number).
|
||||
function tokenTotal(u: TokenUsage | null): number | null {
|
||||
if (!u) return null;
|
||||
return (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
|
||||
}
|
||||
|
||||
export function Root() {
|
||||
const { state, refresh } = useAgentState();
|
||||
const { todos, refresh: refreshTodos } = useTodos();
|
||||
const [openPanel, setOpenPanel] = useState<OpenPanel>(null);
|
||||
const liveStreamRef = useRef<LiveStreamHandle>(null);
|
||||
|
||||
// Browser tab title — ports app.js's setHeader logic verbatim: prefer
|
||||
// the human display name + hive_name ("iris // pr1ma") so tabs read
|
||||
// naturally; fall back to the qualified domain label for multi-hive
|
||||
// disambiguation when hive_name is unset, then to plain label for
|
||||
// single-hive deploys. Without this every agent's tab reads the
|
||||
// static index.html <title> forever — can't tell tabs apart with
|
||||
// several open.
|
||||
useEffect(() => {
|
||||
if (!state) return;
|
||||
const tab =
|
||||
state.qualified_label && state.qualified_label !== state.label
|
||||
? state.qualified_label
|
||||
: state.label;
|
||||
document.title = state.hive_name ? `${state.label} // ${state.hive_name}` : `${tab} // hyperhive`;
|
||||
}, [state?.label, state?.qualified_label, state?.hive_name]);
|
||||
const termInput = (
|
||||
<footer class="agent-composer">
|
||||
<TermInput
|
||||
label={state?.label ?? '…'}
|
||||
online={state?.status === 'online'}
|
||||
onLocalNote={(text) => liveStreamRef.current?.pushNote(text)}
|
||||
onClear={() => liveStreamRef.current?.clear()}
|
||||
/>
|
||||
</footer>
|
||||
);
|
||||
|
||||
const pills = (
|
||||
<>
|
||||
<HeaderPill kind="inbox" icon="📬" label="inbox" count={state?.inbox.length ?? 0} onClick={() => setOpenPanel('inbox')} />
|
||||
<HeaderPill kind="todos" icon="📋" label="todos" count={todos.length} onClick={() => setOpenPanel('todos')} />
|
||||
{/* Needs a loaded snapshot for their data (links/forge_public_url,
|
||||
* dashboard_port) — both omitted (like the old page's
|
||||
* populateOverflowMenu) until the first /api/state resolves. */}
|
||||
{state ? <MetaNav links={state.links} forgePublicUrl={state.forge_public_url} /> : null}
|
||||
{state ? <OverflowMenu label={state.label} dashboardBase={resolveDashboardBase(state.dashboard_port)} /> : null}
|
||||
</>
|
||||
);
|
||||
// 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 = (
|
||||
<SidePanel open={openPanel !== null} title={panelTitle} onClose={() => setOpenPanel(null)}>
|
||||
{openPanel === 'inbox' ? (
|
||||
<InboxPanel
|
||||
rows={state?.inbox ?? []}
|
||||
label={state?.label ?? ''}
|
||||
dashboardBase={state ? resolveDashboardBase(state.dashboard_port) : ''}
|
||||
onCleared={refresh}
|
||||
/>
|
||||
) : openPanel === 'todos' ? (
|
||||
<TodosPanel todos={todos} onCleared={refreshTodos} />
|
||||
) : null}
|
||||
</SidePanel>
|
||||
);
|
||||
|
||||
if (!state) {
|
||||
return (
|
||||
<>
|
||||
<Header label="…" pills={pills}>
|
||||
<StatusChips
|
||||
aliveLabel="… connecting"
|
||||
aliveTone="neutral"
|
||||
stateLabel="… booting"
|
||||
stateTone="neutral"
|
||||
model=""
|
||||
availableModels={[]}
|
||||
onSelectModel={() => {}}
|
||||
effort=""
|
||||
availableEfforts={[]}
|
||||
onSelectEffort={() => {}}
|
||||
paused={false}
|
||||
onTogglePause={() => {}}
|
||||
/>
|
||||
</Header>
|
||||
<main className="agent-main">
|
||||
<LiveStream ref={liveStreamRef} />
|
||||
</main>
|
||||
{termInput}
|
||||
{panel}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const alive = ALIVE_LABELS[state.status] ?? { glyph: '○', text: 'offline', tone: 'negative' as BadgeTone };
|
||||
// Mirrors app.js's refreshState: any non-'online' status forces the
|
||||
// turn-state badge to 'offline' regardless of the server's last-known
|
||||
// turn_state (login/rate-limit takes visual priority over a stale
|
||||
// idle/thinking reading from before the harness went offline).
|
||||
const effectiveTurnState = state.status === 'online' ? state.turn_state : 'offline';
|
||||
const turnDef = STATE_LABELS[effectiveTurnState] ?? { glyph: '○', text: 'offline', tone: 'negative' as BadgeTone };
|
||||
const stateAge = fmtAge(Date.now() - state.turn_state_since * 1000);
|
||||
const ctx = tokenTotal(state.ctx_usage);
|
||||
const cost = tokenTotal(state.cost_usage);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
label={state.label}
|
||||
hiveLabel={[state.swarm_name, state.hive_name].filter(Boolean).join(' / ') || null}
|
||||
pills={pills}
|
||||
>
|
||||
<StatusChips
|
||||
aliveLabel={`${alive.glyph} ${alive.text}`}
|
||||
aliveTone={alive.tone}
|
||||
stateLabel={`${turnDef.glyph} ${turnDef.text} · ${stateAge}`}
|
||||
stateTone={turnDef.tone}
|
||||
stateTooltip={STATE_TOOLTIPS[effectiveTurnState]}
|
||||
model={state.model}
|
||||
availableModels={state.available_models}
|
||||
onSelectModel={(name) => {
|
||||
postModel(name).then(() => refresh());
|
||||
}}
|
||||
effort={state.effort}
|
||||
availableEfforts={state.available_efforts}
|
||||
onSelectEffort={(level) => {
|
||||
postEffort(level).then(() => refresh());
|
||||
}}
|
||||
ctxLabel={ctx !== null ? fmtTokens(ctx) : undefined}
|
||||
costLabel={cost !== null ? fmtTokens(cost) : undefined}
|
||||
paused={state.paused}
|
||||
onTogglePause={() => {
|
||||
submitPauseResume(resolveDashboardBase(state.dashboard_port), state.label, state.paused ? 'resume' : 'pause');
|
||||
}}
|
||||
/>
|
||||
</Header>
|
||||
<main className="agent-main">
|
||||
{state.status === 'needs_login_idle' || state.status === 'needs_login_in_progress' ? (
|
||||
<LoginFlow status={state.status} session={state.session} onRefresh={refresh} />
|
||||
) : null}
|
||||
<LiveStream ref={liveStreamRef} onLiveTurnBoundary={refresh} />
|
||||
</main>
|
||||
{termInput}
|
||||
{panel}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -129,30 +129,15 @@ body.agent-shell {
|
|||
gap: 0.5em;
|
||||
}
|
||||
|
||||
/* Right cluster — flyout pills, and (new Preact page only) the status
|
||||
badges (`StatusChips`) too — mara, live: "move badges to where the
|
||||
dropdowns are". `flex-wrap: wrap` alone doesn't help if this
|
||||
container can't itself shrink: the old `flex-shrink: 0` here (fine
|
||||
for app.js's 2-3-small-pills case, which never needed to shrink)
|
||||
told the flex layout "give this its full natural width no matter
|
||||
what," so once badges made that natural width wider than the
|
||||
viewport, it just overflowed past the edge instead of ever getting
|
||||
narrow enough to trigger its own wrap — mara, live, on the 500px
|
||||
screenshot: "does not overflow properly". Dropping `flex-shrink: 0`
|
||||
(default is `1`) is the whole fix — deliberately NOT pairing it
|
||||
with `min-width: 0` (tried that first, made it worse: an unset
|
||||
`min-width` still floors shrinking at this container's own
|
||||
min-content — the widest single wrapped child, e.g. one badge, not
|
||||
the full unwrapped row — which is the correct floor; `min-width: 0`
|
||||
removes that floor entirely, so at a narrow enough viewport the
|
||||
container shrinks past what its own content needs and the content
|
||||
overflows its box sideways, landing on top of `.agent-header-main`
|
||||
instead of wrapping cleanly — confirmed live, reproducible at
|
||||
320px, not a one-off capture glitch). */
|
||||
/* Right cluster — flyout pills stacked / inline with the overflow
|
||||
trigger. Vertically centred against the full-height icon, no
|
||||
wrap; pills can drop to a row of their own under crowding via
|
||||
the flex-wrap of `.agent-header-pills` itself. */
|
||||
.agent-header-pills {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
align-self: center;
|
||||
|
|
@ -372,16 +357,10 @@ h2, h3 {
|
|||
|
||||
/* Login flow overlay: only rendered when status != online. Sits
|
||||
centred over the (likely-empty) terminal area; doesn't take chrome
|
||||
space in the normal online flow. `--agent-header-real-h` (set by
|
||||
the new Preact page's `Header.tsx` via ResizeObserver, falling back
|
||||
to the static `--agent-header-h` guess before the first
|
||||
measurement / on the old app.js page, which never sets it) is the
|
||||
header's ACTUAL rendered height, not an assumed one — see
|
||||
Header.tsx's file comment for why this matters (a static guess is
|
||||
wrong the moment the header wraps an extra line on some viewport). */
|
||||
space in the normal online flow. */
|
||||
.agent-status-overlay {
|
||||
position: absolute;
|
||||
top: calc(var(--agent-header-real-h, var(--agent-header-h)) + 1.5em);
|
||||
top: calc(var(--agent-header-h) + 1.5em);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
max-width: 44em;
|
||||
|
|
@ -737,9 +716,9 @@ pre.diff {
|
|||
and last rows reachable with extra padding inside the scroll
|
||||
area. scroll-padding-* keeps anchor-jumps (the `↓ N new` pill,
|
||||
focus restore) clear of the floats too. */
|
||||
padding-top: calc(var(--agent-header-real-h, var(--agent-header-h)) + 0.8em);
|
||||
padding-top: calc(var(--agent-header-h) + 0.8em);
|
||||
padding-bottom: calc(var(--agent-composer-h) + 0.8em);
|
||||
scroll-padding-top: calc(var(--agent-header-real-h, var(--agent-header-h)) + 0.8em);
|
||||
scroll-padding-top: calc(var(--agent-header-h) + 0.8em);
|
||||
scroll-padding-bottom: calc(var(--agent-composer-h) + 0.8em);
|
||||
overflow: auto;
|
||||
}
|
||||
|
|
@ -760,21 +739,7 @@ pre.diff {
|
|||
rules below stay scoped to whichever ancestor owns it. */
|
||||
.term-input .sendform-term {
|
||||
display: flex;
|
||||
/* mara, live: "bottom input row text not aligned / inconsistent
|
||||
sizes." Real cause: `.prompt` (1em, no line-height override) and
|
||||
`.submit-hint` (0.8em, no line-height override) each get a
|
||||
browser-default line-box at a different em-relative size, so
|
||||
`align-items: flex-start` (their old value) pinned each one's
|
||||
line-box top edge to the row's top — same nominal `padding-top`
|
||||
but a DIFFERENT absolute offset per element, since 0.25em means
|
||||
something different at 1em vs 0.8em font-size. `center` sidesteps
|
||||
the whole "line-box for text this size" mismatch by aligning each
|
||||
item's own box middle instead of a top edge that was never
|
||||
actually equal. Still correct once the textarea grows multi-line
|
||||
(a common composer-bar convention — Slack/Discord centre their
|
||||
send affordance against a grown box too, not pin it to the first
|
||||
line). */
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 0.5em;
|
||||
/* The dashed in-frame separator is dropped — see the
|
||||
.agent-composer .term-input override above for the floating-bar
|
||||
|
|
@ -782,6 +747,9 @@ pre.diff {
|
|||
border-top: 1px dashed var(--purple-dim);
|
||||
padding-top: 0.5em;
|
||||
}
|
||||
.term-input .prompt, .term-input .submit-hint {
|
||||
padding-top: 0.25em;
|
||||
}
|
||||
.term-input .prompt {
|
||||
color: var(--green);
|
||||
text-shadow: 0 0 6px color-mix(in srgb, var(--green) 60%, transparent);
|
||||
|
|
|
|||
|
|
@ -1,99 +0,0 @@
|
|||
// <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). Same three-column shape as the
|
||||
// old `#agent-header` markup (icon · main · pills) — see the `pills`
|
||||
// prop doc below for what moved into that third column and why.
|
||||
// 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.
|
||||
|
||||
// Measures its own rendered height via ResizeObserver, writes it to
|
||||
// `--agent-header-real-h` (agent.css's downstream consumers —
|
||||
// `.agent-status-overlay`, `.agent-main`'s scroll padding — prefer
|
||||
// this over the static `--agent-header-h` guess). A static guess was
|
||||
// the root cause of two "login card behind the header" reports on
|
||||
// this PR (mara, live): any header content that can wrap an extra
|
||||
// line breaks the guess at whatever width triggers it, and no width
|
||||
// can be promised safe. Deliberately a SEPARATE var, not an overwrite
|
||||
// of `--agent-header-h` in place: `.agent-header`'s own `min-height`
|
||||
// also reads that var — overwriting it in place would make the
|
||||
// header's own size react to its own just-measured height every
|
||||
// tick, a real ResizeObserver feedback loop (confirmed live on the
|
||||
// first pass of this fix). `--agent-header-h` stays the static floor;
|
||||
// `--agent-header-real-h` is output-only.
|
||||
import { useEffect, useRef } from 'preact/hooks';
|
||||
import type { ComponentChildren } from 'preact';
|
||||
|
||||
// Icon 404s when the agent has no `hyperhive.icon` override (no
|
||||
// bundled server-side default any more — see
|
||||
// `hive_agent::web_ui::screen::serve_icon`). Ports app.js's
|
||||
// `bindHeaderIconFallback` exactly: fire-and-forget load, swap to
|
||||
// `/favicon.svg` on failure, `dataset.fallback` guards against a
|
||||
// 404-on-the-fallback-itself loop.
|
||||
function handleIconError(e: Event) {
|
||||
const img = e.currentTarget as HTMLImageElement;
|
||||
if (img.dataset.fallback) return;
|
||||
img.dataset.fallback = '1';
|
||||
img.src = '/favicon.svg';
|
||||
}
|
||||
|
||||
export interface HeaderProps {
|
||||
label: string;
|
||||
hiveLabel?: string | null;
|
||||
/** The status/badge row (`<StatusChips>`) — lives in the
|
||||
* `.agent-header-pills` cluster now, alongside `pills` (see below),
|
||||
* not stacked as its own row under the title. */
|
||||
children?: ComponentChildren;
|
||||
/** Flyout triggers (inbox/todos/links pills, the overflow menu
|
||||
* button) — and, since this round, `children` (the status badges)
|
||||
* too, both in `.agent-header-pills`, the SAME header-level column
|
||||
* the old markup used for pills alone. First pass of this ask
|
||||
* nested pills into a row inside `.agent-header-main` instead —
|
||||
* backwards, mara corrected live: "i meant move badges to where the
|
||||
* dropdowns are, not the other way around. this way the height does
|
||||
* not change even on widescreen" — `.agent-header-main` now only
|
||||
* ever holds the title + hive-label rows, which don't wrap in
|
||||
* practice, so it stays a stable height; only this pills-cluster
|
||||
* column (already used to absorbing a variable pill count) grows to
|
||||
* fit badges+pills together. Meta-nav (`<MetaNav>`) lives in here
|
||||
* too, as a fixed-size trigger — not the old markup's inline list in
|
||||
* the title row (`<nav id="meta-links">`, docs/web-ui/agent.md). */
|
||||
pills?: ComponentChildren;
|
||||
}
|
||||
|
||||
export function Header({ label, hiveLabel, children, pills }: HeaderProps) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el || typeof ResizeObserver === 'undefined') return;
|
||||
// getBoundingClientRect() (not ResizeObserver's own contentRect) —
|
||||
// we want the full visual box padding+border included, the thing
|
||||
// content below the fixed header actually needs to clear, not the
|
||||
// content-box-only measurement ResizeObserver's entry defaults to.
|
||||
const ro = new ResizeObserver(() => {
|
||||
document.documentElement.style.setProperty('--agent-header-real-h', `${el.getBoundingClientRect().height}px`);
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header class="agent-header" ref={ref}>
|
||||
<img class="agent-icon" src="icon" alt="" onError={handleIconError} />
|
||||
<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>
|
||||
{children || pills ? (
|
||||
<div class="agent-header-pills">
|
||||
{children}
|
||||
{pills}
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
// <HeaderPill> — 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 (
|
||||
<button type="button" class={`hive-pill header-pill header-pill-${kind}`} onClick={onClick} title={`open ${label} flyout`}>
|
||||
<span class="header-pill-icon" aria-hidden="true">
|
||||
{icon}
|
||||
</span>
|
||||
<span class="header-pill-label">{label}</span>
|
||||
<span class="header-pill-count">{count}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
// <InboxPanel> — content rendered inside <SidePanel> 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 <p class="side-panel-empty">inbox empty.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="agent-inbox">
|
||||
<div class="inbox-mark-all-row">
|
||||
<button
|
||||
type="button"
|
||||
class="inbox-mark-all-btn"
|
||||
onClick={trigger}
|
||||
disabled={busy}
|
||||
title="mark every queued message for this agent as read — drains the host broker's pending + delivered-unacked rows. history shown here is the most-recent-N regardless of state, so the list itself stays visible."
|
||||
>
|
||||
{armed ? 'sure? click again' : '✓ mark all read'}
|
||||
</button>
|
||||
<span class="inbox-mark-status">{status}</span>
|
||||
</div>
|
||||
<ul>
|
||||
{rows.map((m) => (
|
||||
<li key={m.id} class={m.in_reply_to != null ? 'inbox-reply' : undefined}>
|
||||
{m.in_reply_to != null && <span class="inbox-reply-tag">↳ reply · </span>}
|
||||
<span class="inbox-ts">{fmtTs(m.at)}</span> <span class="inbox-from">{m.from}</span>{' '}
|
||||
<span class="inbox-sep">→</span> <span class="inbox-body">{m.body}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
// Live event pane — Preact owner of the DOM node `useLiveStream`'s rows
|
||||
// get rendered into. Deliberately NOT the old MutationObserver + rAF
|
||||
// snap-animation + `smoothScrollingUntil` gate machinery
|
||||
// (@hive/shared/terminal.js) — mara explicitly flagged that the old
|
||||
// page's autoscroll breaks when new events land while the operator has
|
||||
// scrolled up, and not to copy it 1:1. This is the "do it properly the
|
||||
// preact way" version:
|
||||
//
|
||||
// - `stickToBottom` is plain state, updated synchronously from the
|
||||
// scroll handler (near-bottom → true, scrolled away → false). No
|
||||
// separate "am I mid-animation" gate to fight with it — there's no
|
||||
// animation to fight, snapping is an instant `scrollTop` write in a
|
||||
// `useLayoutEffect` that runs once after Preact has already
|
||||
// committed the new rows to the DOM, so it always sees the real
|
||||
// `scrollHeight`, never a stale pre-mutation one.
|
||||
// - A "load older" prepend is told apart from a normal append via
|
||||
// `prependingRef` (set immediately before calling `loadMore()`) so
|
||||
// the effect can choose the right response: viewport-preserving
|
||||
// scrollTop compensation for a prepend, stick-to-bottom-or-count-
|
||||
// unseen for an append. The old code inferred this from DOM
|
||||
// mutation shape after the fact; here the caller already knows
|
||||
// which one it did, so there's nothing to infer.
|
||||
import { useLayoutEffect, useRef, useState, useImperativeHandle } from 'preact/hooks';
|
||||
import { forwardRef } from 'preact/compat';
|
||||
import { useLiveStream } from '../hooks/useLiveStream.js';
|
||||
import { Row } from './Row.js';
|
||||
|
||||
const NEAR_BOTTOM_PX = 48;
|
||||
const LOAD_MORE_SCROLL_PX = 80;
|
||||
|
||||
export interface LiveStreamProps {
|
||||
/** Forwarded to useLiveStream — fires on live turn_start/turn_end so
|
||||
* the caller can refresh `/api/state` sooner than its poll interval. */
|
||||
onLiveTurnBoundary?: () => void;
|
||||
}
|
||||
|
||||
/** Imperative escape hatch for TermInput's local-only slash commands
|
||||
* (`/help`, `/clear`) — same shape as app.js's old `termAPI` object,
|
||||
* kept as a ref handle rather than lifting the whole row array up to
|
||||
* Root so LiveStream/useLiveStream still own their state privately. */
|
||||
export interface LiveStreamHandle {
|
||||
pushNote: (text: string) => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
export const LiveStream = forwardRef<LiveStreamHandle, LiveStreamProps>(function LiveStream(
|
||||
{ onLiveTurnBoundary },
|
||||
ref,
|
||||
) {
|
||||
const { rows, hasMore, loadingMore, loadMore, pushLocalNote, clearLocal } = useLiveStream({ onLiveTurnBoundary });
|
||||
useImperativeHandle(ref, () => ({ pushNote: pushLocalNote, clear: clearLocal }), [pushLocalNote, clearLocal]);
|
||||
const logRef = useRef<HTMLDivElement>(null);
|
||||
const [stickToBottom, setStickToBottom] = useState(true);
|
||||
const [unseen, setUnseen] = useState(0);
|
||||
const prevRowCount = useRef(0);
|
||||
const prependingRef = useRef(false);
|
||||
const preScrollHeightRef = useRef(0);
|
||||
|
||||
function isNearBottom(el: HTMLDivElement): boolean {
|
||||
return el.scrollHeight - el.scrollTop - el.clientHeight <= NEAR_BOTTOM_PX;
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
const el = logRef.current;
|
||||
if (!el) return;
|
||||
const nearBottom = isNearBottom(el);
|
||||
setStickToBottom(nearBottom);
|
||||
if (nearBottom) setUnseen(0);
|
||||
if (el.scrollTop <= LOAD_MORE_SCROLL_PX && hasMore && !loadingMore) startLoadMore();
|
||||
}
|
||||
|
||||
function startLoadMore() {
|
||||
const el = logRef.current;
|
||||
if (!el || loadingMore || !hasMore) return;
|
||||
prependingRef.current = true;
|
||||
preScrollHeightRef.current = el.scrollHeight;
|
||||
loadMore();
|
||||
}
|
||||
|
||||
function jumpToBottom() {
|
||||
const el = logRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight - el.clientHeight;
|
||||
setStickToBottom(true);
|
||||
setUnseen(0);
|
||||
}
|
||||
|
||||
// Runs after every commit that changed `rows` — i.e. after the browser
|
||||
// has already laid out the new/coalesced content, so `scrollHeight`
|
||||
// below is always current.
|
||||
useLayoutEffect(() => {
|
||||
const el = logRef.current;
|
||||
if (!el) return;
|
||||
const added = rows.length - prevRowCount.current;
|
||||
prevRowCount.current = rows.length;
|
||||
|
||||
if (prependingRef.current) {
|
||||
prependingRef.current = false;
|
||||
el.scrollTop += el.scrollHeight - preScrollHeightRef.current;
|
||||
return;
|
||||
}
|
||||
if (stickToBottom) {
|
||||
el.scrollTop = el.scrollHeight - el.clientHeight;
|
||||
} else if (added > 0) {
|
||||
setUnseen((n) => n + added);
|
||||
}
|
||||
}, [rows, stickToBottom]);
|
||||
|
||||
return (
|
||||
<div className="terminal-wrap">
|
||||
<div className="live terminal" ref={logRef} onScroll={handleScroll}>
|
||||
{hasMore && (
|
||||
<button type="button" className="load-more-pill" disabled={loadingMore} onClick={startLoadMore}>
|
||||
{loadingMore ? '↑ loading…' : '↑ load older'}
|
||||
</button>
|
||||
)}
|
||||
{rows.map((row) => (
|
||||
<Row key={row.key} row={row} />
|
||||
))}
|
||||
</div>
|
||||
{unseen > 0 && (
|
||||
<button type="button" className="tail-pill visible" onClick={jumpToBottom}>
|
||||
↓ {unseen} new
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
/* .login-card — one elevated-surface card for the whole login-recovery
|
||||
overlay, replacing the old markup's per-`<p>`/`<form>` boxing
|
||||
(`agent.css`'s `.agent-status-overlay > *`, still needed there
|
||||
verbatim since app.js's still-live markup depends on it until the
|
||||
index.html cutover — this file coexists with it rather than editing
|
||||
it). Same visual language as `Dropdown`'s `.ui-dropdown`
|
||||
(`@hive/shared/dropdown/Dropdown.css`) — background/border/radius/
|
||||
shadow values match exactly, so this reads as the same "popup" family
|
||||
as every other floating surface in the app, just laid out as a static
|
||||
block instead of an anchored, position:absolute menu (that part of
|
||||
`Dropdown` is specific to hanging under a trigger and doesn't apply
|
||||
here — `.agent-status-overlay` already centres this card itself). */
|
||||
.login-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6em;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5em;
|
||||
box-shadow: 0 0.4em 1em rgba(0, 0, 0, 0.35);
|
||||
padding: 1em 1.2em;
|
||||
}
|
||||
.login-card > * {
|
||||
margin: 0;
|
||||
}
|
||||
|
|
@ -1,171 +0,0 @@
|
|||
// <LoginFlow> — the `needs_login_idle` / `needs_login_in_progress`
|
||||
// overlay. Ports app.js's `renderNeedsLoginIdle`/`renderLoginInProgress`
|
||||
// as real functional UI, not just a status label: an agent whose
|
||||
// credentials got rotated (or hit `/logout`) has NO other way back
|
||||
// online, so this is load-bearing, not cosmetic. Reuses agent.css's
|
||||
// `.status-needs-login`/`.btn-login`/`.loginform`/`.loginform-reveal`
|
||||
// rules verbatim (same class names as the old markup).
|
||||
//
|
||||
// mara (screenshot review): "reuse the popup component for the login,
|
||||
// the floating separate panels should be one card." The old markup's
|
||||
// `.agent-status-overlay > *` rule boxed every direct-child `<p>`/
|
||||
// `<form>` separately (a fine translation of `el().append()`-per-line,
|
||||
// but not what a hand-authored component should do) — this renders one
|
||||
// `.login-card` instead, styled with the same "elevated surface" look
|
||||
// `Dropdown`'s `.ui-dropdown` already established (`@hive/shared/
|
||||
// dropdown/Dropdown.css`), not `Dropdown` itself (its `options.map`
|
||||
// render is menu-item-shaped, not a generic content container — not a
|
||||
// fit for a login form + output pane).
|
||||
import { useState } from 'preact/hooks';
|
||||
import type { SessionView } from '../types.js';
|
||||
import { postLoginStart, postLoginCode, postLoginCancel } from '../lib/loginAction.js';
|
||||
import './LoginFlow.css';
|
||||
|
||||
function LoginIdle({ onStarted }: { onStarted: () => void }) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function submit(e: Event) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
postLoginStart().then((r) => {
|
||||
setBusy(false);
|
||||
if (r.ok) onStarted();
|
||||
else setError(r.detail ?? 'failed');
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="login-card">
|
||||
<p class="status-needs-login">◌ NEEDS L0G1N</p>
|
||||
<p>
|
||||
No Claude session in <code>~/.claude/</code>. The harness is up but the turn loop is
|
||||
paused until you log in.
|
||||
</p>
|
||||
<form onSubmit={submit}>
|
||||
<button type="submit" class="btn btn-login" disabled={busy}>
|
||||
◆ ST4RT L0G1N
|
||||
</button>
|
||||
</form>
|
||||
{error ? <p class="meta">error: {error}</p> : null}
|
||||
<p class="meta">
|
||||
Spawns <code>claude auth login</code> over plain stdio pipes. The OAuth URL will appear
|
||||
here when claude emits it; paste the resulting code back into the form below.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginInProgress({ session, onDone }: { session: SessionView | null; onDone: () => void }) {
|
||||
const [code, setCode] = useState('');
|
||||
const [reveal, setReveal] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const finished = session?.finished ?? false;
|
||||
|
||||
function submitCode(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!code) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
// Success: the next `/api/state` poll flips `status` back to
|
||||
// `online` (or leaves it `needs_login_in_progress` on a bad code,
|
||||
// with the process's own stderr showing up in `output` below) —
|
||||
// nothing local to update here either way.
|
||||
postLoginCode(code).then((r) => {
|
||||
setBusy(false);
|
||||
if (!r.ok) setError(r.detail ?? 'failed');
|
||||
});
|
||||
}
|
||||
|
||||
function cancel(e: Event) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
postLoginCancel().then(() => {
|
||||
setBusy(false);
|
||||
onDone();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="login-card">
|
||||
<p class="status-needs-login">◌ L0G1N 1N PR0GRESS</p>
|
||||
{session?.url ? (
|
||||
<>
|
||||
<p>
|
||||
▶{' '}
|
||||
<a href={session.url} target="_blank" rel="noreferrer">
|
||||
{session.url}
|
||||
</a>
|
||||
</p>
|
||||
<p class="meta">
|
||||
open this URL in a browser, complete the OAuth flow, paste the resulting code below.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p class="meta">waiting for claude to emit an OAuth URL on stdout… (output below)</p>
|
||||
)}
|
||||
{!finished ? (
|
||||
<form class="loginform" onSubmit={submitCode}>
|
||||
<input
|
||||
name="code"
|
||||
type={reveal ? 'text' : 'password'}
|
||||
placeholder="paste OAuth code here (hidden)"
|
||||
required
|
||||
autocomplete="one-time-code"
|
||||
spellcheck={false}
|
||||
value={code}
|
||||
onInput={(e) => setCode((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="loginform-reveal"
|
||||
title="show / hide pasted code"
|
||||
aria-label="show / hide pasted OAuth code"
|
||||
aria-pressed={reveal}
|
||||
onClick={() => setReveal((r) => !r)}
|
||||
>
|
||||
👁
|
||||
</button>
|
||||
<button type="submit" class="btn btn-login" disabled={busy}>
|
||||
◆ S3ND C0DE
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
<form style={{ marginTop: '0.4em' }} onSubmit={cancel}>
|
||||
<button type="submit" class="btn btn-cancel" disabled={busy}>
|
||||
cancel + kill
|
||||
</button>
|
||||
</form>
|
||||
{finished ? (
|
||||
<p class="status-needs-login">
|
||||
claude process exited: {session?.exit_note || 'exited'}. Start over if needed.
|
||||
</p>
|
||||
) : null}
|
||||
{error ? <p class="meta">error: {error}</p> : null}
|
||||
<h3>output</h3>
|
||||
<pre class="diff">{session?.output || ''}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoginFlow({
|
||||
status,
|
||||
session,
|
||||
onRefresh,
|
||||
}: {
|
||||
status: 'needs_login_idle' | 'needs_login_in_progress';
|
||||
session: SessionView | null;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div class="agent-status-overlay">
|
||||
{status === 'needs_login_idle' ? (
|
||||
<LoginIdle onStarted={onRefresh} />
|
||||
) : (
|
||||
<LoginInProgress session={session} onDone={onRefresh} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
/* <MetaNav> popover — matches `@hive/shared`'s `Dropdown` (`.ui-dropdown`)
|
||||
values exactly (background/border/radius/shadow, item padding/hover),
|
||||
same "reuse the values, not the component" call `.login-card`
|
||||
(LoginFlow.css) already made, for the same reason: this popover's
|
||||
items are real `<a>` tags, not `Dropdown`'s command-dispatch
|
||||
`<button>`s. Right-anchored, same as OverflowMenu.css's
|
||||
`.header-overflow-anchor` — MetaNav sits in the same right-hand
|
||||
pills cluster, so a left anchor would push it off-screen the same
|
||||
way. */
|
||||
.meta-nav-anchor {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.meta-nav-popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.25em);
|
||||
right: 0;
|
||||
left: auto;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 10em;
|
||||
padding: 0.25em;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5em;
|
||||
box-shadow: 0 0.4em 1em rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.meta-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
min-height: 2.75em;
|
||||
padding: 0.4em 0.7em;
|
||||
border-radius: 0.35em;
|
||||
color: var(--fg);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.meta-nav-item:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
// <MetaNav> — the header's meta-nav trigger: a single fixed-size Badge
|
||||
// ("🔗") in the pills cluster that opens a popover listing
|
||||
// stats/screen/forge/config + any `hyperhive.dashboardLinks` extras,
|
||||
// sourced from the backend's `agent_links()` (the single source of
|
||||
// truth — same list also feeds the dashboard card's icon strip).
|
||||
//
|
||||
// v1 of this rendered every link inline in the header's title row —
|
||||
// mara, live: "the login card looks like it is behind the header"
|
||||
// (a variable-width link list can grow the title row past the fixed
|
||||
// `--agent-header-h` the rest of the page is offset against — see
|
||||
// Header.tsx's comment) and "the links should have the same popout as
|
||||
// the links in the nav bar of swarm-ui." This is that: same shape as
|
||||
// swarm-ui's own `LinksMenu` (Shell/LinksMenu.tsx) — one quiet icon
|
||||
// trigger, a popover on click, real `<a>` items (not a command-dispatch
|
||||
// button list) so ctrl/middle-click and "copy link address" keep
|
||||
// working. Trigger itself reuses `@hive/shared`'s `Badge` (icon-only,
|
||||
// same shape as the header's `⋯` overflow trigger — same touch target,
|
||||
// same hover/expanded treatment, one visual language for every header
|
||||
// trigger) rather than a bespoke button. The popover isn't built from
|
||||
// `@hive/shared`'s `Dropdown` — its items are always `<button>`s
|
||||
// (command dispatch), which would lose real link semantics — but
|
||||
// matches its `.ui-dropdown` visual values exactly (see MetaNav.css),
|
||||
// the same "reuse the values, not the component" call `LoginFlow`'s
|
||||
// `.login-card` already made for the same reason.
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { Badge } from '@hive/shared/badge.js';
|
||||
import type { AgentLink } from '../types.js';
|
||||
import './MetaNav.css';
|
||||
|
||||
export interface MetaNavProps {
|
||||
links: AgentLink[];
|
||||
forgePublicUrl: string | null;
|
||||
}
|
||||
|
||||
export function MetaNav({ links, forgePublicUrl }: MetaNavProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Same "outside click or Escape closes" contract as Dropdown/OverflowMenu.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (rootRef.current && e.target instanceof Node && !rootRef.current.contains(e.target)) setOpen(false);
|
||||
}
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
}
|
||||
document.addEventListener('pointerdown', onPointerDown, true);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onPointerDown, true);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Same kind -> URL resolution as app.js's old refreshState meta-links
|
||||
// loop: `forge` needs `forgePublicUrl` set or the link is dropped
|
||||
// entirely (never guessed from `<host>:3000`); `external` is already
|
||||
// absolute; `container` is a same-origin path.
|
||||
const visible = links.filter((lnk) => lnk.kind !== 'forge' || forgePublicUrl);
|
||||
if (visible.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div class="meta-nav-anchor" ref={rootRef}>
|
||||
<Badge value="🔗" title="agent links" onClick={() => setOpen((o) => !o)} expanded={open} />
|
||||
{open ? (
|
||||
<div class="meta-nav-popover" role="menu" aria-label="agent links">
|
||||
{visible.map((lnk) => {
|
||||
const href = lnk.kind === 'forge' ? `${forgePublicUrl}${lnk.url}` : lnk.url;
|
||||
return (
|
||||
<a
|
||||
key={lnk.url}
|
||||
class="meta-nav-item"
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
role="menuitem"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{lnk.icon ? (
|
||||
<span aria-hidden="true">{lnk.icon}</span>
|
||||
) : null}
|
||||
{lnk.label}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
/* Anchors <Dropdown> under the "⋯" trigger — same positioning
|
||||
contract as StatusChips.css's `.status-chip-anchor`, kept as its own
|
||||
rule here rather than reused across components (see this file's
|
||||
sibling .tsx comment) so OverflowMenu doesn't depend on StatusChips'
|
||||
incidental CSS. */
|
||||
.header-overflow-anchor {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
/* @hive/shared's Dropdown.css anchors left-edge-to-left-edge, correct
|
||||
for a picker with room to its right (StatusChips' model/effort). The
|
||||
overflow trigger is the header's right-most element instead, so a
|
||||
left anchor pushes the menu off-screen — flip to right-edge-to-
|
||||
right-edge here, scoped to this one consumer rather than changing
|
||||
the shared default (which is still correct for every other caller). */
|
||||
.header-overflow-anchor .ui-dropdown {
|
||||
left: auto;
|
||||
right: 0;
|
||||
}
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
// <OverflowMenu> — the header's `⋯` trigger. Deliberately trimmed vs.
|
||||
// app.js's `populateOverflowMenu`: that one held dashboard-link/rebuild/
|
||||
// new-session/logout/model-picker/effort-picker all in one flat list —
|
||||
// the design guide's own named anti-example for a junk drawer. The
|
||||
// model/effort pickers already moved to StatusChips' own Badge+Dropdown
|
||||
// controls (this rewrite's first commit); new-session/logout now live
|
||||
// as TermInput slash commands (typed, not menu-clicked, and already
|
||||
// have their own two-step confirm). What's left here — rebuild + a
|
||||
// dashboard back-link — are genuinely menu-shaped (rare, not tied to
|
||||
// any other visible control), so they're still a `⋯` menu, just a much
|
||||
// shorter one.
|
||||
//
|
||||
// Reuses `@hive/shared`'s `Badge`+`Dropdown` — same anchored-popover
|
||||
// pair StatusChips' model/effort pickers use, not a bespoke popover.
|
||||
// Rebuild's confirm is the same "select once to arm, select again to
|
||||
// fire" pattern as TermInput's `/new-session`/`/logout` — adapted here
|
||||
// to a menu click instead of a typed repeat, but the same "requires
|
||||
// deliberate repetition, not a modal" idea.
|
||||
import { useState } from 'preact/hooks';
|
||||
import { Badge } from '@hive/shared/badge.js';
|
||||
import { Dropdown, type DropdownOption } from '@hive/shared/dropdown.js';
|
||||
import { submitRebuild } from '../lib/rebuildAction.js';
|
||||
import './OverflowMenu.css';
|
||||
|
||||
export interface OverflowMenuProps {
|
||||
label: string;
|
||||
dashboardBase: string;
|
||||
}
|
||||
|
||||
export function OverflowMenu({ label, dashboardBase }: OverflowMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [armed, setArmed] = useState(false);
|
||||
|
||||
function close() {
|
||||
setOpen(false);
|
||||
setArmed(false);
|
||||
}
|
||||
|
||||
const options: DropdownOption[] = [
|
||||
{ value: 'dashboard', label: '↑ dashboard' },
|
||||
{ value: 'rebuild', label: armed ? '↻ rebuild — click again to confirm' : '↻ rebuild container' },
|
||||
];
|
||||
|
||||
function onSelect(value: string) {
|
||||
if (value === 'dashboard') {
|
||||
window.open(`${dashboardBase}dashboard.html`, '_blank', 'noopener');
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (value === 'rebuild') {
|
||||
if (armed) {
|
||||
submitRebuild(dashboardBase, label);
|
||||
close();
|
||||
} else {
|
||||
setArmed(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="header-overflow-anchor">
|
||||
<Badge value="⋯" title="more actions" onClick={() => setOpen((o) => !o)} expanded={open} />
|
||||
<Dropdown open={open} options={options} label="more actions" onSelect={onSelect} onClose={close} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
// Renders one `StreamRow` — flat `<div class="row …">` or expandable
|
||||
// `<details class="row …">`, matching @hive/shared/terminal.css's
|
||||
// existing row-kind classes exactly (see docs/terminal-rendering.md).
|
||||
// Reuses that stylesheet as-is (imported once by LiveStream.tsx) — the
|
||||
// taxonomy's visual language isn't what mara asked to change, the
|
||||
// component *model* underneath it is.
|
||||
import { useEffect, useRef } from 'preact/hooks';
|
||||
import type { StreamRow } from '../lib/streamRow.js';
|
||||
import { linkifyToNodes } from '../lib/linkify.js';
|
||||
import { renderMarkdown } from '../lib/markdown.js';
|
||||
|
||||
function MarkdownBody({ text }: { text: string }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const html = renderMarkdown(text);
|
||||
useEffect(() => {
|
||||
// marked autolinks URLs but leaves them same-tab — open externally
|
||||
// so a click never navigates the terminal away.
|
||||
ref.current?.querySelectorAll('a[href]').forEach((a) => {
|
||||
a.setAttribute('target', '_blank');
|
||||
a.setAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
}, [html]);
|
||||
// eslint-disable-next-line react/no-danger -- sanitized by DOMPurify in renderMarkdown
|
||||
return <div className="md" ref={ref} dangerouslySetInnerHTML={{ __html: html }} />;
|
||||
}
|
||||
|
||||
function DiffBody({ text }: { text: string }) {
|
||||
const lines = String(text).split('\n');
|
||||
return (
|
||||
<pre className="tool-body diff-body">
|
||||
{lines.map((line, i) => {
|
||||
const cls = line.startsWith('+ ') ? 'diff-add' : line.startsWith('- ') ? 'diff-del' : 'diff-ctx';
|
||||
return (
|
||||
<span key={i} className={cls}>
|
||||
{line}
|
||||
{'\n'}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
export function Row({ row }: { row: StreamRow }) {
|
||||
if (row.details) {
|
||||
return (
|
||||
<details className={`row ${row.cssClass}`} open={row.defaultOpen || undefined}>
|
||||
<summary>
|
||||
{row.icon != null && row.icon !== '' && <span className="row-glyph">{row.icon}</span>}
|
||||
<span className="summary-text">{row.text}</span>
|
||||
</summary>
|
||||
{row.diffBody != null && <DiffBody text={row.diffBody} />}
|
||||
{row.markdownBody != null && <MarkdownBody text={row.markdownBody} />}
|
||||
{row.plainBody != null && <pre className="tool-body">{linkifyToNodes(row.plainBody)}</pre>}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={`row ${row.cssClass}`}>
|
||||
{row.icon != null && row.icon !== '' && <span className="row-glyph">{row.icon}</span>}
|
||||
{row.text != null && linkifyToNodes(row.text)}
|
||||
{row.meta?.map((m) => (
|
||||
<span key={m.cls} className={m.cls}>
|
||||
{m.text}
|
||||
</span>
|
||||
))}
|
||||
{row.childText != null && <div className={row.childText.cls}>{row.childText.text}</div>}
|
||||
{row.markdownBody != null && <MarkdownBody text={row.markdownBody} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
/* Drawer chrome for <SidePanel> — 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;
|
||||
}
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
// <SidePanel> — the slide-in drawer for the inbox/todos flyouts.
|
||||
// Deliberately a new Preact component, not the shared
|
||||
// `<hive-side-panel>` 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 (
|
||||
<>
|
||||
<div class={`agent-side-panel-backdrop${open ? ' open' : ''}`} onClick={onClose} aria-hidden="true" />
|
||||
<aside
|
||||
class={`agent-side-panel-drawer${open ? ' open' : ''}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-hidden={!open}
|
||||
aria-label={title}
|
||||
>
|
||||
<div class="agent-side-panel-head">
|
||||
<span class="agent-side-panel-title">{title}</span>
|
||||
<button type="button" class="agent-side-panel-close" title="close (esc)" onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div class="agent-side-panel-body">{children}</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
/* <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;
|
||||
}
|
||||
/* mara, live, on a screenshot of this row: "badges and interactive
|
||||
elements on same row have inconsistent size." Real cause: Badge.css's
|
||||
`.ui-badge-interactive` grows to a 2.75em WCAG touch-target floor
|
||||
(model/effort pickers, pause) while a plain display `<span>` badge
|
||||
(alive, state, ctx, cost — no `onClick`) stays compact, so the row
|
||||
reads as two different pill sizes even though every badge here is
|
||||
the same `<Badge>` component. Scoped to this row rather than
|
||||
Badge.css itself — display-only badges elsewhere (e.g. the dashboard)
|
||||
have no reason to grow, this is specifically about badges and
|
||||
buttons sitting side by side in one row needing to read as one
|
||||
family. */
|
||||
.status-chips .ui-badge {
|
||||
min-height: 2.75em;
|
||||
padding-inline: 0.8em;
|
||||
}
|
||||
.status-chips-last-turn {
|
||||
font-size: 0.8em;
|
||||
color: var(--muted);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue