Watch
0
0
Fork
You've already forked hyperhive
0

treefmt: apply prettier

Pure `nix fmt` output from the commit before this one — no hand edits.
203 files: 52 md, 42 tsx, 32 js, 32 css, 21 ts, 13 html, 8 json, 3 mjs.

Reproduce with `nix develop -c nix fmt` on the parent commit; the result
should be byte-identical to this tree.

None of the 13 `.prettierignore` entries appears here — verified by
intersecting the changed-file list against the ignore file, with a
control proving the intersection finds a match when one exists.
This commit is contained in:
atlas 2026-09-02 14:29:33 +02:00
commit 39b95c2ede
34 changed files with 10090 additions and 6085 deletions

View file

@ -4,55 +4,61 @@
// `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 { SettingsMenu } from '@hive/shared/settings-menu.js';
import { useApplyThemeOverride } from '@hive/shared/theme-apply.js';
import { useApplyMotionOverride } from '@hive/shared/motion-apply.js';
import { Header } from './components/Header.js';
import { MetaNav } from './components/MetaNav.js';
import { ExpandDetailsSetting } from './components/ExpandDetailsSetting.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 { 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 { postCancelTurn } from './lib/termActions.js';
import type { TokenUsage } from './types.js';
import { useEffect, useRef, useState } from "preact/hooks";
import type { BadgeTone } from "@hive/shared/badge.js";
import { SettingsMenu } from "@hive/shared/settings-menu.js";
import { useApplyThemeOverride } from "@hive/shared/theme-apply.js";
import { useApplyMotionOverride } from "@hive/shared/motion-apply.js";
import { Header } from "./components/Header.js";
import { MetaNav } from "./components/MetaNav.js";
import { ExpandDetailsSetting } from "./components/ExpandDetailsSetting.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 { 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 { postCancelTurn } from "./lib/termActions.js";
import type { TokenUsage } from "./types.js";
type OpenPanel = 'inbox' | 'todos' | null;
type OpenPanel = "inbox" | "todos" | null;
// Storage keys this page owns for `@hive/shared`'s settings mechanism —
// kept here, not derived, so `useApplyThemeOverride`/`useApplyMotionOverride`
// (mounted once below) and `<SettingsMenu>` (in `pills`, further down)
// always agree on which browser-local key they're reading/writing.
const THEME_KEY = 'agent:theme-override';
const MOTION_KEY = 'agent:motion-override';
const THEME_KEY = "agent:theme-override";
const MOTION_KEY = "agent:motion-override";
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 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_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',
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",
};
@ -61,7 +67,11 @@ const STATE_TOOLTIPS: Record<string, string> = {
// 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);
return (
(u.input_tokens ?? 0) +
(u.cache_read_input_tokens ?? 0) +
(u.cache_creation_input_tokens ?? 0)
);
}
export function Root() {
@ -96,13 +106,15 @@ export function Root() {
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`;
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'}
label={state?.label ?? "…"}
online={state?.status === "online"}
onLocalNote={(text) => liveStreamRef.current?.pushNote(text)}
onClear={() => liveStreamRef.current?.clear()}
/>
@ -111,8 +123,20 @@ export function Root() {
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')} />
<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 its data (links/forge_public_url,
* dashboard_port) — omitted until the first /api/state resolves,
* same as the old page's populateOverflowMenu (now deleted —
@ -135,17 +159,28 @@ export function Root() {
// 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 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' ? (
<SidePanel
open={openPanel !== null}
title={panelTitle}
onClose={() => setOpenPanel(null)}
>
{openPanel === "inbox" ? (
<InboxPanel
rows={state?.inbox ?? []}
label={state?.label ?? ''}
dashboardBase={state ? resolveDashboardBase(state.dashboard_port) : ''}
label={state?.label ?? ""}
dashboardBase={
state ? resolveDashboardBase(state.dashboard_port) : ""
}
onCleared={refresh}
/>
) : openPanel === 'todos' ? (
) : openPanel === "todos" ? (
<TodosPanel todos={todos} onCleared={refreshTodos} />
) : null}
</SidePanel>
@ -179,13 +214,22 @@ export function Root() {
);
}
const alive = ALIVE_LABELS[state.status] ?? { glyph: '○', text: 'offline', tone: 'negative' as BadgeTone };
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 effectiveTurnState =
state.status === "online" ? state.turn_state : "offline";
const turnDef = STATE_LABELS[effectiveTurnState] ?? {
glyph: "○",
text: "offline",
tone: "negative" as BadgeTone,
};
const stateAge = fmtAge(now - state.turn_state_since * 1000);
// Consolidated status badge (mara: "why do we have separate alive
// badge? ... one badge that shows thinking / idle / paused").
@ -195,10 +239,20 @@ export function Root() {
// thinking regardless of its last turn_state. Online + running →
// turn-state + age, same as before.
const primaryStatus =
state.status !== 'online'
? { glyph: alive.glyph, text: alive.text, tone: alive.tone, tooltip: undefined as string | undefined }
state.status !== "online"
? {
glyph: alive.glyph,
text: alive.text,
tone: alive.tone,
tooltip: undefined as string | undefined,
}
: state.paused
? { glyph: '⏸', text: 'paused', tone: 'warning' as BadgeTone, tooltip: undefined }
? {
glyph: "⏸",
text: "paused",
tone: "warning" as BadgeTone,
tooltip: undefined,
}
: {
glyph: turnDef.glyph,
text: `${turnDef.text} · ${stateAge}`,
@ -212,7 +266,10 @@ export function Root() {
<>
<Header
label={state.label}
hiveLabel={[state.swarm_name, state.hive_name].filter(Boolean).join(' / ') || null}
hiveLabel={
[state.swarm_name, state.hive_name].filter(Boolean).join(" / ") ||
null
}
pills={pills}
>
<StatusChips
@ -234,22 +291,32 @@ export function Root() {
costLabel={cost !== null ? fmtTokens(cost) : undefined}
paused={state.paused}
onTogglePause={() => {
submitPauseResume(resolveDashboardBase(state.dashboard_port), state.label, state.paused ? 'resume' : 'pause').then(
() => refresh(),
);
submitPauseResume(
resolveDashboardBase(state.dashboard_port),
state.label,
state.paused ? "resume" : "pause",
).then(() => refresh());
}}
thinking={effectiveTurnState === 'thinking'}
thinking={effectiveTurnState === "thinking"}
onCancelTurn={() =>
postCancelTurn().then((r) => {
if (!r.ok) liveStreamRef.current?.pushNote(`✗ /cancel failed${r.detail ? ': ' + r.detail : ''}`);
if (!r.ok)
liveStreamRef.current?.pushNote(
`✗ /cancel failed${r.detail ? ": " + r.detail : ""}`,
);
refresh();
})
}
/>
</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} />
{state.status === "needs_login_idle" ||
state.status === "needs_login_in_progress" ? (
<LoginFlow
status={state.status}
session={state.session}
onRefresh={refresh}
/>
) : null}
<LiveStream ref={liveStreamRef} />
</main>

View file

@ -30,7 +30,11 @@
--agent-frost-blur: blur(12px) saturate(140%);
}
html, body { height: 100%; margin: 0; }
html,
body {
height: 100%;
margin: 0;
}
/* Legacy in-page layout retained for the sibling stats page
(`stats.html`) which doesn't apply `body.agent-shell` and stays
@ -58,7 +62,9 @@ body:not(.agent-shell):not(.screen-shell) {
-webkit-background-clip: text;
background-clip: text;
color: transparent;
filter: drop-shadow(0 0 6px color-mix(in srgb, var(--purple) 45%, transparent));
filter: drop-shadow(
0 0 6px color-mix(in srgb, var(--purple) 45%, transparent)
);
}
body.agent-shell {
@ -69,9 +75,11 @@ body.agent-shell {
/* Subtle radial accent to give the otherwise-flat full-screen
surface some depth and reinforce the vibec0re mood. */
background:
radial-gradient(ellipse 80% 60% at 50% 0%,
color-mix(in srgb, var(--purple) 6%, transparent) 0%,
transparent 60%),
radial-gradient(
ellipse 80% 60% at 50% 0%,
color-mix(in srgb, var(--purple) 6%, transparent) 0%,
transparent 60%
),
var(--bg);
}
@ -158,7 +166,8 @@ body.agent-shell {
align-self: center;
}
h2, h3 {
h2,
h3 {
color: var(--purple);
text-transform: uppercase;
letter-spacing: 0.15em;
@ -190,7 +199,10 @@ h2, h3 {
padding: 0.1em 0.35em;
border-radius: 3px;
text-shadow: 0 0 4px color-mix(in srgb, var(--cyan) 40%, transparent);
transition: color 0.15s ease, text-shadow 0.15s ease, background 0.15s ease;
transition:
color 0.15s ease,
text-shadow 0.15s ease,
background 0.15s ease;
}
.agent-nav-link:hover {
color: var(--fg);
@ -230,7 +242,9 @@ h2, h3 {
width: calc(100% - 3em);
z-index: 10;
}
.agent-status-overlay:empty { display: none; }
.agent-status-overlay:empty {
display: none;
}
.agent-status-overlay > * {
background: var(--bg-elev);
border: 1px solid var(--purple-dim);
@ -263,15 +277,31 @@ h2, h3 {
border-top: 0;
padding-top: 0;
}
.meta { color: var(--muted); font-size: 0.85em; }
.status-online { color: var(--green); text-shadow: 0 0 6px color-mix(in srgb, var(--green) 55%, transparent); }
.status-needs-login { color: var(--amber); text-shadow: 0 0 6px color-mix(in srgb, var(--amber) 55%, transparent); }
code { background: color-mix(in srgb, var(--purple) 12%, transparent); padding: 0.05em 0.3em; border-radius: 2px; }
.meta {
color: var(--muted);
font-size: 0.85em;
}
.status-online {
color: var(--green);
text-shadow: 0 0 6px color-mix(in srgb, var(--green) 55%, transparent);
}
.status-needs-login {
color: var(--amber);
text-shadow: 0 0 6px color-mix(in srgb, var(--amber) 55%, transparent);
}
code {
background: color-mix(in srgb, var(--purple) 12%, transparent);
padding: 0.05em 0.3em;
border-radius: 2px;
}
a {
color: var(--cyan);
text-shadow: 0 0 4px color-mix(in srgb, var(--cyan) 50%, transparent);
}
a:hover { color: var(--fg); text-shadow: 0 0 12px color-mix(in srgb, var(--cyan) 90%, transparent); }
a:hover {
color: var(--fg);
text-shadow: 0 0 12px color-mix(in srgb, var(--cyan) 90%, transparent);
}
.btn {
font-family: inherit;
font-size: 1em;
@ -284,15 +314,25 @@ a:hover { color: var(--fg); text-shadow: 0 0 12px color-mix(in srgb, var(--cyan)
}
.btn {
text-shadow: 0 0 4px currentColor;
transition: box-shadow 0.15s ease, text-shadow 0.15s ease;
transition:
box-shadow 0.15s ease,
text-shadow 0.15s ease;
}
.btn:hover {
background: color-mix(in srgb, var(--fg) 6%, transparent);
text-shadow: 0 0 10px currentColor;
box-shadow: 0 0 10px -2px currentColor;
}
.btn-login { color: var(--amber); border-color: var(--amber); }
.btn-cancel { color: var(--red); border-color: var(--red); font-size: 0.85em; padding: 0.15em 0.6em; }
.btn-login {
color: var(--amber);
border-color: var(--amber);
}
.btn-cancel {
color: var(--red);
border-color: var(--red);
font-size: 0.85em;
padding: 0.15em 0.6em;
}
/* Orphaned rules — left here as a tombstone so a search for the
class name finds them. Live consumers gone:
- `.btn-rebuild` was a per-agent header chip, then briefly an
@ -301,26 +341,41 @@ a:hover { color: var(--fg); text-shadow: 0 0 12px color-mix(in srgb, var(--cyan)
outright now, no replacement.
- `.btn-send` was a green send-button variant — the dashboard's
compose form that used it is retired. */
.sendform { display: flex; gap: 0.6em; margin-top: 0.5em; }
.sendform {
display: flex;
gap: 0.6em;
margin-top: 0.5em;
}
.sendform input {
font-family: inherit; font-size: 1em;
font-family: inherit;
font-size: 1em;
background: rgba(255, 255, 255, 0.04);
color: var(--fg);
border: 1px solid var(--purple-dim);
padding: 0.4em 0.6em;
flex: 1;
}
.sendform input:focus { outline: 1px solid var(--purple); }
.loginform { display: flex; gap: 0.6em; margin-top: 0.5em; align-items: stretch; }
.sendform input:focus {
outline: 1px solid var(--purple);
}
.loginform {
display: flex;
gap: 0.6em;
margin-top: 0.5em;
align-items: stretch;
}
.loginform input {
font-family: inherit; font-size: 1em;
font-family: inherit;
font-size: 1em;
background: rgba(255, 255, 255, 0.04);
color: var(--fg);
border: 1px solid var(--purple-dim);
padding: 0.4em 0.6em;
flex: 1;
}
.loginform input:focus { outline: 1px solid var(--purple); }
.loginform input:focus {
outline: 1px solid var(--purple);
}
/* Show / hide toggle for the masked OAuth-code input. Quiet by
default (muted border + transparent bg), lights amber on hover
@ -338,7 +393,10 @@ a:hover { color: var(--fg); text-shadow: 0 0 12px color-mix(in srgb, var(--cyan)
display: inline-flex;
align-items: center;
justify-content: center;
transition: color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
transition:
color 0.15s ease,
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.loginform-reveal:hover,
.loginform-reveal:focus-visible {
@ -373,8 +431,12 @@ pre.diff {
letter-spacing: 0.05em;
list-style: none;
}
.agent-inbox > summary::marker { content: ''; }
.agent-inbox[open] > summary > span::before { content: ''; }
.agent-inbox > summary::marker {
content: "";
}
.agent-inbox[open] > summary > span::before {
content: "";
}
.agent-inbox ul {
list-style: none;
padding: 0.4em 0.8em;
@ -392,9 +454,18 @@ pre.diff {
padding: 0.4em 0;
display: block;
}
.agent-inbox .inbox-ts { color: var(--muted); font-size: 0.9em; margin-left: 0.5em; }
.agent-inbox .inbox-from { color: var(--amber); }
.agent-inbox .inbox-sep { color: var(--muted); margin-left: 0.4em; }
.agent-inbox .inbox-ts {
color: var(--muted);
font-size: 0.9em;
margin-left: 0.5em;
}
.agent-inbox .inbox-from {
color: var(--amber);
}
.agent-inbox .inbox-sep {
color: var(--muted);
margin-left: 0.4em;
}
/* Todos flyout per-row checkbox (bulk mark-done) — sits inline before the
existing `.inbox-from` label, same row. */
.agent-inbox .todo-cb {
@ -411,9 +482,16 @@ pre.diff {
border-left: 2px solid var(--purple-dim);
}
/* Running bash-tasks flyout rows (buildBashTasksList). */
.agent-inbox .bash-task-status { font-weight: bold; font-size: 0.9em; }
.agent-inbox .bash-task-running { color: var(--green); }
.agent-inbox .bash-task-pending { color: var(--blue); }
.agent-inbox .bash-task-status {
font-weight: bold;
font-size: 0.9em;
}
.agent-inbox .bash-task-running {
color: var(--green);
}
.agent-inbox .bash-task-pending {
color: var(--blue);
}
.agent-inbox .bash-task-cmd {
font-family: monospace;
font-size: 0.92em;
@ -423,7 +501,10 @@ pre.diff {
border-left: 2px solid var(--border);
margin-left: 0.4em;
}
.agent-inbox .inbox-reply-tag { color: var(--muted); font-size: 0.85em; }
.agent-inbox .inbox-reply-tag {
color: var(--muted);
font-size: 0.85em;
}
/* Bulk-action header row: "mark all read" above the recent-messages
list in the inbox flyout, and "select all / select none / mark
@ -473,8 +554,12 @@ pre.diff {
.effort-chip {
border-color: var(--purple-dim);
}
.model-chip { color: var(--cyan); }
.effort-chip { color: var(--amber); }
.model-chip {
color: var(--cyan);
}
.effort-chip {
color: var(--amber);
}
/* Context-window badge. Mirrors Claude Code's bottom-right "N tokens"
chip — single primary number (total prompt tokens in use), full
breakdown on hover. Sized/coloured like a peer of model-chip so
@ -489,13 +574,28 @@ pre.diff {
/* Harness reachability badge. Markup carries `hive-pill` alongside
`status-badge` (shared pill.css supplies the shape); colour
communicates the actual reachability state. */
.status-badge.status-loading { color: var(--muted); border-color: var(--purple-dim); }
.status-badge.status-online { color: var(--green); border-color: var(--green);
text-shadow: 0 0 6px color-mix(in srgb, var(--green) 55%, transparent); }
.status-badge.status-rate-limited { color: var(--red); border-color: var(--red);
text-shadow: 0 0 6px color-mix(in srgb, var(--red) 55%, transparent); }
.status-badge.status-needs-login { color: var(--amber); border-color: var(--amber); }
.status-badge.status-offline { color: var(--muted); border-color: var(--muted); }
.status-badge.status-loading {
color: var(--muted);
border-color: var(--purple-dim);
}
.status-badge.status-online {
color: var(--green);
border-color: var(--green);
text-shadow: 0 0 6px color-mix(in srgb, var(--green) 55%, transparent);
}
.status-badge.status-rate-limited {
color: var(--red);
border-color: var(--red);
text-shadow: 0 0 6px color-mix(in srgb, var(--red) 55%, transparent);
}
.status-badge.status-needs-login {
color: var(--amber);
border-color: var(--amber);
}
.status-badge.status-offline {
color: var(--muted);
border-color: var(--muted);
}
/* Orphaned tombstone — `.btn-dashlink` chip that lived beside the
title moved into the overflow menu, then into `MetaNav`'s `🔗`
popover (the `↑ dashboard` item) once the overflow menu itself was
@ -512,26 +612,34 @@ pre.diff {
supplies the shape); this rule is only the transition + state-
specific colour below. */
.state-badge {
transition: color 280ms ease, border-color 280ms ease,
box-shadow 280ms ease, background 280ms ease;
transition:
color 280ms ease,
border-color 280ms ease,
box-shadow 280ms ease,
background 280ms ease;
}
.state-badge.state-loading {
color: var(--muted); border-color: var(--purple-dim);
color: var(--muted);
border-color: var(--purple-dim);
}
.state-badge.state-offline {
color: var(--muted); border-color: var(--muted);
color: var(--muted);
border-color: var(--muted);
}
.state-badge.state-idle {
color: var(--cyan); border-color: var(--cyan);
color: var(--cyan);
border-color: var(--cyan);
text-shadow: 0 0 6px color-mix(in srgb, var(--cyan) 55%, transparent);
}
.state-badge.state-thinking {
color: var(--amber); border-color: var(--amber);
color: var(--amber);
border-color: var(--amber);
text-shadow: 0 0 6px color-mix(in srgb, var(--amber) 65%, transparent);
animation: badge-pulse 1.8s ease-in-out infinite;
}
.state-badge.state-compacting {
color: var(--purple); border-color: var(--purple);
color: var(--purple);
border-color: var(--purple);
text-shadow: 0 0 6px color-mix(in srgb, var(--purple) 65%, transparent);
animation: badge-pulse 1.8s ease-in-out infinite;
}
@ -539,9 +647,21 @@ pre.diff {
animation: state-flash 600ms ease-out;
}
@keyframes state-flash {
0% { box-shadow: 0 0 0 0 currentColor, 0 0 0 0 currentColor; }
60% { box-shadow: 0 0 18px -4px currentColor, 0 0 4px 0 currentColor; }
100% { box-shadow: 0 0 0 0 currentColor, 0 0 0 0 currentColor; }
0% {
box-shadow:
0 0 0 0 currentColor,
0 0 0 0 currentColor;
}
60% {
box-shadow:
0 0 18px -4px currentColor,
0 0 4px 0 currentColor;
}
100% {
box-shadow:
0 0 0 0 currentColor,
0 0 0 0 currentColor;
}
}
/* Full-screen overrides for the shared terminal rules. The base
`.terminal-wrap` (in shared/src/terminal.css) ships a crust-on-
@ -571,7 +691,9 @@ pre.diff {
focus restore) clear of the floats too. */
padding-top: calc(var(--agent-header-real-h, 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-real-h, var(--agent-header-h)) + 0.8em
);
scroll-padding-bottom: calc(var(--agent-composer-h) + 0.8em);
overflow: auto;
}
@ -635,10 +757,21 @@ pre.diff {
line-height: 1.4;
min-height: 1.4em;
}
.term-input textarea::placeholder { color: var(--muted); }
.term-input .submit-hint { color: var(--muted); font-size: 0.8em; flex: 0 0 auto; }
.term-input.disabled .prompt { color: var(--muted); text-shadow: none; }
.term-input.disabled textarea { color: var(--muted); }
.term-input textarea::placeholder {
color: var(--muted);
}
.term-input .submit-hint {
color: var(--muted);
font-size: 0.8em;
flex: 0 0 auto;
}
.term-input.disabled .prompt {
color: var(--muted);
text-shadow: none;
}
.term-input.disabled textarea {
color: var(--muted);
}
/* ─── side panel (singleton drawer) ────────────────────────────────
Inbox + loose-ends details open here instead of expanding inline. The
@ -658,7 +791,9 @@ pre.diff {
body.side-panel-resizing {
user-select: none;
}
body.side-panel-resizing * { cursor: ew-resize !important; }
body.side-panel-resizing * {
cursor: ew-resize !important;
}
/* Inbox / loose-ends lists rendered into the side-panel body: strip
the inbox-only chrome (background, border-left) here and let the
panel body's own padding own the framing. */
@ -774,9 +909,16 @@ hive-side-panel .agent-inbox ul {
font-size: 0.95rem;
font-weight: normal;
}
.stats-card .chart-wrap { position: relative; height: 220px; }
.stats-card.wide { grid-column: 1 / -1; }
.stats-card.wide .chart-wrap { height: 260px; }
.stats-card .chart-wrap {
position: relative;
height: 220px;
}
.stats-card.wide {
grid-column: 1 / -1;
}
.stats-card.wide .chart-wrap {
height: 260px;
}
/* ─── /screen page ─── */
/* Full-screen VNC canvas viewer (screen.html). All rules scoped to
@ -802,8 +944,14 @@ body.screen-shell {
border-radius: 4px;
cursor: pointer;
}
.tbtn.active { color: var(--green); border-color: var(--green); }
.tbtn:disabled { opacity: 0.4; cursor: default; }
.tbtn.active {
color: var(--green);
border-color: var(--green);
}
.tbtn:disabled {
opacity: 0.4;
cursor: default;
}
/* Connection status chip at the right end of the screen page header. */
#status {
@ -811,13 +959,19 @@ body.screen-shell {
font-size: 0.75rem;
color: var(--subtext0);
}
#status.connected { color: var(--green); }
#status.error { color: var(--red); }
#status.connected {
color: var(--green);
}
#status.error {
color: var(--red);
}
/* RFB debug log — fixed panel at the bottom, hidden by default. */
#debug-log {
position: fixed;
bottom: 0; left: 0; right: 0;
bottom: 0;
left: 0;
right: 0;
max-height: 40vh;
overflow-y: auto;
background: color-mix(in srgb, var(--crust) 95%, transparent);
@ -828,10 +982,20 @@ body.screen-shell {
z-index: 100;
display: none; /* toggled by the debug button */
}
#debug-log .dbg-line { color: var(--subtext0); margin: 1px 0; white-space: pre; }
#debug-log .dbg-line.err { color: var(--red); }
#debug-log .dbg-line.ok { color: var(--green); }
#debug-log .dbg-line.send { color: var(--blue); }
#debug-log .dbg-line {
color: var(--subtext0);
margin: 1px 0;
white-space: pre;
}
#debug-log .dbg-line.err {
color: var(--red);
}
#debug-log .dbg-line.ok {
color: var(--green);
}
#debug-log .dbg-line.send {
color: var(--blue);
}
/* Canvas container — centres the VNC framebuffer. */
#canvas-wrap {
@ -844,11 +1008,21 @@ body.screen-shell {
background: var(--crust);
}
/* Fit mode: canvas scaled in JS to fill the container. */
#canvas-wrap.fit { align-items: center; overflow: hidden; }
#canvas-wrap canvas { display: block; cursor: default; }
#canvas-wrap.fit {
align-items: center;
overflow: hidden;
}
#canvas-wrap canvas {
display: block;
cursor: default;
}
/* Pin the canvas to the JS-calculated size; prevents flex-item
min-width:auto from silently expanding beyond the scaled size. */
#canvas-wrap.fit canvas { flex: none; min-width: 0; min-height: 0; }
#canvas-wrap.fit canvas {
flex: none;
min-width: 0;
min-height: 0;
}
/* Transient flash message (copy-text, disconnect, etc.). */
#msg {

View file

@ -8,8 +8,11 @@
// will keep accumulating cruft in the shared component." This file owns
// its own state/storage entirely; `SettingsMenu` just renders it as a
// child, no different from any other consumer's markup.
import { useState } from 'preact/hooks';
import { getExpandDetailsPref, setExpandDetailsPref } from '@hive/shared/prefs.js';
import { useState } from "preact/hooks";
import {
getExpandDetailsPref,
setExpandDetailsPref,
} from "@hive/shared/prefs.js";
export function ExpandDetailsSetting() {
// Plain localStorage read, not a hook-managed override like
@ -17,7 +20,9 @@ export function ExpandDetailsSetting() {
// (`@hive/shared/prefs.js`) are the existing shared-storage-key pair
// the terminal itself already reads live (no round-trip needed here
// beyond re-rendering this row's own checkbox state on toggle).
const [expandDetails, setExpandDetailsState] = useState(() => getExpandDetailsPref());
const [expandDetails, setExpandDetailsState] = useState(() =>
getExpandDetailsPref(),
);
return (
<label class="settings-menu-row">
<span>expand tool output</span>

View file

@ -22,8 +22,8 @@
// 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';
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
@ -34,8 +34,8 @@ import type { ComponentChildren } from 'preact';
function handleIconError(e: Event) {
const img = e.currentTarget as HTMLImageElement;
if (img.dataset.fallback) return;
img.dataset.fallback = '1';
img.src = '/favicon.svg';
img.dataset.fallback = "1";
img.src = "/favicon.svg";
}
export interface HeaderProps {
@ -62,13 +62,16 @@ export function Header({ label, hiveLabel, children, pills }: HeaderProps) {
useEffect(() => {
const el = ref.current;
if (!el || typeof ResizeObserver === 'undefined') return;
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`);
document.documentElement.style.setProperty(
"--agent-header-real-h",
`${el.getBoundingClientRect().height}px`,
);
});
ro.observe(el);
return () => ro.disconnect();
@ -81,7 +84,9 @@ export function Header({ label, hiveLabel, children, pills }: HeaderProps) {
<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}
{hiveLabel ? (
<div class="agent-header-row agent-hive-row">{hiveLabel}</div>
) : null}
</div>
{children || pills ? (
<div class="agent-header-pills">

View file

@ -8,22 +8,28 @@
// count === 0` in the old page) and picking the inbox/todos tone so
// the count reads amber/green like it always has (`Badge`'s `tone`
// colours the value, which here is the count).
import { Badge, type BadgeTone } from '@hive/shared/badge.js';
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
export interface HeaderPillProps {
kind: 'inbox' | 'todos';
kind: "inbox" | "todos";
icon: string;
label: string;
count: number;
onClick: () => void;
}
const TONE: Record<HeaderPillProps['kind'], BadgeTone> = {
inbox: 'warning',
todos: 'positive',
const TONE: Record<HeaderPillProps["kind"], BadgeTone> = {
inbox: "warning",
todos: "positive",
};
export function HeaderPill({ kind, icon, label, count, onClick }: HeaderPillProps) {
export function HeaderPill({
kind,
icon,
label,
count,
onClick,
}: HeaderPillProps) {
if (count === 0) return null;
return (
<Badge

View file

@ -7,9 +7,9 @@
// 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';
import { useState } from "preact/hooks";
import { useConfirmClick } from "../lib/useConfirmClick.js";
import type { InboxRow } from "../types.js";
export interface InboxPanelProps {
rows: InboxRow[];
@ -19,24 +19,35 @@ export interface InboxPanelProps {
}
function fmtTs(unixSeconds: number): string {
return new Date(unixSeconds * 1000).toISOString().replace('T', ' ').slice(5, 19);
return new Date(unixSeconds * 1000)
.toISOString()
.replace("T", " ")
.slice(5, 19);
}
export function InboxPanel({ rows, label, dashboardBase, onCleared }: InboxPanelProps) {
const [status, setStatus] = useState('');
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');
setStatus("dashboard url / label unknown");
return;
}
setBusy(true);
setStatus('clearing…');
setStatus("clearing…");
try {
const resp = await fetch(`${dashboardBase}api/agent/${encodeURIComponent(label)}/mark-all-read`, {
method: 'POST',
});
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;
@ -68,16 +79,23 @@ export function InboxPanel({ rows, label, dashboardBase, onCleared }: InboxPanel
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'}
{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
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>

View file

@ -20,10 +20,15 @@
// 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';
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;
@ -37,82 +42,99 @@ export interface LiveStreamHandle {
clear: () => void;
}
export const LiveStream = forwardRef<LiveStreamHandle, object>(function LiveStream(_props, ref) {
const { rows, hasMore, loadingMore, loadMore, pushLocalNote, clearLocal } = useLiveStream();
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);
export const LiveStream = forwardRef<LiveStreamHandle, object>(
function LiveStream(_props, ref) {
const { rows, hasMore, loadingMore, loadMore, pushLocalNote, clearLocal } =
useLiveStream();
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;
function isNearBottom(el: HTMLDivElement): boolean {
return el.scrollHeight - el.scrollTop - el.clientHeight <= NEAR_BOTTOM_PX;
}
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'}
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>
)}
{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>
);
});
);
},
);

View file

@ -16,10 +16,14 @@
// 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';
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);
@ -32,7 +36,7 @@ function LoginIdle({ onStarted }: { onStarted: () => void }) {
postLoginStart().then((r) => {
setBusy(false);
if (r.ok) onStarted();
else setError(r.detail ?? 'failed');
else setError(r.detail ?? "failed");
});
}
@ -40,8 +44,8 @@ function LoginIdle({ onStarted }: { onStarted: () => void }) {
<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.
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}>
@ -50,15 +54,22 @@ function LoginIdle({ onStarted }: { onStarted: () => void }) {
</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.
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('');
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);
@ -75,7 +86,7 @@ function LoginInProgress({ session, onDone }: { session: SessionView | null; onD
// nothing local to update here either way.
postLoginCode(code).then((r) => {
setBusy(false);
if (!r.ok) setError(r.detail ?? 'failed');
if (!r.ok) setError(r.detail ?? "failed");
});
}
@ -94,23 +105,26 @@ function LoginInProgress({ session, onDone }: { session: SessionView | null; onD
{session?.url ? (
<>
<p class="login-url">
▶{' '}
▶{" "}
<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.
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>
<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'}
type={reveal ? "text" : "password"}
placeholder="paste OAuth code here (hidden)"
required
autocomplete="one-time-code"
@ -133,19 +147,20 @@ function LoginInProgress({ session, onDone }: { session: SessionView | null; onD
</button>
</form>
) : null}
<form style={{ marginTop: '0.4em' }} onSubmit={cancel}>
<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.
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>
<pre class="diff">{session?.output || ""}</pre>
</div>
);
}
@ -155,13 +170,13 @@ export function LoginFlow({
session,
onRefresh,
}: {
status: 'needs_login_idle' | 'needs_login_in_progress';
status: "needs_login_idle" | "needs_login_in_progress";
session: SessionView | null;
onRefresh: () => void;
}) {
return (
<div class="agent-status-overlay">
{status === 'needs_login_idle' ? (
{status === "needs_login_idle" ? (
<LoginIdle onStarted={onRefresh} />
) : (
<LoginInProgress session={session} onDone={onRefresh} />

View file

@ -21,11 +21,11 @@
// matching its `.ui-dropdown` visual values exactly (see MetaNav.css) —
// the same "reuse the values, not the component" call `LoginFlow`'s
// `.login-card` makes for the same reason.
import { useEffect, useRef, useState } from 'preact/hooks';
import { Badge } from '@hive/shared/badge.js';
import { LinkIcon } from '@hive/shared/icons.js';
import type { AgentLink } from '../types.js';
import './MetaNav.css';
import { useEffect, useRef, useState } from "preact/hooks";
import { Badge } from "@hive/shared/badge.js";
import { LinkIcon } from "@hive/shared/icons.js";
import type { AgentLink } from "../types.js";
import "./MetaNav.css";
export interface MetaNavProps {
links: AgentLink[];
@ -35,7 +35,11 @@ export interface MetaNavProps {
dashboardBase: string;
}
export function MetaNav({ links, forgePublicUrl, dashboardBase }: MetaNavProps) {
export function MetaNav({
links,
forgePublicUrl,
dashboardBase,
}: MetaNavProps) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
@ -43,16 +47,21 @@ export function MetaNav({ links, forgePublicUrl, dashboardBase }: MetaNavProps)
useEffect(() => {
if (!open) return;
function onPointerDown(e: PointerEvent) {
if (rootRef.current && e.target instanceof Node && !rootRef.current.contains(e.target)) setOpen(false);
if (
rootRef.current &&
e.target instanceof Node &&
!rootRef.current.contains(e.target)
)
setOpen(false);
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false);
if (e.key === "Escape") setOpen(false);
}
document.addEventListener('pointerdown', onPointerDown, true);
document.addEventListener('keydown', onKeyDown);
document.addEventListener("pointerdown", onPointerDown, true);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener('pointerdown', onPointerDown, true);
document.removeEventListener('keydown', onKeyDown);
document.removeEventListener("pointerdown", onPointerDown, true);
document.removeEventListener("keydown", onKeyDown);
};
}, [open]);
@ -60,7 +69,7 @@ export function MetaNav({ links, forgePublicUrl, dashboardBase }: MetaNavProps)
// 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);
const visible = links.filter((lnk) => lnk.kind !== "forge" || forgePublicUrl);
// No early-return-on-empty: the dashboard link below is always
// present, so the trigger always has at least one item.
@ -86,7 +95,8 @@ export function MetaNav({ links, forgePublicUrl, dashboardBase }: MetaNavProps)
🧭 dashboard
</a>
{visible.map((lnk) => {
const href = lnk.kind === 'forge' ? `${forgePublicUrl}${lnk.url}` : lnk.url;
const href =
lnk.kind === "forge" ? `${forgePublicUrl}${lnk.url}` : lnk.url;
return (
<a
key={lnk.url}
@ -97,9 +107,7 @@ export function MetaNav({ links, forgePublicUrl, dashboardBase }: MetaNavProps)
role="menuitem"
onClick={() => setOpen(false)}
>
{lnk.icon ? (
<span aria-hidden="true">{lnk.icon}</span>
) : null}
{lnk.icon ? <span aria-hidden="true">{lnk.icon}</span> : null}
{lnk.label}
</a>
);

View file

@ -6,11 +6,11 @@
// a body is an expandable details row gated by the operator's
// expand-tool-output preference. No separate classification step —
// mara: "StreamRow should now match what the server sends in TermMsg."
import { useEffect, useRef } from 'preact/hooks';
import type { TermRow } from '../lib/termMsg.js';
import { linkifyToNodes } from '../lib/linkify.js';
import { renderMarkdown } from '../lib/markdown.js';
import { getExpandDetailsPref } from '@hive/shared/prefs.js';
import { useEffect, useRef } from "preact/hooks";
import type { TermRow } from "../lib/termMsg.js";
import { linkifyToNodes } from "../lib/linkify.js";
import { renderMarkdown } from "../lib/markdown.js";
import { getExpandDetailsPref } from "@hive/shared/prefs.js";
function MarkdownBody({ text }: { text: string }) {
const ref = useRef<HTMLDivElement>(null);
@ -18,25 +18,31 @@ function MarkdownBody({ text }: { text: string }) {
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');
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 }} />;
return (
<div className="md" ref={ref} dangerouslySetInnerHTML={{ __html: html }} />
);
}
function DiffBody({ text }: { text: string }) {
const lines = String(text).split('\n');
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';
const cls = line.startsWith("+ ")
? "diff-add"
: line.startsWith("- ")
? "diff-del"
: "diff-ctx";
return (
<span key={i} className={cls}>
{line}
{'\n'}
{"\n"}
</span>
);
})}
@ -45,8 +51,10 @@ function DiffBody({ text }: { text: string }) {
}
export function Row({ row }: { row: TermRow }) {
const cssClass = 'level-' + row.level;
const icon = row.icon != null && row.icon !== '' && <span className="row-glyph">{row.icon}</span>;
const cssClass = "level-" + row.level;
const icon = row.icon != null && row.icon !== "" && (
<span className="row-glyph">{row.icon}</span>
);
if (row.body == null) {
return (
@ -59,7 +67,7 @@ export function Row({ row }: { row: TermRow }) {
// Empty summary + markdown body → the body itself is the whole row
// (assistant text), no summary prefix line, never collapsible.
if (row.body_format === 'markdown' && row.summary === '') {
if (row.body_format === "markdown" && row.summary === "") {
return (
<div className={`row ${cssClass}`}>
{icon}
@ -69,14 +77,19 @@ export function Row({ row }: { row: TermRow }) {
}
return (
<details className={`row ${cssClass}`} open={getExpandDetailsPref() || undefined}>
<details
className={`row ${cssClass}`}
open={getExpandDetailsPref() || undefined}
>
<summary>
{icon}
<span className="summary-text">{row.summary}</span>
</summary>
{row.body_format === 'diff' && <DiffBody text={row.body} />}
{row.body_format === 'markdown' && <MarkdownBody text={row.body} />}
{row.body_format == null && <pre className="tool-body">{linkifyToNodes(row.body)}</pre>}
{row.body_format === "diff" && <DiffBody text={row.body} />}
{row.body_format === "markdown" && <MarkdownBody text={row.body} />}
{row.body_format == null && (
<pre className="tool-body">{linkifyToNodes(row.body)}</pre>
)}
</details>
);
}

View file

@ -11,9 +11,9 @@
// 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';
import { useEffect } from "preact/hooks";
import type { ComponentChildren } from "preact";
import "./SidePanel.css";
export interface SidePanelProps {
open: boolean;
@ -26,17 +26,21 @@ export function SidePanel({ open, title, onClose, children }: SidePanelProps) {
useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
if (e.key === "Escape") onClose();
}
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
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" />
<div
class={`agent-side-panel-backdrop${open ? " open" : ""}`}
onClick={onClose}
aria-hidden="true"
/>
<aside
class={`agent-side-panel-drawer${open ? ' open' : ''}`}
class={`agent-side-panel-drawer${open ? " open" : ""}`}
role="dialog"
aria-modal="true"
aria-hidden={!open}
@ -44,7 +48,12 @@ export function SidePanel({ open, title, onClose, children }: SidePanelProps) {
>
<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
type="button"
class="agent-side-panel-close"
title="close (esc)"
onClick={onClose}
>
✕
</button>
</div>

View file

@ -10,10 +10,10 @@
// selection state live in the caller (`Root.tsx`, via the
// `useAgentState` hook), so this component can still be demoed and
// reviewed against plain sample data independent of live `/api/state`.
import { useRef, 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';
import { useRef, 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 {
statusLabel: string;
@ -40,16 +40,16 @@ export interface StatusChipsProps {
}
const MODEL_DESCRIPTIONS: Record<string, string> = {
haiku: 'fast',
sonnet: 'balanced',
opus: 'powerful',
haiku: "fast",
sonnet: "balanced",
opus: "powerful",
};
const EFFORT_DESCRIPTIONS: Record<string, string> = {
low: '',
medium: 'default',
high: '',
xhigh: '',
max: '',
low: "",
medium: "default",
high: "",
xhigh: "",
max: "",
};
function Picker({
@ -76,7 +76,13 @@ function Picker({
}));
return (
<div class="status-chip-anchor" ref={anchorRef}>
<Badge label={label} value={value} onClick={() => setOpen((o) => !o)} expanded={open} title={title} />
<Badge
label={label}
value={value}
onClick={() => setOpen((o) => !o)}
expanded={open}
title={title}
/>
<Dropdown
open={open}
options={dropdownOptions}
@ -110,7 +116,13 @@ function StatusMenu({
onCancelTurn,
}: Pick<
StatusChipsProps,
'statusLabel' | 'statusTone' | 'statusTooltip' | 'paused' | 'onTogglePause' | 'thinking' | 'onCancelTurn'
| "statusLabel"
| "statusTone"
| "statusTooltip"
| "paused"
| "onTogglePause"
| "thinking"
| "onCancelTurn"
>) {
const [open, setOpen] = useState(false);
const [confirmCancel, setConfirmCancel] = useState(false);
@ -121,11 +133,13 @@ function StatusMenu({
setConfirmCancel(false);
}
const options: DropdownOption[] = [{ value: 'toggle-pause', label: paused ? '▶ resume' : '⏸ pause' }];
const options: DropdownOption[] = [
{ value: "toggle-pause", label: paused ? "▶ resume" : "⏸ pause" },
];
if (thinking) {
options.push({
value: 'cancel-turn',
label: confirmCancel ? '■ click again to confirm' : '■ cancel turn',
value: "cancel-turn",
label: confirmCancel ? "■ click again to confirm" : "■ cancel turn",
danger: true,
});
}
@ -144,7 +158,7 @@ function StatusMenu({
options={options}
label="agent status"
onSelect={(value) => {
if (value === 'toggle-pause') {
if (value === "toggle-pause") {
onTogglePause();
close();
return;
@ -225,7 +239,9 @@ export function StatusChips({
title="cumulative tokens billed across the last turn (sum across every inference; tool-heavy turns rebill the cached prompt per call)"
/>
) : null}
{lastTurnLabel ? <span class="status-chips-last-turn">{lastTurnLabel}</span> : null}
{lastTurnLabel ? (
<span class="status-chips-last-turn">{lastTurnLabel}</span>
) : null}
</div>
);
}

View file

@ -10,9 +10,15 @@
// a text-input flow anyway. Instead: typing the command once arms it
// (a local note explains what confirming does), typing it again within
// the same "armed" state fires it — a keyboard-native two-step confirm.
import { useRef, useState } from 'preact/hooks';
import { postModel, postEffort } from '../lib/modelEffort.js';
import { postCancelTurn, postCompact, postNewSession, postLogout, postSend } from '../lib/termActions.js';
import { useRef, useState } from "preact/hooks";
import { postModel, postEffort } from "../lib/modelEffort.js";
import {
postCancelTurn,
postCompact,
postNewSession,
postLogout,
postSend,
} from "../lib/termActions.js";
export interface TermInputProps {
label: string;
@ -22,14 +28,26 @@ export interface TermInputProps {
}
const SLASH_COMMANDS: { name: string; desc: string }[] = [
{ name: '/help', desc: 'list slash commands' },
{ name: '/clear', desc: 'wipe the terminal panel (local-only)' },
{ name: '/cancel', desc: 'SIGINT the in-flight claude turn' },
{ name: '/compact', desc: 'compact the persistent claude session' },
{ name: '/model', desc: '/model <name> — switch claude model for future turns' },
{ name: '/effort', desc: '/effort <level> — set claude effort (medium/high/xhigh) for future turns' },
{ name: '/new-session', desc: 'fresh claude session next turn (type twice to confirm)' },
{ name: '/logout', desc: 'rotate OAuth creds + park in needs-login (type twice to confirm)' },
{ name: "/help", desc: "list slash commands" },
{ name: "/clear", desc: "wipe the terminal panel (local-only)" },
{ name: "/cancel", desc: "SIGINT the in-flight claude turn" },
{ name: "/compact", desc: "compact the persistent claude session" },
{
name: "/model",
desc: "/model <name> — switch claude model for future turns",
},
{
name: "/effort",
desc: "/effort <level> — set claude effort (medium/high/xhigh) for future turns",
},
{
name: "/new-session",
desc: "fresh claude session next turn (type twice to confirm)",
},
{
name: "/logout",
desc: "rotate OAuth creds + park in needs-login (type twice to confirm)",
},
];
const MAX_PX = 12 * 16; // ~8 lines @ 1.5 line-height, 1em base
@ -41,8 +59,13 @@ function completeSlash(prefix: string): string | null {
return matches[(idx + 1) % matches.length].name;
}
export function TermInput({ label, online, onLocalNote, onClear }: TermInputProps) {
const [value, setValue] = useState('');
export function TermInput({
label,
online,
onLocalNote,
onClear,
}: TermInputProps) {
const [value, setValue] = useState("");
const taRef = useRef<HTMLTextAreaElement>(null);
// Which destructive command is currently "armed" (typed once, waiting
// for the confirming repeat) — any other command clears it.
@ -51,59 +74,82 @@ export function TermInput({ label, online, onLocalNote, onClear }: TermInputProp
function grow() {
const ta = taRef.current;
if (!ta) return;
ta.style.height = 'auto';
ta.style.height = Math.min(ta.scrollHeight, MAX_PX) + 'px';
ta.style.height = "auto";
ta.style.height = Math.min(ta.scrollHeight, MAX_PX) + "px";
}
function reportFailure(label_: string, detail?: string) {
onLocalNote(`✗ ${label_} failed${detail ? ': ' + detail : ''}`);
onLocalNote(`✗ ${label_} failed${detail ? ": " + detail : ""}`);
}
function handleSlash(line: string): boolean {
const trimmed = line.trim();
const [cmd, ...rest] = trimmed.split(/\s+/);
const arg = rest.join(' ');
const arg = rest.join(" ");
const wasArmed = armedRef.current === cmd;
armedRef.current = null;
switch (cmd) {
case '/help':
onLocalNote('/help');
for (const c of SLASH_COMMANDS) onLocalNote(` ${c.name.padEnd(13)} — ${c.desc}`);
case "/help":
onLocalNote("/help");
for (const c of SLASH_COMMANDS)
onLocalNote(` ${c.name.padEnd(13)} — ${c.desc}`);
return true;
case '/clear':
case "/clear":
onClear();
onLocalNote('· terminal cleared (local view only — server history kept)');
onLocalNote(
"· terminal cleared (local view only — server history kept)",
);
return true;
case '/cancel':
postCancelTurn().then((r) => !r.ok && reportFailure('/cancel', r.detail));
case "/cancel":
postCancelTurn().then(
(r) => !r.ok && reportFailure("/cancel", r.detail),
);
return true;
case '/compact':
postCompact().then((r) => !r.ok && reportFailure('/compact', r.detail));
case "/compact":
postCompact().then((r) => !r.ok && reportFailure("/compact", r.detail));
return true;
case '/new-session':
case "/new-session":
if (wasArmed) {
postNewSession().then((r) => !r.ok && reportFailure('/new-session', r.detail));
postNewSession().then(
(r) => !r.ok && reportFailure("/new-session", r.detail),
);
} else {
armedRef.current = '/new-session';
onLocalNote('⚠ drops all prior --continue context. type /new-session again to confirm.');
armedRef.current = "/new-session";
onLocalNote(
"⚠ drops all prior --continue context. type /new-session again to confirm.",
);
}
return true;
case '/logout':
case "/logout":
if (wasArmed) {
postLogout().then((r) => !r.ok && reportFailure('/logout', r.detail));
postLogout().then((r) => !r.ok && reportFailure("/logout", r.detail));
} else {
armedRef.current = '/logout';
onLocalNote('⚠ SIGINTs the current turn + rotates OAuth credentials. type /logout again to confirm.');
armedRef.current = "/logout";
onLocalNote(
"⚠ SIGINTs the current turn + rotates OAuth credentials. type /logout again to confirm.",
);
}
return true;
case '/model':
if (!arg) onLocalNote('✗ /model needs a name (e.g. /model haiku, /model sonnet, /model opus)');
else postModel(arg).then((r) => !r.ok && reportFailure('/model', r.detail));
case "/model":
if (!arg)
onLocalNote(
"✗ /model needs a name (e.g. /model haiku, /model sonnet, /model opus)",
);
else
postModel(arg).then(
(r) => !r.ok && reportFailure("/model", r.detail),
);
return true;
case '/effort':
if (!arg) onLocalNote('✗ /effort needs a level (e.g. /effort medium, /effort high, /effort xhigh)');
else postEffort(arg).then((r) => !r.ok && reportFailure('/effort', r.detail));
case "/effort":
if (!arg)
onLocalNote(
"✗ /effort needs a level (e.g. /effort medium, /effort high, /effort xhigh)",
);
else
postEffort(arg).then(
(r) => !r.ok && reportFailure("/effort", r.detail),
);
return true;
default:
onLocalNote(`✗ unknown slash command: ${cmd} — try /help`);
@ -114,14 +160,14 @@ export function TermInput({ label, online, onLocalNote, onClear }: TermInputProp
function submit() {
const line = value;
if (!line.trim()) return;
setValue('');
setValue("");
requestAnimationFrame(grow);
if (line.trim().startsWith('/')) {
if (line.trim().startsWith("/")) {
handleSlash(line);
return;
}
armedRef.current = null;
postSend(line).then((r) => !r.ok && reportFailure('send', r.detail));
postSend(line).then((r) => !r.ok && reportFailure("send", r.detail));
}
function onInput(e: Event) {
@ -130,7 +176,7 @@ export function TermInput({ label, online, onLocalNote, onClear }: TermInputProp
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Tab' && value.startsWith('/') && !value.includes(' ')) {
if (e.key === "Tab" && value.startsWith("/") && !value.includes(" ")) {
const next = completeSlash(value);
if (next) {
e.preventDefault();
@ -138,14 +184,14 @@ export function TermInput({ label, online, onLocalNote, onClear }: TermInputProp
}
return;
}
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
e.preventDefault();
submit();
}
}
return (
<div class={`term-input${online ? '' : ' disabled'}`}>
<div class={`term-input${online ? "" : " disabled"}`}>
<form
class="sendform-term"
onSubmit={(e) => {

View file

@ -2,8 +2,8 @@
// flyout. "mark done" POSTs to this agent's own backend
// (`api/todos/mark-done`, same-origin), unlike InboxPanel's cross-agent
// mark-all-read.
import { useState } from 'preact/hooks';
import type { TodoRow } from '../types.js';
import { useState } from "preact/hooks";
import type { TodoRow } from "../types.js";
export interface TodosPanelProps {
todos: TodoRow[];
@ -19,7 +19,7 @@ function fmtAge(seconds: number): string {
export function TodosPanel({ todos, onCleared }: TodosPanelProps) {
const [checked, setChecked] = useState<Set<number>>(new Set());
const [status, setStatus] = useState('');
const [status, setStatus] = useState("");
const [busy, setBusy] = useState(false);
function toggle(id: number) {
@ -35,15 +35,15 @@ export function TodosPanel({ todos, onCleared }: TodosPanelProps) {
const ids = [...checked];
if (!ids.length) return;
setBusy(true);
setStatus('marking…');
setStatus("marking…");
try {
const resp = await fetch('api/todos/mark-done', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `ids=${encodeURIComponent(ids.join(','))}`,
const resp = await fetch("api/todos/mark-done", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: `ids=${encodeURIComponent(ids.join(","))}`,
});
if (resp.ok) {
setStatus('✓ marked done');
setStatus("✓ marked done");
setChecked(new Set());
onCleared();
} else {
@ -57,7 +57,9 @@ export function TodosPanel({ todos, onCleared }: TodosPanelProps) {
}
if (!todos.length) {
return <p class="side-panel-empty">no todos — all subsystem queues are clear.</p>;
return (
<p class="side-panel-empty">no todos — all subsystem queues are clear.</p>
);
}
const allChecked = todos.length > 0 && todos.every((t) => checked.has(t.id));
@ -68,11 +70,18 @@ export function TodosPanel({ todos, onCleared }: TodosPanelProps) {
<button
type="button"
class="inbox-mark-all-btn"
onClick={() => setChecked(allChecked ? new Set() : new Set(todos.map((t) => t.id)))}
onClick={() =>
setChecked(allChecked ? new Set() : new Set(todos.map((t) => t.id)))
}
>
{allChecked ? 'select none' : 'select all'}
{allChecked ? "select none" : "select all"}
</button>
<button type="button" class="inbox-mark-all-btn" onClick={markDone} disabled={busy || checked.size === 0}>
<button
type="button"
class="inbox-mark-all-btn"
onClick={markDone}
disabled={busy || checked.size === 0}
>
✓ mark done
</button>
<span class="inbox-mark-status">{status}</span>
@ -89,10 +98,10 @@ export function TodosPanel({ todos, onCleared }: TodosPanelProps) {
class="todo-cb"
checked={checked.has(t.id)}
onChange={() => toggle(t.id)}
/>{' '}
/>{" "}
<label for={cbId} class="inbox-from">
{label}
</label>{' '}
</label>{" "}
<span class="inbox-ts">{fmtAge(t.age_seconds)} ago</span>
<div class="inbox-body">{t.summary}</div>
</li>

View file

@ -1,4 +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';
declare module "*.css";

View file

@ -5,8 +5,8 @@
// the UI derives from it, like the turn-state badge's elapsed-time text —
// goes stale until something proactively refetches; without this the
// only way back to a live reading was a full page reload.
import { useEffect, useRef, useState } from 'preact/hooks';
import type { AgentState } from '../types.js';
import { useEffect, useRef, useState } from "preact/hooks";
import type { AgentState } from "../types.js";
const POLL_MS = 4000;
const RETRY_MS = 5000;
@ -38,7 +38,7 @@ export function useAgentState(): UseAgentStateResult {
async function poll() {
try {
const resp = await fetch('api/state');
const resp = await fetch("api/state");
if (!resp.ok) throw new Error(`http ${resp.status}`);
const s = (await resp.json()) as AgentState;
if (stoppedRef.current) return;
@ -80,10 +80,10 @@ export function useAgentState(): UseAgentStateResult {
// component that calls this hook.
useEffect(() => {
function onVisible() {
if (document.visibilityState === 'visible') refresh();
if (document.visibilityState === "visible") refresh();
}
document.addEventListener('visibilitychange', onVisible);
return () => document.removeEventListener('visibilitychange', onVisible);
document.addEventListener("visibilitychange", onVisible);
return () => document.removeEventListener("visibilitychange", onVisible);
}, []);
return { state, error, refresh };

View file

@ -15,8 +15,8 @@
// initial history page, drop it from the buffer). `seq` alone is the
// whole dedup signal, see `TermEnvelope`'s doc in hive-agent's
// `web_ui/stream.rs`.
import { useEffect, useRef, useState } from 'preact/hooks';
import type { TermEnvelope, TermRow } from '../lib/termMsg.js';
import { useEffect, useRef, useState } from "preact/hooks";
import type { TermEnvelope, TermRow } from "../lib/termMsg.js";
export interface UseLiveStreamOptions {
historyUrl?: string;
@ -50,9 +50,11 @@ function appendRow(rows: TermRow[], row: TermRow): TermRow[] {
return [...rows, row];
}
export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamResult {
const historyUrl = opts.historyUrl ?? 'events/history';
const streamUrl = opts.streamUrl ?? 'events/stream';
export function useLiveStream(
opts: UseLiveStreamOptions = {},
): UseLiveStreamResult {
const historyUrl = opts.historyUrl ?? "events/history";
const streamUrl = opts.streamUrl ?? "events/stream";
const [rows, setRows] = useState<TermRow[]>([]);
const [hasMore, setHasMore] = useState(false);
@ -61,12 +63,16 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
const keySeqRef = useRef(0);
function nextKey(): string {
keySeqRef.current += 1;
return 'r' + keySeqRef.current;
return "r" + keySeqRef.current;
}
function toRows(env: TermEnvelope, fromHistory: boolean): TermRow[] {
return env.msgs.map((m) => ({ ...m, key: nextKey(), fromHistory }));
}
function appendEnvelope(rows: TermRow[], env: TermEnvelope, fromHistory: boolean): TermRow[] {
function appendEnvelope(
rows: TermRow[],
env: TermEnvelope,
fromHistory: boolean,
): TermRow[] {
let next = rows;
for (const row of toRows(env, fromHistory)) next = appendRow(next, row);
return next;
@ -89,9 +95,14 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
try {
env = JSON.parse(e.data);
} catch {
setRows((prev) => appendRow(prev, {
key: 'parse-err-' + Date.now(), level: 'warn', summary: '[parse err] ' + e.data, fromHistory: false,
}));
setRows((prev) =>
appendRow(prev, {
key: "parse-err-" + Date.now(),
level: "warn",
summary: "[parse err] " + e.data,
fromHistory: false,
}),
);
return;
}
if (!liveRef.current) {
@ -101,30 +112,54 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
pushLive(env);
};
es.onerror = () => {
setRows((prev) => appendRow(prev, {
key: 'conn-note', level: 'warn', fromHistory: false, coalesce_key: 'conn-status',
summary: es.readyState === 0 /* CONNECTING */ ? '[reconnecting…]' : '[disconnected]',
}));
setRows((prev) =>
appendRow(prev, {
key: "conn-note",
level: "warn",
fromHistory: false,
coalesce_key: "conn-status",
summary:
es.readyState === 0 /* CONNECTING */
? "[reconnecting…]"
: "[disconnected]",
}),
);
};
async function backfill() {
try {
const resp = await fetch(historyUrl);
if (!resp.ok) throw new Error('http ' + resp.status);
if (!resp.ok) throw new Error("http " + resp.status);
const body = await resp.json();
const events: TermEnvelope[] = Array.isArray(body) ? body : body.events || [];
const boundarySeq: number | null = Array.isArray(body) ? null : (body.seq ?? null);
const events: TermEnvelope[] = Array.isArray(body)
? body
: body.events || [];
const boundarySeq: number | null = Array.isArray(body)
? null
: (body.seq ?? null);
if (!Array.isArray(body)) {
setHasMore(!!body.has_more);
if (typeof body.min_id === 'number') minIdRef.current = body.min_id;
if (typeof body.min_id === "number") minIdRef.current = body.min_id;
}
if (cancelled) return;
let initial: TermRow[] = [];
for (const env of events) initial = appendEnvelope(initial, env, true);
initial = events.length
? appendRow(initial, { key: 'live-sep', level: 'debug', fromHistory: true, summary: '─── live (older above) ───' })
: [{ key: 'placeholder', level: 'debug', fromHistory: true, summary: '(connected — waiting for events)' }];
? appendRow(initial, {
key: "live-sep",
level: "debug",
fromHistory: true,
summary: "─── live (older above) ───",
})
: [
{
key: "placeholder",
level: "debug",
fromHistory: true,
summary: "(connected — waiting for events)",
},
];
setRows(initial);
const drained = bufferedRef.current;
@ -133,11 +168,16 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
for (const env of drained) {
// Already covered by the initial history page — drop it from
// the buffer rather than rendering it twice.
if (boundarySeq != null && typeof env.seq === 'number' && env.seq <= boundarySeq) continue;
if (
boundarySeq != null &&
typeof env.seq === "number" &&
env.seq <= boundarySeq
)
continue;
pushLive(env);
}
} catch (err) {
console.warn('history backfill failed', err);
console.warn("history backfill failed", err);
if (cancelled) return;
const drained = bufferedRef.current;
bufferedRef.current = [];
@ -157,30 +197,42 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
if (!hasMore || loadingMore || minIdRef.current == null) return;
setLoadingMore(true);
try {
const sep = historyUrl.includes('?') ? '&' : '?';
const resp = await fetch(historyUrl + sep + 'before=' + minIdRef.current);
const sep = historyUrl.includes("?") ? "&" : "?";
const resp = await fetch(historyUrl + sep + "before=" + minIdRef.current);
if (!resp.ok) return;
const body = await resp.json();
const events: TermEnvelope[] = Array.isArray(body) ? body : body.events || [];
const events: TermEnvelope[] = Array.isArray(body)
? body
: body.events || [];
setHasMore(!!body.has_more);
if (typeof body.min_id === 'number') minIdRef.current = body.min_id;
if (typeof body.min_id === "number") minIdRef.current = body.min_id;
if (events.length) {
let older: TermRow[] = [];
for (const env of events) older = appendEnvelope(older, env, true);
older = appendRow(older, {
key: 'older-sep-' + minIdRef.current, level: 'debug', fromHistory: true, summary: '─── older above ───',
key: "older-sep-" + minIdRef.current,
level: "debug",
fromHistory: true,
summary: "─── older above ───",
});
setRows((prev) => [...older, ...prev]);
}
} catch (err) {
console.warn('loadMore failed', err);
console.warn("loadMore failed", err);
} finally {
setLoadingMore(false);
}
}
function pushLocalNote(text: string) {
setRows((prev) => appendRow(prev, { key: 'local-' + nextKey(), level: 'info', fromHistory: false, summary: text }));
setRows((prev) =>
appendRow(prev, {
key: "local-" + nextKey(),
level: "info",
fromHistory: false,
summary: text,
}),
);
}
function clearLocal() {
setRows([]);

View file

@ -3,8 +3,8 @@
// useAgentState: todos change asynchronously (matrix syncs, bash task
// starts/completions) independent of the state snapshot, and the old
// page polled them independently too.
import { useEffect, useRef, useState } from 'preact/hooks';
import type { TodoRow } from '../types.js';
import { useEffect, useRef, useState } from "preact/hooks";
import type { TodoRow } from "../types.js";
const POLL_MS = 4000;
@ -20,16 +20,16 @@ export function useTodos(): UseTodosResult {
async function poll() {
try {
const resp = await fetch('api/todos');
const resp = await fetch("api/todos");
if (!resp.ok) throw new Error(`http ${resp.status}`);
const body = (await resp.json()) as { todos?: TodoRow[] };
if (stoppedRef.current) return;
// Defensive filter: the wire type is the general `LooseEnd` tagged
// enum even though this endpoint only ever emits the `todo` variant
// today — see types.ts's TodoRow comment.
setTodos((body.todos ?? []).filter((t) => t.kind === 'todo'));
setTodos((body.todos ?? []).filter((t) => t.kind === "todo"));
} catch (err) {
console.warn('todos fetch failed', err);
console.warn("todos fetch failed", err);
if (!stoppedRef.current) setTodos([]);
}
}

View file

@ -1,28 +1,27 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>hyperhive agent</title>
<link rel="icon" type="image/svg+xml" href="icon">
<link rel="stylesheet" href="static/colors.css">
<link rel="stylesheet" href="static/theme.css">
<link rel="stylesheet" href="static/agent.css">
<!-- main.css (bundled component CSS: Badge/Dropdown/LoginFlow/MetaNav/
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>hyperhive agent</title>
<link rel="icon" type="image/svg+xml" href="icon" />
<link rel="stylesheet" href="static/colors.css" />
<link rel="stylesheet" href="static/theme.css" />
<link rel="stylesheet" href="static/agent.css" />
<!-- main.css (bundled component CSS: Badge/Dropdown/LoginFlow/MetaNav/
StatusChips/SidePanel/...) MUST load after agent.css —
`.login-card`/`.meta-nav-popover`/etc. share class-selector
specificity with agent.css's legacy rules, so load order is what
decides which one wins the cascade (see LoginFlow.css/MetaNav.css
file comments). -->
<link rel="stylesheet" href="static/main.css">
</head>
<body class="agent-shell">
<!-- The Preact rewrite owns everything below — header, terminal,
<link rel="stylesheet" href="static/main.css" />
</head>
<body class="agent-shell">
<!-- The Preact rewrite owns everything below — header, terminal,
composer, side panel. See main.tsx/Root.tsx for the component
tree; docs/web-ui/agent.md for the design. -->
<div id="preact-root"></div>
<div id="preact-root"></div>
<script type="module" src="static/main.js" defer></script>
</body>
<script type="module" src="static/main.js" defer></script>
</body>
</html>

View file

@ -5,7 +5,7 @@
// `/agent/<name>/` on the dashboard's own origin (`location.origin`);
// accessed directly, it's the same host on `dashboardPort`.
export function resolveDashboardBase(dashboardPort: number): string {
return location.pathname.startsWith('/agent/')
? location.origin + '/'
return location.pathname.startsWith("/agent/")
? location.origin + "/"
: `${location.protocol}//${location.hostname}:${dashboardPort}/`;
}

Some files were not shown because too many files have changed in this diff Show more