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:
parent
5d24bedd60
commit
39b95c2ede
203 changed files with 10090 additions and 6085 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
2
frontend/packages/agent/src/css.d.ts
vendored
2
frontend/packages/agent/src/css.d.ts
vendored
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}/`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,16 +2,16 @@
|
|||
// output shapes — this page's operators are used to reading them).
|
||||
|
||||
export function fmtTokens(n: number): string {
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
|
||||
if (n >= 1_000) return Math.round(n / 1000) + 'k';
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
|
||||
if (n >= 1_000) return Math.round(n / 1000) + "k";
|
||||
return String(n);
|
||||
}
|
||||
|
||||
export function fmtAge(ms: number): string {
|
||||
const s = Math.floor(ms / 1000);
|
||||
if (s < 60) return s + 's';
|
||||
if (s < 60) return s + "s";
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return m + 'm ' + (s % 60) + 's';
|
||||
if (m < 60) return m + "m " + (s % 60) + "s";
|
||||
const h = Math.floor(m / 60);
|
||||
return h + 'h ' + (m % 60) + 'm';
|
||||
return h + "h " + (m % 60) + "m";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,13 +3,15 @@
|
|||
// output there, JSX fragment here). Same regex + trailing-punctuation
|
||||
// strip; deliberately text-only, never innerHTML, so untrusted row text
|
||||
// (matrix-relayed bodies, tool args) can't inject markup this way.
|
||||
import type { JSX } from 'preact';
|
||||
import type { JSX } from "preact";
|
||||
|
||||
const LINKIFY_URL_RE = /https?:\/\/[^\s<>"']+/g;
|
||||
|
||||
export function linkifyToNodes(text: string | null | undefined): (string | JSX.Element)[] {
|
||||
const str = text == null ? '' : String(text);
|
||||
if (str.indexOf('://') === -1) return [str];
|
||||
export function linkifyToNodes(
|
||||
text: string | null | undefined,
|
||||
): (string | JSX.Element)[] {
|
||||
const str = text == null ? "" : String(text);
|
||||
if (str.indexOf("://") === -1) return [str];
|
||||
const out: (string | JSX.Element)[] = [];
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
|
|
@ -18,10 +20,10 @@ export function linkifyToNodes(text: string | null | undefined): (string | JSX.E
|
|||
while ((m = LINKIFY_URL_RE.exec(str)) !== null) {
|
||||
let url = m[0];
|
||||
const trail = url.match(/[.,;:!?)\]}'"]+$/);
|
||||
const tail = trail ? trail[0] : '';
|
||||
const tail = trail ? trail[0] : "";
|
||||
if (tail) url = url.slice(0, -tail.length);
|
||||
if (m.index > last) out.push(str.slice(last, m.index));
|
||||
if (!url.slice(url.indexOf('://') + 3)) {
|
||||
if (!url.slice(url.indexOf("://") + 3)) {
|
||||
// Nothing past the scheme — not a real URL, emit verbatim.
|
||||
out.push(m[0]);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -6,23 +6,38 @@
|
|||
// these three routes actually redirect today, but the same tolerant
|
||||
// check costs nothing and keeps every same-origin POST in this package
|
||||
// behaving identically.
|
||||
async function post(path: string, body?: URLSearchParams): Promise<{ ok: boolean; detail?: string }> {
|
||||
async function post(
|
||||
path: string,
|
||||
body?: URLSearchParams,
|
||||
): Promise<{ ok: boolean; detail?: string }> {
|
||||
try {
|
||||
const resp = await fetch(path, {
|
||||
method: 'POST',
|
||||
headers: body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : undefined,
|
||||
method: "POST",
|
||||
headers: body
|
||||
? { "Content-Type": "application/x-www-form-urlencoded" }
|
||||
: undefined,
|
||||
body,
|
||||
redirect: 'manual',
|
||||
redirect: "manual",
|
||||
});
|
||||
const ok = resp.ok || resp.type === 'opaqueredirect' || (resp.status >= 200 && resp.status < 400);
|
||||
const ok =
|
||||
resp.ok ||
|
||||
resp.type === "opaqueredirect" ||
|
||||
(resp.status >= 200 && resp.status < 400);
|
||||
if (ok) return { ok: true };
|
||||
const detail = await resp.text().catch(() => '');
|
||||
return { ok: false, detail: `http ${resp.status}${detail ? ' — ' + detail : ''}` };
|
||||
const detail = await resp.text().catch(() => "");
|
||||
return {
|
||||
ok: false,
|
||||
detail: `http ${resp.status}${detail ? " — " + detail : ""}`,
|
||||
};
|
||||
} catch (err) {
|
||||
return { ok: false, detail: err instanceof Error ? err.message : String(err) };
|
||||
return {
|
||||
ok: false,
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const postLoginStart = () => post('login/start');
|
||||
export const postLoginCode = (code: string) => post('login/code', new URLSearchParams({ code }));
|
||||
export const postLoginCancel = () => post('login/cancel');
|
||||
export const postLoginStart = () => post("login/start");
|
||||
export const postLoginCode = (code: string) =>
|
||||
post("login/code", new URLSearchParams({ code }));
|
||||
export const postLoginCancel = () => post("login/cancel");
|
||||
|
|
|
|||
|
|
@ -4,22 +4,27 @@
|
|||
// agent-authored files) — `marked` itself no longer sanitizes (v5+
|
||||
// dropped the built-in sanitizer), so every parse is run through
|
||||
// DOMPurify before it's ever handed to `dangerouslySetInnerHTML`.
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { marked } from "marked";
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
marked.setOptions({ breaks: true, gfm: true });
|
||||
|
||||
const ESCAPE_RE = /[&<>"]/g;
|
||||
const ESCAPE_MAP: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"' };
|
||||
const ESCAPE_MAP: Record<string, string> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
};
|
||||
|
||||
/** Render `text` as sanitized markdown HTML. Falls back to escaped plain
|
||||
* text if `marked` throws (mirrors app.js's try/catch fallback). */
|
||||
export function renderMarkdown(text: string | null | undefined): string {
|
||||
const src = String(text ?? '');
|
||||
const src = String(text ?? "");
|
||||
try {
|
||||
return DOMPurify.sanitize(marked.parse(src) as string);
|
||||
} catch (err) {
|
||||
console.warn('marked failed', err);
|
||||
console.warn("marked failed", err);
|
||||
return src.replace(ESCAPE_RE, (c) => ESCAPE_MAP[c] ?? c);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,22 +4,36 @@
|
|||
// ported from app.js unchanged — the endpoints don't actually redirect
|
||||
// today, but treating an opaque redirect as success costs nothing and
|
||||
// matches the existing contract exactly.
|
||||
async function post(path: string, field: string, value: string): Promise<{ ok: boolean; detail?: string }> {
|
||||
async function post(
|
||||
path: string,
|
||||
field: string,
|
||||
value: string,
|
||||
): Promise<{ ok: boolean; detail?: string }> {
|
||||
try {
|
||||
const resp = await fetch(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({ [field]: value }),
|
||||
redirect: 'manual',
|
||||
redirect: "manual",
|
||||
});
|
||||
const ok = resp.ok || resp.type === 'opaqueredirect' || (resp.status >= 200 && resp.status < 400);
|
||||
const ok =
|
||||
resp.ok ||
|
||||
resp.type === "opaqueredirect" ||
|
||||
(resp.status >= 200 && resp.status < 400);
|
||||
if (ok) return { ok: true };
|
||||
const detail = await resp.text().catch(() => '');
|
||||
return { ok: false, detail: `http ${resp.status}${detail ? ' — ' + detail : ''}` };
|
||||
const detail = await resp.text().catch(() => "");
|
||||
return {
|
||||
ok: false,
|
||||
detail: `http ${resp.status}${detail ? " — " + detail : ""}`,
|
||||
};
|
||||
} catch (err) {
|
||||
return { ok: false, detail: err instanceof Error ? err.message : String(err) };
|
||||
return {
|
||||
ok: false,
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const postModel = (name: string) => post('api/model', 'model', name);
|
||||
export const postEffort = (level: string) => post('api/effort', 'effort', level);
|
||||
export const postModel = (name: string) => post("api/model", "model", name);
|
||||
export const postEffort = (level: string) =>
|
||||
post("api/effort", "effort", level);
|
||||
|
|
|
|||
|
|
@ -14,10 +14,14 @@
|
|||
// navigates the whole page to whatever hive-c0re's endpoint returns —
|
||||
// here, the literal text "ok". Fire-and-forget `fetch` keeps the click
|
||||
// on the agent page.
|
||||
export async function submitPauseResume(dashboardBase: string, label: string, verb: 'pause' | 'resume'): Promise<void> {
|
||||
export async function submitPauseResume(
|
||||
dashboardBase: string,
|
||||
label: string,
|
||||
verb: "pause" | "resume",
|
||||
): Promise<void> {
|
||||
await fetch(`${dashboardBase}api/${verb}/${label}`, {
|
||||
method: 'POST',
|
||||
mode: 'no-cors',
|
||||
credentials: 'include',
|
||||
method: "POST",
|
||||
mode: "no-cors",
|
||||
credentials: "include",
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,40 @@
|
|||
// Same-origin fetch POST helpers for the term-input slash commands —
|
||||
// same shape as modelEffort.ts's `post()` ({ ok, detail } rather than
|
||||
// throwing), reused by TermInput.tsx.
|
||||
async function post(path: string, body?: URLSearchParams): Promise<{ ok: boolean; detail?: string }> {
|
||||
async function post(
|
||||
path: string,
|
||||
body?: URLSearchParams,
|
||||
): Promise<{ ok: boolean; detail?: string }> {
|
||||
try {
|
||||
const resp = await fetch(path, {
|
||||
method: 'POST',
|
||||
headers: body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : undefined,
|
||||
method: "POST",
|
||||
headers: body
|
||||
? { "Content-Type": "application/x-www-form-urlencoded" }
|
||||
: undefined,
|
||||
body,
|
||||
redirect: 'manual',
|
||||
redirect: "manual",
|
||||
});
|
||||
const ok = resp.ok || resp.type === 'opaqueredirect' || (resp.status >= 200 && resp.status < 400);
|
||||
const ok =
|
||||
resp.ok ||
|
||||
resp.type === "opaqueredirect" ||
|
||||
(resp.status >= 200 && resp.status < 400);
|
||||
if (ok) return { ok: true };
|
||||
const detail = await resp.text().catch(() => '');
|
||||
return { ok: false, detail: `http ${resp.status}${detail ? ' — ' + detail : ''}` };
|
||||
const detail = await resp.text().catch(() => "");
|
||||
return {
|
||||
ok: false,
|
||||
detail: `http ${resp.status}${detail ? " — " + detail : ""}`,
|
||||
};
|
||||
} catch (err) {
|
||||
return { ok: false, detail: err instanceof Error ? err.message : String(err) };
|
||||
return {
|
||||
ok: false,
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const postCancelTurn = () => post('api/cancel');
|
||||
export const postCompact = () => post('api/compact');
|
||||
export const postNewSession = () => post('api/new-session');
|
||||
export const postLogout = () => post('api/logout');
|
||||
export const postSend = (body: string) => post('send', new URLSearchParams({ body }));
|
||||
export const postCancelTurn = () => post("api/cancel");
|
||||
export const postCompact = () => post("api/compact");
|
||||
export const postNewSession = () => post("api/new-session");
|
||||
export const postLogout = () => post("api/logout");
|
||||
export const postSend = (body: string) =>
|
||||
post("send", new URLSearchParams({ body }));
|
||||
|
|
|
|||
|
|
@ -3,14 +3,14 @@
|
|||
// a `TermMsg` close to as-is (see components/Row.tsx); there's no
|
||||
// separate client-side row model or classification step any more —
|
||||
// mara: "StreamRow should now match what the server sends in TermMsg."
|
||||
export type Level = 'debug' | 'info' | 'warn' | 'error';
|
||||
export type Level = "debug" | "info" | "warn" | "error";
|
||||
|
||||
export interface TermMsg {
|
||||
icon?: string;
|
||||
level: Level;
|
||||
summary: string;
|
||||
body?: string;
|
||||
body_format?: 'markdown' | 'diff';
|
||||
body_format?: "markdown" | "diff";
|
||||
coalesce_key?: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
// the old widget family. First click arms the button (caller renders a
|
||||
// distinct "sure? click again" label off `armed`); a second click within
|
||||
// `resetMs` fires `onConfirm`; anything else (timeout, blur) disarms.
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
|
||||
export function useConfirmClick(onConfirm: () => void, resetMs = 2500) {
|
||||
const [armed, setArmed] = useState(false);
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@
|
|||
// Mounts to `#preact-root`, the only content `index.html`'s `<body>`
|
||||
// declares now — no more coexisting-with-legacy-markup sibling div,
|
||||
// that was only needed while both pages built in parallel.
|
||||
import { render } from 'preact';
|
||||
import { Root } from './Root.js';
|
||||
import { render } from "preact";
|
||||
import { Root } from "./Root.js";
|
||||
|
||||
const root = document.getElementById('preact-root');
|
||||
const root = document.getElementById("preact-root");
|
||||
if (root) render(<Root />, root);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,52 +1,84 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>hyperhive agent — stats</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">
|
||||
</head>
|
||||
<body>
|
||||
<header class="page-header">
|
||||
<a class="page-back" id="back-link" href="./">← live</a>
|
||||
<a class="page-back" id="dashboard-link" href="#">dashboard ↗</a>
|
||||
<span class="page-title" id="title">◆ … ◆</span>
|
||||
</header>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>hyperhive agent — stats</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" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="page-header">
|
||||
<a class="page-back" id="back-link" href="./">← live</a>
|
||||
<a class="page-back" id="dashboard-link" href="#">dashboard ↗</a>
|
||||
<span class="page-title" id="title">◆ … ◆</span>
|
||||
</header>
|
||||
|
||||
<div class="window-tabs" id="window-tabs" role="tablist">
|
||||
<button type="button" data-tab="1h">last 1h</button>
|
||||
<button type="button" data-tab="4h">last 4h</button>
|
||||
<button type="button" data-tab="24h">last 24h</button>
|
||||
<button type="button" data-tab="3d">last 3d</button>
|
||||
<button type="button" data-tab="7d">last 7d</button>
|
||||
<button type="button" data-tab="30d">last 30d</button>
|
||||
<button type="button" data-tab="all">all</button>
|
||||
</div>
|
||||
<div class="window-tabs" id="window-tabs" role="tablist">
|
||||
<button type="button" data-tab="1h">last 1h</button>
|
||||
<button type="button" data-tab="4h">last 4h</button>
|
||||
<button type="button" data-tab="24h">last 24h</button>
|
||||
<button type="button" data-tab="3d">last 3d</button>
|
||||
<button type="button" data-tab="7d">last 7d</button>
|
||||
<button type="button" data-tab="30d">last 30d</button>
|
||||
<button type="button" data-tab="all">all</button>
|
||||
</div>
|
||||
|
||||
<div class="summary" id="summary"></div>
|
||||
<div class="summary" id="summary"></div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stats-card wide"><h3>turns per bucket</h3><div class="chart-wrap"><canvas id="chart-turns"></canvas></div></div>
|
||||
<div class="stats-card wide"><h3>turn duration (ms) — p50 / p95 / avg</h3><div class="chart-wrap"><canvas id="chart-duration"></canvas></div></div>
|
||||
<div class="stats-card wide"><h3>context tokens (last inference per turn) — avg / max</h3><div class="chart-wrap"><canvas id="chart-ctx"></canvas></div></div>
|
||||
<div class="stats-card wide"><h3>token cost per bucket (sum across inferences)</h3><div class="chart-wrap"><canvas id="chart-cost"></canvas></div></div>
|
||||
<div class="stats-card wide"><h3>turns by model per bucket — model drives token cost</h3><div class="chart-wrap"><canvas id="chart-model"></canvas></div></div>
|
||||
<div class="stats-card"><h3>top tools</h3><div class="chart-wrap"><canvas id="chart-tools"></canvas></div></div>
|
||||
<!-- "favorite tools": most-run shell commands. Hidden until the
|
||||
<div class="stats-grid">
|
||||
<div class="stats-card wide">
|
||||
<h3>turns per bucket</h3>
|
||||
<div class="chart-wrap"><canvas id="chart-turns"></canvas></div>
|
||||
</div>
|
||||
<div class="stats-card wide">
|
||||
<h3>turn duration (ms) — p50 / p95 / avg</h3>
|
||||
<div class="chart-wrap"><canvas id="chart-duration"></canvas></div>
|
||||
</div>
|
||||
<div class="stats-card wide">
|
||||
<h3>context tokens (last inference per turn) — avg / max</h3>
|
||||
<div class="chart-wrap"><canvas id="chart-ctx"></canvas></div>
|
||||
</div>
|
||||
<div class="stats-card wide">
|
||||
<h3>token cost per bucket (sum across inferences)</h3>
|
||||
<div class="chart-wrap"><canvas id="chart-cost"></canvas></div>
|
||||
</div>
|
||||
<div class="stats-card wide">
|
||||
<h3>turns by model per bucket — model drives token cost</h3>
|
||||
<div class="chart-wrap"><canvas id="chart-model"></canvas></div>
|
||||
</div>
|
||||
<div class="stats-card">
|
||||
<h3>top tools</h3>
|
||||
<div class="chart-wrap"><canvas id="chart-tools"></canvas></div>
|
||||
</div>
|
||||
<!-- "favorite tools": most-run shell commands. Hidden until the
|
||||
bash_commands capture (hive-bash-daemon) has recorded data, so the
|
||||
card never shows a permanently-empty doughnut. -->
|
||||
<div class="stats-card" id="card-bash" hidden><h3>favorite tools (bash)</h3><div class="chart-wrap"><canvas id="chart-bash"></canvas></div></div>
|
||||
<div class="stats-card"><h3>wake source mix</h3><div class="chart-wrap"><canvas id="chart-wake"></canvas></div></div>
|
||||
<div class="stats-card"><h3>result mix</h3><div class="chart-wrap"><canvas id="chart-result"></canvas></div></div>
|
||||
<div class="stats-card wide"><h3>result trend per bucket — errors / rate-limits / compactions over time</h3><div class="chart-wrap"><canvas id="chart-result-trend"></canvas></div></div>
|
||||
</div>
|
||||
<div class="stats-card" id="card-bash" hidden>
|
||||
<h3>favorite tools (bash)</h3>
|
||||
<div class="chart-wrap"><canvas id="chart-bash"></canvas></div>
|
||||
</div>
|
||||
<div class="stats-card">
|
||||
<h3>wake source mix</h3>
|
||||
<div class="chart-wrap"><canvas id="chart-wake"></canvas></div>
|
||||
</div>
|
||||
<div class="stats-card">
|
||||
<h3>result mix</h3>
|
||||
<div class="chart-wrap"><canvas id="chart-result"></canvas></div>
|
||||
</div>
|
||||
<div class="stats-card wide">
|
||||
<h3>
|
||||
result trend per bucket — errors / rate-limits / compactions over time
|
||||
</h3>
|
||||
<div class="chart-wrap"><canvas id="chart-result-trend"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chart.js is now bundled into stats.js by esbuild (npm dep
|
||||
<!-- Chart.js is now bundled into stats.js by esbuild (npm dep
|
||||
chart.js@4.4.4), so the page works offline / on operator
|
||||
machines without internet egress. No SRI hash to maintain. -->
|
||||
<script type="module" src="static/stats.js" defer></script>
|
||||
</body>
|
||||
<script type="module" src="static/stats.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
// once on load, then /api/stats?window=... for the chart data — re-fetches
|
||||
// when the operator clicks a window tab.
|
||||
|
||||
import Chart from 'chart.js/auto';
|
||||
import { createTabStrip } from '@hive/shared/tabs.js';
|
||||
import Chart from "chart.js/auto";
|
||||
import { createTabStrip } from "@hive/shared/tabs.js";
|
||||
|
||||
// Expose for the IIFE below — pre-split this was a window global from
|
||||
// the jsDelivr CDN script tag. esbuild now bundles chart.js into
|
||||
|
|
@ -12,26 +12,36 @@ import { createTabStrip } from '@hive/shared/tabs.js';
|
|||
window.Chart = Chart;
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
"use strict";
|
||||
|
||||
const cssVar = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
const cssVar = (name) =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
const palette = {
|
||||
bg: cssVar('--bg'),
|
||||
bgElev: cssVar('--bg-elev'),
|
||||
fg: cssVar('--fg'),
|
||||
muted: cssVar('--muted'),
|
||||
purple: cssVar('--purple'),
|
||||
cyan: cssVar('--cyan'),
|
||||
pink: cssVar('--pink'),
|
||||
amber: cssVar('--amber'),
|
||||
green: cssVar('--green'),
|
||||
red: cssVar('--red'),
|
||||
border: cssVar('--border'),
|
||||
bg: cssVar("--bg"),
|
||||
bgElev: cssVar("--bg-elev"),
|
||||
fg: cssVar("--fg"),
|
||||
muted: cssVar("--muted"),
|
||||
purple: cssVar("--purple"),
|
||||
cyan: cssVar("--cyan"),
|
||||
pink: cssVar("--pink"),
|
||||
amber: cssVar("--amber"),
|
||||
green: cssVar("--green"),
|
||||
red: cssVar("--red"),
|
||||
border: cssVar("--border"),
|
||||
};
|
||||
// Distinct hues for categorical charts (top tools / wake mix / result mix).
|
||||
const wheel = [palette.purple, palette.cyan, palette.pink, palette.amber,
|
||||
palette.green, palette.red, '#94e2d5', '#f9e2af',
|
||||
'#74c7ec', '#b4befe'];
|
||||
const wheel = [
|
||||
palette.purple,
|
||||
palette.cyan,
|
||||
palette.pink,
|
||||
palette.amber,
|
||||
palette.green,
|
||||
palette.red,
|
||||
"#94e2d5",
|
||||
"#f9e2af",
|
||||
"#74c7ec",
|
||||
"#b4befe",
|
||||
];
|
||||
|
||||
// Apply Catppuccin defaults globally so each Chart inherits without per-call
|
||||
// overrides. Chart.js v4 reads these on chart construction.
|
||||
|
|
@ -42,16 +52,16 @@ window.Chart = Chart;
|
|||
Chart.defaults.plugins.legend.labels.color = palette.fg;
|
||||
|
||||
const charts = {};
|
||||
let currentWindow = '24h';
|
||||
let currentWindow = "24h";
|
||||
|
||||
function fmtMs(ms) {
|
||||
if (!Number.isFinite(ms) || ms <= 0) return '0';
|
||||
if (ms < 1000) return ms.toFixed(0) + 'ms';
|
||||
return (ms / 1000).toFixed(ms < 10000 ? 2 : 1) + 's';
|
||||
if (!Number.isFinite(ms) || ms <= 0) return "0";
|
||||
if (ms < 1000) return ms.toFixed(0) + "ms";
|
||||
return (ms / 1000).toFixed(ms < 10000 ? 2 : 1) + "s";
|
||||
}
|
||||
|
||||
function fmtInt(n) {
|
||||
if (!Number.isFinite(n)) return '0';
|
||||
if (!Number.isFinite(n)) return "0";
|
||||
return new Intl.NumberFormat().format(Math.round(n));
|
||||
}
|
||||
|
||||
|
|
@ -74,18 +84,21 @@ window.Chart = Chart;
|
|||
destroy(canvasId);
|
||||
const cv = document.getElementById(canvasId);
|
||||
if (!cv) return;
|
||||
const ctx = cv.getContext('2d');
|
||||
const ctx = cv.getContext("2d");
|
||||
ctx.clearRect(0, 0, cv.width, cv.height);
|
||||
ctx.fillStyle = palette.muted;
|
||||
ctx.font = '12px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.font = "12px monospace";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(msg, cv.width / 2, cv.height / 2);
|
||||
}
|
||||
|
||||
// Sum the four token streams across every bucket in the window.
|
||||
function tokenTotals(s) {
|
||||
let input = 0, output = 0, cacheRead = 0, cacheCreation = 0;
|
||||
let input = 0,
|
||||
output = 0,
|
||||
cacheRead = 0,
|
||||
cacheCreation = 0;
|
||||
for (const b of s.buckets || []) {
|
||||
input += b.input_tokens || 0;
|
||||
output += b.output_tokens || 0;
|
||||
|
|
@ -96,54 +109,58 @@ window.Chart = Chart;
|
|||
}
|
||||
|
||||
function renderSummary(s) {
|
||||
const root = document.getElementById('summary');
|
||||
const root = document.getElementById("summary");
|
||||
root.replaceChildren();
|
||||
const chips = [
|
||||
['turns', fmtInt(s.turn_count)],
|
||||
['avg duration', fmtMs(s.duration_summary.avg_ms)],
|
||||
['p50 duration', fmtMs(s.duration_summary.p50_ms)],
|
||||
['p95 duration', fmtMs(s.duration_summary.p95_ms)],
|
||||
['window', s.window],
|
||||
["turns", fmtInt(s.turn_count)],
|
||||
["avg duration", fmtMs(s.duration_summary.avg_ms)],
|
||||
["p50 duration", fmtMs(s.duration_summary.p50_ms)],
|
||||
["p95 duration", fmtMs(s.duration_summary.p95_ms)],
|
||||
["window", s.window],
|
||||
];
|
||||
// Token-efficiency chips: cache hit-rate (cached input vs all
|
||||
// input-side tokens) and average tokens billed per turn.
|
||||
const t = tokenTotals(s);
|
||||
const inputSide = t.input + t.cacheRead + t.cacheCreation;
|
||||
if (inputSide > 0) {
|
||||
chips.push(['cache hit-rate', (100 * t.cacheRead / inputSide).toFixed(1) + '%']);
|
||||
chips.push([
|
||||
"cache hit-rate",
|
||||
((100 * t.cacheRead) / inputSide).toFixed(1) + "%",
|
||||
]);
|
||||
}
|
||||
if (s.turn_count > 0) {
|
||||
const perTurn = (t.input + t.output + t.cacheRead + t.cacheCreation) / s.turn_count;
|
||||
chips.push(['tokens/turn', fmtInt(perTurn)]);
|
||||
const perTurn =
|
||||
(t.input + t.output + t.cacheRead + t.cacheCreation) / s.turn_count;
|
||||
chips.push(["tokens/turn", fmtInt(perTurn)]);
|
||||
}
|
||||
if (s.reminder_stats) {
|
||||
chips.push(
|
||||
['reminders scheduled', fmtInt(s.reminder_stats.scheduled)],
|
||||
['reminders delivered', fmtInt(s.reminder_stats.delivered)],
|
||||
['reminders pending', fmtInt(s.reminder_stats.pending)],
|
||||
["reminders scheduled", fmtInt(s.reminder_stats.scheduled)],
|
||||
["reminders delivered", fmtInt(s.reminder_stats.delivered)],
|
||||
["reminders pending", fmtInt(s.reminder_stats.pending)],
|
||||
);
|
||||
}
|
||||
// Session count: fresh claude sessions started in the window (each
|
||||
// new-session or auto-compaction-fallback mints one). Omitted until
|
||||
// the sessions table exists (older db).
|
||||
if (typeof s.session_count === 'number') {
|
||||
chips.push(['sessions', fmtInt(s.session_count)]);
|
||||
if (typeof s.session_count === "number") {
|
||||
chips.push(["sessions", fmtInt(s.session_count)]);
|
||||
}
|
||||
// First-turn ctx: input tokens of the most recent fresh session's
|
||||
// first turn — the cold system-prompt + CLAUDE.md cost, a sprawl
|
||||
// proxy. Omitted from the JSON (and so absent here) until the
|
||||
// per-session capture has data.
|
||||
if (typeof s.first_turn_ctx === 'number') {
|
||||
chips.push(['first-turn ctx', fmtInt(s.first_turn_ctx)]);
|
||||
if (typeof s.first_turn_ctx === "number") {
|
||||
chips.push(["first-turn ctx", fmtInt(s.first_turn_ctx)]);
|
||||
}
|
||||
for (const [label, value] of chips) {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'chip';
|
||||
const l = document.createElement('span');
|
||||
l.className = 'label';
|
||||
const chip = document.createElement("span");
|
||||
chip.className = "chip";
|
||||
const l = document.createElement("span");
|
||||
l.className = "label";
|
||||
l.textContent = label;
|
||||
const v = document.createElement('span');
|
||||
v.className = 'value';
|
||||
const v = document.createElement("span");
|
||||
v.className = "value";
|
||||
v.textContent = value;
|
||||
chip.append(l, v);
|
||||
root.append(chip);
|
||||
|
|
@ -151,54 +168,67 @@ window.Chart = Chart;
|
|||
}
|
||||
|
||||
function renderTurnsChart(s) {
|
||||
const id = 'chart-turns';
|
||||
const id = "chart-turns";
|
||||
destroy(id);
|
||||
const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds));
|
||||
const data = s.buckets.map((b) => b.turn_count);
|
||||
charts[id] = new Chart(document.getElementById(id), {
|
||||
type: 'bar',
|
||||
type: "bar",
|
||||
data: {
|
||||
labels,
|
||||
datasets: [{
|
||||
label: 'turns',
|
||||
data,
|
||||
backgroundColor: palette.purple,
|
||||
borderColor: palette.purple,
|
||||
borderWidth: 1,
|
||||
}],
|
||||
datasets: [
|
||||
{
|
||||
label: "turns",
|
||||
data,
|
||||
backgroundColor: palette.purple,
|
||||
borderColor: palette.purple,
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { grid: { color: palette.border } },
|
||||
y: { beginAtZero: true, grid: { color: palette.border }, ticks: { precision: 0 } },
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: { color: palette.border },
|
||||
ticks: { precision: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderDurationChart(s) {
|
||||
const id = 'chart-duration';
|
||||
const id = "chart-duration";
|
||||
destroy(id);
|
||||
const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds));
|
||||
const ds = (label, color, key) => ({
|
||||
label, data: s.buckets.map((b) => b[key]),
|
||||
borderColor: color, backgroundColor: color + '33',
|
||||
tension: 0.25, pointRadius: 0, borderWidth: 2, spanGaps: true,
|
||||
label,
|
||||
data: s.buckets.map((b) => b[key]),
|
||||
borderColor: color,
|
||||
backgroundColor: color + "33",
|
||||
tension: 0.25,
|
||||
pointRadius: 0,
|
||||
borderWidth: 2,
|
||||
spanGaps: true,
|
||||
});
|
||||
charts[id] = new Chart(document.getElementById(id), {
|
||||
type: 'line',
|
||||
type: "line",
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
ds('p50', palette.cyan, 'p50_duration_ms'),
|
||||
ds('p95', palette.pink, 'p95_duration_ms'),
|
||||
ds('avg', palette.amber, 'avg_duration_ms'),
|
||||
ds("p50", palette.cyan, "p50_duration_ms"),
|
||||
ds("p95", palette.pink, "p95_duration_ms"),
|
||||
ds("avg", palette.amber, "avg_duration_ms"),
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: { grid: { color: palette.border } },
|
||||
y: {
|
||||
|
|
@ -212,77 +242,108 @@ window.Chart = Chart;
|
|||
}
|
||||
|
||||
function renderCtxChart(s) {
|
||||
const id = 'chart-ctx';
|
||||
const id = "chart-ctx";
|
||||
destroy(id);
|
||||
const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds));
|
||||
charts[id] = new Chart(document.getElementById(id), {
|
||||
type: 'line',
|
||||
type: "line",
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'avg ctx',
|
||||
label: "avg ctx",
|
||||
data: s.buckets.map((b) => b.avg_ctx_tokens),
|
||||
borderColor: palette.cyan,
|
||||
backgroundColor: palette.cyan + '33',
|
||||
tension: 0.25, pointRadius: 0, borderWidth: 2, spanGaps: true,
|
||||
backgroundColor: palette.cyan + "33",
|
||||
tension: 0.25,
|
||||
pointRadius: 0,
|
||||
borderWidth: 2,
|
||||
spanGaps: true,
|
||||
},
|
||||
{
|
||||
label: 'max ctx',
|
||||
label: "max ctx",
|
||||
data: s.buckets.map((b) => b.max_ctx_tokens),
|
||||
borderColor: palette.amber,
|
||||
backgroundColor: palette.amber + '33',
|
||||
tension: 0.25, pointRadius: 0, borderWidth: 2, spanGaps: true,
|
||||
backgroundColor: palette.amber + "33",
|
||||
tension: 0.25,
|
||||
pointRadius: 0,
|
||||
borderWidth: 2,
|
||||
spanGaps: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: { grid: { color: palette.border } },
|
||||
y: { beginAtZero: true, grid: { color: palette.border }, ticks: { callback: (v) => fmtInt(v) } },
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: { color: palette.border },
|
||||
ticks: { callback: (v) => fmtInt(v) },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderCostChart(s) {
|
||||
const id = 'chart-cost';
|
||||
const id = "chart-cost";
|
||||
destroy(id);
|
||||
const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds));
|
||||
// Stacked bars: cache_read (cheap) / cache_creation / input / output.
|
||||
// Highlights "what's actually getting billed at full rate" vs cache hits.
|
||||
charts[id] = new Chart(document.getElementById(id), {
|
||||
type: 'bar',
|
||||
type: "bar",
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: 'cache_read', data: s.buckets.map((b) => b.cache_read_input_tokens),
|
||||
backgroundColor: palette.muted },
|
||||
{ label: 'cache_creation', data: s.buckets.map((b) => b.cache_creation_input_tokens),
|
||||
backgroundColor: palette.cyan },
|
||||
{ label: 'input', data: s.buckets.map((b) => b.input_tokens),
|
||||
backgroundColor: palette.amber },
|
||||
{ label: 'output', data: s.buckets.map((b) => b.output_tokens),
|
||||
backgroundColor: palette.pink },
|
||||
{
|
||||
label: "cache_read",
|
||||
data: s.buckets.map((b) => b.cache_read_input_tokens),
|
||||
backgroundColor: palette.muted,
|
||||
},
|
||||
{
|
||||
label: "cache_creation",
|
||||
data: s.buckets.map((b) => b.cache_creation_input_tokens),
|
||||
backgroundColor: palette.cyan,
|
||||
},
|
||||
{
|
||||
label: "input",
|
||||
data: s.buckets.map((b) => b.input_tokens),
|
||||
backgroundColor: palette.amber,
|
||||
},
|
||||
{
|
||||
label: "output",
|
||||
data: s.buckets.map((b) => b.output_tokens),
|
||||
backgroundColor: palette.pink,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: { stacked: true, grid: { color: palette.border } },
|
||||
y: { stacked: true, beginAtZero: true,
|
||||
grid: { color: palette.border }, ticks: { callback: (v) => fmtInt(v) } },
|
||||
y: {
|
||||
stacked: true,
|
||||
beginAtZero: true,
|
||||
grid: { color: palette.border },
|
||||
ticks: { callback: (v) => fmtInt(v) },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderModelChart(s) {
|
||||
const id = 'chart-model';
|
||||
const id = "chart-model";
|
||||
destroy(id);
|
||||
const models = s.models || [];
|
||||
if (!models.length) { paintEmpty(id, 'no turns in window'); return; }
|
||||
if (!models.length) {
|
||||
paintEmpty(id, "no turns in window");
|
||||
return;
|
||||
}
|
||||
const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds));
|
||||
// One stacked series per model. Model choice drives token cost,
|
||||
// so this lines up against the cost chart above it.
|
||||
|
|
@ -292,28 +353,36 @@ window.Chart = Chart;
|
|||
backgroundColor: wheel[i % wheel.length],
|
||||
}));
|
||||
charts[id] = new Chart(document.getElementById(id), {
|
||||
type: 'bar',
|
||||
type: "bar",
|
||||
data: { labels, datasets },
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { position: 'top', labels: { boxWidth: 12 } } },
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { position: "top", labels: { boxWidth: 12 } } },
|
||||
scales: {
|
||||
x: { stacked: true, grid: { color: palette.border } },
|
||||
y: { stacked: true, beginAtZero: true,
|
||||
grid: { color: palette.border }, ticks: { precision: 0 } },
|
||||
y: {
|
||||
stacked: true,
|
||||
beginAtZero: true,
|
||||
grid: { color: palette.border },
|
||||
ticks: { precision: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderResultTrendChart(s) {
|
||||
const id = 'chart-result-trend';
|
||||
const id = "chart-result-trend";
|
||||
destroy(id);
|
||||
// Series = the result kinds seen in the window (same order +
|
||||
// colours as the result-mix doughnut). One stacked bar series per
|
||||
// kind, so error / rate-limit / compaction spikes line up in time.
|
||||
const kinds = (s.result_mix || []).map((kc) => kc.key);
|
||||
if (!kinds.length) { paintEmpty(id, 'no results'); return; }
|
||||
if (!kinds.length) {
|
||||
paintEmpty(id, "no results");
|
||||
return;
|
||||
}
|
||||
const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds));
|
||||
const datasets = kinds.map((k, i) => ({
|
||||
label: k,
|
||||
|
|
@ -321,15 +390,20 @@ window.Chart = Chart;
|
|||
backgroundColor: wheel[i % wheel.length],
|
||||
}));
|
||||
charts[id] = new Chart(document.getElementById(id), {
|
||||
type: 'bar',
|
||||
type: "bar",
|
||||
data: { labels, datasets },
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { position: 'top', labels: { boxWidth: 12 } } },
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { position: "top", labels: { boxWidth: 12 } } },
|
||||
scales: {
|
||||
x: { stacked: true, grid: { color: palette.border } },
|
||||
y: { stacked: true, beginAtZero: true,
|
||||
grid: { color: palette.border }, ticks: { precision: 0 } },
|
||||
y: {
|
||||
stacked: true,
|
||||
beginAtZero: true,
|
||||
grid: { color: palette.border },
|
||||
ticks: { precision: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -345,11 +419,22 @@ window.Chart = Chart;
|
|||
const data = items.map((kc) => kc.count);
|
||||
const colors = items.map((_, i) => wheel[i % wheel.length]);
|
||||
charts[canvasId] = new Chart(document.getElementById(canvasId), {
|
||||
type: 'doughnut',
|
||||
data: { labels, datasets: [{ data, backgroundColor: colors, borderColor: palette.bg, borderWidth: 2 }] },
|
||||
type: "doughnut",
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
data,
|
||||
backgroundColor: colors,
|
||||
borderColor: palette.bg,
|
||||
borderWidth: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { position: 'right', labels: { boxWidth: 12 } } },
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { position: "right", labels: { boxWidth: 12 } } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -360,30 +445,30 @@ window.Chart = Chart;
|
|||
// doughnut. Runs independently of turn_count (a bash task is tied to
|
||||
// a turn, but we don't want to couple the two reads).
|
||||
function renderBashCard(s) {
|
||||
const card = document.getElementById('card-bash');
|
||||
const card = document.getElementById("card-bash");
|
||||
const items = s.bash_breakdown || [];
|
||||
if (!items.length) {
|
||||
if (card) card.hidden = true;
|
||||
destroy('chart-bash');
|
||||
destroy("chart-bash");
|
||||
return;
|
||||
}
|
||||
if (card) card.hidden = false;
|
||||
renderKeyCount('chart-bash', items, 'no bash commands');
|
||||
renderKeyCount("chart-bash", items, "no bash commands");
|
||||
}
|
||||
|
||||
function render(s) {
|
||||
renderSummary(s);
|
||||
renderBashCard(s);
|
||||
if (s.turn_count === 0) {
|
||||
paintEmpty('chart-turns', 'no turns in window');
|
||||
paintEmpty('chart-duration', 'no turns in window');
|
||||
paintEmpty('chart-ctx', 'no turns in window');
|
||||
paintEmpty('chart-cost', 'no turns in window');
|
||||
paintEmpty('chart-model', 'no turns in window');
|
||||
paintEmpty('chart-tools', 'no tool calls');
|
||||
paintEmpty('chart-wake', 'no wakes');
|
||||
paintEmpty('chart-result', 'no results');
|
||||
paintEmpty('chart-result-trend', 'no results');
|
||||
paintEmpty("chart-turns", "no turns in window");
|
||||
paintEmpty("chart-duration", "no turns in window");
|
||||
paintEmpty("chart-ctx", "no turns in window");
|
||||
paintEmpty("chart-cost", "no turns in window");
|
||||
paintEmpty("chart-model", "no turns in window");
|
||||
paintEmpty("chart-tools", "no tool calls");
|
||||
paintEmpty("chart-wake", "no wakes");
|
||||
paintEmpty("chart-result", "no results");
|
||||
paintEmpty("chart-result-trend", "no results");
|
||||
return;
|
||||
}
|
||||
renderTurnsChart(s);
|
||||
|
|
@ -391,51 +476,63 @@ window.Chart = Chart;
|
|||
renderCtxChart(s);
|
||||
renderCostChart(s);
|
||||
renderModelChart(s);
|
||||
renderKeyCount('chart-tools', s.tool_breakdown, 'no tool calls');
|
||||
renderKeyCount('chart-wake', s.wake_mix, 'no wakes');
|
||||
renderKeyCount('chart-result', s.result_mix, 'no results');
|
||||
renderKeyCount("chart-tools", s.tool_breakdown, "no tool calls");
|
||||
renderKeyCount("chart-wake", s.wake_mix, "no wakes");
|
||||
renderKeyCount("chart-result", s.result_mix, "no results");
|
||||
renderResultTrendChart(s);
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const resp = await fetch('api/stats?window=' + encodeURIComponent(currentWindow));
|
||||
if (!resp.ok) throw new Error('http ' + resp.status);
|
||||
const resp = await fetch(
|
||||
"api/stats?window=" + encodeURIComponent(currentWindow),
|
||||
);
|
||||
if (!resp.ok) throw new Error("http " + resp.status);
|
||||
const snap = await resp.json();
|
||||
render(snap);
|
||||
} catch (e) {
|
||||
document.getElementById('summary').textContent = 'stats fetch failed: ' + e;
|
||||
document.getElementById("summary").textContent =
|
||||
"stats fetch failed: " + e;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadIdentity() {
|
||||
try {
|
||||
const resp = await fetch('api/state');
|
||||
const resp = await fetch("api/state");
|
||||
if (!resp.ok) return;
|
||||
const s = await resp.json();
|
||||
document.title = 'stats · ' + s.label;
|
||||
document.getElementById('title').textContent = '◆ ' + s.label + ' ◆';
|
||||
const dl = document.getElementById('dashboard-link');
|
||||
document.title = "stats · " + s.label;
|
||||
document.getElementById("title").textContent = "◆ " + s.label + " ◆";
|
||||
const dl = document.getElementById("dashboard-link");
|
||||
// When accessed via hive-gateway the page lives at `/agent/<name>/`
|
||||
// on the same origin as the dashboard. Detect via path prefix
|
||||
// rather than the direct port (which is unreachable or wrong scheme
|
||||
// behind HTTPS TLS termination). The dashboard SPA lives at
|
||||
// `dashboard.html` — the `/` root now serves the H0M3 menu hub.
|
||||
dl.href = window.location.pathname.startsWith('/agent/')
|
||||
? window.location.origin + '/dashboard.html'
|
||||
: 'http://' + window.location.hostname + ':' + s.dashboard_port + '/dashboard.html';
|
||||
} catch (_) { /* non-fatal */ }
|
||||
dl.href = window.location.pathname.startsWith("/agent/")
|
||||
? window.location.origin + "/dashboard.html"
|
||||
: "http://" +
|
||||
window.location.hostname +
|
||||
":" +
|
||||
s.dashboard_port +
|
||||
"/dashboard.html";
|
||||
} catch (_) {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
loadIdentity();
|
||||
// The shared hash-routed tab strip drives the stat-window selector,
|
||||
// making it deep-linkable (#1h / #24h / …). onShow updates the
|
||||
// window + reloads; the strip's initial show fires onShow once, so
|
||||
// there's no separate loadStats() call here (avoids a double fetch).
|
||||
createTabStrip(document.getElementById('window-tabs'), {
|
||||
createTabStrip(document.getElementById("window-tabs"), {
|
||||
defaultId: currentWindow,
|
||||
onShow: (w) => { currentWindow = w; loadStats(); },
|
||||
onShow: (w) => {
|
||||
currentWindow = w;
|
||||
loadStats();
|
||||
},
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -9,7 +9,11 @@ export interface AgentState {
|
|||
label: string;
|
||||
qualified_label: string;
|
||||
dashboard_port: number;
|
||||
status: 'online' | 'rate_limited' | 'needs_login_idle' | 'needs_login_in_progress';
|
||||
status:
|
||||
| "online"
|
||||
| "rate_limited"
|
||||
| "needs_login_idle"
|
||||
| "needs_login_in_progress";
|
||||
session: SessionView | null;
|
||||
turn_state: string;
|
||||
turn_state_since: number;
|
||||
|
|
@ -38,7 +42,7 @@ export interface AgentLink {
|
|||
url: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
kind: 'container' | 'forge' | 'external';
|
||||
kind: "container" | "forge" | "external";
|
||||
}
|
||||
|
||||
// Mirrors `hive_agent::web_ui::state::SessionView` — populated only
|
||||
|
|
@ -72,7 +76,7 @@ export interface InboxRow {
|
|||
// the only variant `GET /api/todos` actually returns (a dynamic,
|
||||
// subsystem-pushed todo: matrix/bash/forge are the built-in producers).
|
||||
export interface TodoRow {
|
||||
kind: 'todo';
|
||||
kind: "todo";
|
||||
id: number;
|
||||
subsystem: string;
|
||||
subsystem_key?: string | null;
|
||||
|
|
|
|||
Loading…
Reference in a new issue