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
35 changed files with 10090 additions and 6085 deletions
|
|
@ -21,33 +21,33 @@
|
|||
// `tower_http::ServeDir` fallback; the layout above keeps every URL
|
||||
// the HTML references reachable without rewriting paths.
|
||||
|
||||
import { build } from 'esbuild';
|
||||
import { mkdirSync, copyFileSync, rmSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { build } from "esbuild";
|
||||
import { mkdirSync, copyFileSync, rmSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const src = (p) => resolve(here, 'src', p);
|
||||
const dist = (p) => resolve(here, 'dist', p);
|
||||
const staticDir = (p) => resolve(here, 'dist', 'static', p);
|
||||
const src = (p) => resolve(here, "src", p);
|
||||
const dist = (p) => resolve(here, "dist", p);
|
||||
const staticDir = (p) => resolve(here, "dist", "static", p);
|
||||
|
||||
rmSync(dist(''), { recursive: true, force: true });
|
||||
mkdirSync(staticDir(''), { recursive: true });
|
||||
rmSync(dist(""), { recursive: true, force: true });
|
||||
mkdirSync(staticDir(""), { recursive: true });
|
||||
|
||||
// The stats page's own bundle — chart.js/auto, its own deps.
|
||||
await build({
|
||||
entryPoints: [src('stats.js')],
|
||||
outdir: staticDir(''),
|
||||
entryPoints: [src("stats.js")],
|
||||
outdir: staticDir(""),
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
platform: 'browser',
|
||||
target: ['es2022'],
|
||||
format: "esm",
|
||||
platform: "browser",
|
||||
target: ["es2022"],
|
||||
sourcemap: true,
|
||||
logLevel: 'info',
|
||||
logLevel: "info",
|
||||
// `@hive/shared/modal.js` imports its shadow-DOM component CSS as raw
|
||||
// text (see dashboard/build.mjs's matching comment) — same reasoning
|
||||
// applies here.
|
||||
loader: { '.css': 'text' },
|
||||
loader: { ".css": "text" },
|
||||
});
|
||||
|
||||
// The main agent page (hyperhive#3685's Preact rewrite — app.js,
|
||||
|
|
@ -58,17 +58,17 @@ await build({
|
|||
// modal.js`) and JSX transpilation, same split swarm-ui's own build
|
||||
// already makes.
|
||||
await build({
|
||||
entryPoints: [src('main.tsx')],
|
||||
outfile: staticDir('main.js'),
|
||||
entryPoints: [src("main.tsx")],
|
||||
outfile: staticDir("main.js"),
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
platform: 'browser',
|
||||
target: ['es2022'],
|
||||
format: "esm",
|
||||
platform: "browser",
|
||||
target: ["es2022"],
|
||||
sourcemap: true,
|
||||
logLevel: 'info',
|
||||
jsx: 'automatic',
|
||||
jsxImportSource: 'preact',
|
||||
loader: { '.css': 'css' },
|
||||
logLevel: "info",
|
||||
jsx: "automatic",
|
||||
jsxImportSource: "preact",
|
||||
loader: { ".css": "css" },
|
||||
});
|
||||
|
||||
// Bundle the CSS. `colors.css` re-exports the standalone base16 palette
|
||||
|
|
@ -76,18 +76,18 @@ await build({
|
|||
// it, no bundle rebuild); `theme.css` is the semantic derivation layer;
|
||||
// `agent.css`'s @import lines pull in shared base.css + terminal.css from
|
||||
// the @hive/shared workspace dep.
|
||||
for (const entry of ['colors.css', 'theme.css', 'agent.css']) {
|
||||
for (const entry of ["colors.css", "theme.css", "agent.css"]) {
|
||||
await build({
|
||||
entryPoints: [src(entry)],
|
||||
outfile: staticDir(entry),
|
||||
bundle: true,
|
||||
loader: { '.css': 'css' },
|
||||
logLevel: 'info',
|
||||
loader: { ".css": "css" },
|
||||
logLevel: "info",
|
||||
});
|
||||
}
|
||||
|
||||
for (const html of ['index.html', 'stats.html', 'screen.html']) {
|
||||
for (const html of ["index.html", "stats.html", "screen.html"]) {
|
||||
copyFileSync(src(html), dist(html));
|
||||
}
|
||||
|
||||
console.log('agent build ok →', dist(''));
|
||||
console.log("agent build ok →", dist(""));
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue