treefmt: apply prettier

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

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

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

View file

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

View file

@ -22,8 +22,8 @@
// tick, a real ResizeObserver feedback loop (confirmed live on the
// first pass of this fix). `--agent-header-h` stays the static floor;
// `--agent-header-real-h` is output-only.
import { useEffect, useRef } from 'preact/hooks';
import type { ComponentChildren } from 'preact';
import { useEffect, useRef } from "preact/hooks";
import type { ComponentChildren } from "preact";
// Icon 404s when the agent has no `hyperhive.icon` override (no
// bundled server-side default any more — see
@ -34,8 +34,8 @@ import type { ComponentChildren } from 'preact';
function handleIconError(e: Event) {
const img = e.currentTarget as HTMLImageElement;
if (img.dataset.fallback) return;
img.dataset.fallback = '1';
img.src = '/favicon.svg';
img.dataset.fallback = "1";
img.src = "/favicon.svg";
}
export interface HeaderProps {
@ -62,13 +62,16 @@ export function Header({ label, hiveLabel, children, pills }: HeaderProps) {
useEffect(() => {
const el = ref.current;
if (!el || typeof ResizeObserver === 'undefined') return;
if (!el || typeof ResizeObserver === "undefined") return;
// getBoundingClientRect() (not ResizeObserver's own contentRect) —
// we want the full visual box padding+border included, the thing
// content below the fixed header actually needs to clear, not the
// content-box-only measurement ResizeObserver's entry defaults to.
const ro = new ResizeObserver(() => {
document.documentElement.style.setProperty('--agent-header-real-h', `${el.getBoundingClientRect().height}px`);
document.documentElement.style.setProperty(
"--agent-header-real-h",
`${el.getBoundingClientRect().height}px`,
);
});
ro.observe(el);
return () => ro.disconnect();
@ -81,7 +84,9 @@ export function Header({ label, hiveLabel, children, pills }: HeaderProps) {
<div class="agent-header-row agent-header-title-row">
<h2 class="agent-header-title"> {label} </h2>
</div>
{hiveLabel ? <div class="agent-header-row agent-hive-row">{hiveLabel}</div> : null}
{hiveLabel ? (
<div class="agent-header-row agent-hive-row">{hiveLabel}</div>
) : null}
</div>
{children || pills ? (
<div class="agent-header-pills">

View file

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

View file

@ -7,9 +7,9 @@
// JSON, not a redirect; in the common gateway-fronted deployment
// `dashboardBase` already resolves to `location.origin` anyway (see
// dashboardBase.ts), so it's same-origin in practice.
import { useState } from 'preact/hooks';
import { useConfirmClick } from '../lib/useConfirmClick.js';
import type { InboxRow } from '../types.js';
import { useState } from "preact/hooks";
import { useConfirmClick } from "../lib/useConfirmClick.js";
import type { InboxRow } from "../types.js";
export interface InboxPanelProps {
rows: InboxRow[];
@ -19,24 +19,35 @@ export interface InboxPanelProps {
}
function fmtTs(unixSeconds: number): string {
return new Date(unixSeconds * 1000).toISOString().replace('T', ' ').slice(5, 19);
return new Date(unixSeconds * 1000)
.toISOString()
.replace("T", " ")
.slice(5, 19);
}
export function InboxPanel({ rows, label, dashboardBase, onCleared }: InboxPanelProps) {
const [status, setStatus] = useState('');
export function InboxPanel({
rows,
label,
dashboardBase,
onCleared,
}: InboxPanelProps) {
const [status, setStatus] = useState("");
const [busy, setBusy] = useState(false);
async function markAllRead() {
if (!dashboardBase || !label) {
setStatus('dashboard url / label unknown');
setStatus("dashboard url / label unknown");
return;
}
setBusy(true);
setStatus('clearing…');
setStatus("clearing…");
try {
const resp = await fetch(`${dashboardBase}api/agent/${encodeURIComponent(label)}/mark-all-read`, {
method: 'POST',
});
const resp = await fetch(
`${dashboardBase}api/agent/${encodeURIComponent(label)}/mark-all-read`,
{
method: "POST",
},
);
if (resp.ok) {
const data = await resp.json().catch(() => ({}));
const n = Number((data as { marked?: number }).marked) || 0;
@ -68,16 +79,23 @@ export function InboxPanel({ rows, label, dashboardBase, onCleared }: InboxPanel
disabled={busy}
title="mark every queued message for this agent as read — drains the host broker's pending + delivered-unacked rows. history shown here is the most-recent-N regardless of state, so the list itself stays visible."
>
{armed ? 'sure? click again' : '✓ mark all read'}
{armed ? "sure? click again" : "✓ mark all read"}
</button>
<span class="inbox-mark-status">{status}</span>
</div>
<ul>
{rows.map((m) => (
<li key={m.id} class={m.in_reply_to != null ? 'inbox-reply' : undefined}>
{m.in_reply_to != null && <span class="inbox-reply-tag"> reply · </span>}
<span class="inbox-ts">{fmtTs(m.at)}</span> <span class="inbox-from">{m.from}</span>{' '}
<span class="inbox-sep"></span> <span class="inbox-body">{m.body}</span>
<li
key={m.id}
class={m.in_reply_to != null ? "inbox-reply" : undefined}
>
{m.in_reply_to != null && (
<span class="inbox-reply-tag"> reply · </span>
)}
<span class="inbox-ts">{fmtTs(m.at)}</span>{" "}
<span class="inbox-from">{m.from}</span>{" "}
<span class="inbox-sep"></span>{" "}
<span class="inbox-body">{m.body}</span>
</li>
))}
</ul>

View file

@ -20,10 +20,15 @@
// unseen for an append. The old code inferred this from DOM
// mutation shape after the fact; here the caller already knows
// which one it did, so there's nothing to infer.
import { useLayoutEffect, useRef, useState, useImperativeHandle } from 'preact/hooks';
import { forwardRef } from 'preact/compat';
import { useLiveStream } from '../hooks/useLiveStream.js';
import { Row } from './Row.js';
import {
useLayoutEffect,
useRef,
useState,
useImperativeHandle,
} from "preact/hooks";
import { forwardRef } from "preact/compat";
import { useLiveStream } from "../hooks/useLiveStream.js";
import { Row } from "./Row.js";
const NEAR_BOTTOM_PX = 48;
const LOAD_MORE_SCROLL_PX = 80;
@ -37,82 +42,99 @@ export interface LiveStreamHandle {
clear: () => void;
}
export const LiveStream = forwardRef<LiveStreamHandle, object>(function LiveStream(_props, ref) {
const { rows, hasMore, loadingMore, loadMore, pushLocalNote, clearLocal } = useLiveStream();
useImperativeHandle(ref, () => ({ pushNote: pushLocalNote, clear: clearLocal }), [pushLocalNote, clearLocal]);
const logRef = useRef<HTMLDivElement>(null);
const [stickToBottom, setStickToBottom] = useState(true);
const [unseen, setUnseen] = useState(0);
const prevRowCount = useRef(0);
const prependingRef = useRef(false);
const preScrollHeightRef = useRef(0);
export const LiveStream = forwardRef<LiveStreamHandle, object>(
function LiveStream(_props, ref) {
const { rows, hasMore, loadingMore, loadMore, pushLocalNote, clearLocal } =
useLiveStream();
useImperativeHandle(
ref,
() => ({ pushNote: pushLocalNote, clear: clearLocal }),
[pushLocalNote, clearLocal],
);
const logRef = useRef<HTMLDivElement>(null);
const [stickToBottom, setStickToBottom] = useState(true);
const [unseen, setUnseen] = useState(0);
const prevRowCount = useRef(0);
const prependingRef = useRef(false);
const preScrollHeightRef = useRef(0);
function isNearBottom(el: HTMLDivElement): boolean {
return el.scrollHeight - el.scrollTop - el.clientHeight <= NEAR_BOTTOM_PX;
}
function handleScroll() {
const el = logRef.current;
if (!el) return;
const nearBottom = isNearBottom(el);
setStickToBottom(nearBottom);
if (nearBottom) setUnseen(0);
if (el.scrollTop <= LOAD_MORE_SCROLL_PX && hasMore && !loadingMore) startLoadMore();
}
function startLoadMore() {
const el = logRef.current;
if (!el || loadingMore || !hasMore) return;
prependingRef.current = true;
preScrollHeightRef.current = el.scrollHeight;
loadMore();
}
function jumpToBottom() {
const el = logRef.current;
if (el) el.scrollTop = el.scrollHeight - el.clientHeight;
setStickToBottom(true);
setUnseen(0);
}
// Runs after every commit that changed `rows` — i.e. after the browser
// has already laid out the new/coalesced content, so `scrollHeight`
// below is always current.
useLayoutEffect(() => {
const el = logRef.current;
if (!el) return;
const added = rows.length - prevRowCount.current;
prevRowCount.current = rows.length;
if (prependingRef.current) {
prependingRef.current = false;
el.scrollTop += el.scrollHeight - preScrollHeightRef.current;
return;
function isNearBottom(el: HTMLDivElement): boolean {
return el.scrollHeight - el.scrollTop - el.clientHeight <= NEAR_BOTTOM_PX;
}
if (stickToBottom) {
el.scrollTop = el.scrollHeight - el.clientHeight;
} else if (added > 0) {
setUnseen((n) => n + added);
}
}, [rows, stickToBottom]);
return (
<div className="terminal-wrap">
<div className="live terminal" ref={logRef} onScroll={handleScroll}>
{hasMore && (
<button type="button" className="load-more-pill" disabled={loadingMore} onClick={startLoadMore}>
{loadingMore ? '↑ loading…' : '↑ load older'}
function handleScroll() {
const el = logRef.current;
if (!el) return;
const nearBottom = isNearBottom(el);
setStickToBottom(nearBottom);
if (nearBottom) setUnseen(0);
if (el.scrollTop <= LOAD_MORE_SCROLL_PX && hasMore && !loadingMore)
startLoadMore();
}
function startLoadMore() {
const el = logRef.current;
if (!el || loadingMore || !hasMore) return;
prependingRef.current = true;
preScrollHeightRef.current = el.scrollHeight;
loadMore();
}
function jumpToBottom() {
const el = logRef.current;
if (el) el.scrollTop = el.scrollHeight - el.clientHeight;
setStickToBottom(true);
setUnseen(0);
}
// Runs after every commit that changed `rows` — i.e. after the browser
// has already laid out the new/coalesced content, so `scrollHeight`
// below is always current.
useLayoutEffect(() => {
const el = logRef.current;
if (!el) return;
const added = rows.length - prevRowCount.current;
prevRowCount.current = rows.length;
if (prependingRef.current) {
prependingRef.current = false;
el.scrollTop += el.scrollHeight - preScrollHeightRef.current;
return;
}
if (stickToBottom) {
el.scrollTop = el.scrollHeight - el.clientHeight;
} else if (added > 0) {
setUnseen((n) => n + added);
}
}, [rows, stickToBottom]);
return (
<div className="terminal-wrap">
<div className="live terminal" ref={logRef} onScroll={handleScroll}>
{hasMore && (
<button
type="button"
className="load-more-pill"
disabled={loadingMore}
onClick={startLoadMore}
>
{loadingMore ? "↑ loading…" : "↑ load older"}
</button>
)}
{rows.map((row) => (
<Row key={row.key} row={row} />
))}
</div>
{unseen > 0 && (
<button
type="button"
className="tail-pill visible"
onClick={jumpToBottom}
>
{unseen} new
</button>
)}
{rows.map((row) => (
<Row key={row.key} row={row} />
))}
</div>
{unseen > 0 && (
<button type="button" className="tail-pill visible" onClick={jumpToBottom}>
{unseen} new
</button>
)}
</div>
);
});
);
},
);

View file

@ -16,10 +16,14 @@
// dropdown/Dropdown.css`), not `Dropdown` itself (its `options.map`
// render is menu-item-shaped, not a generic content container — not a
// fit for a login form + output pane).
import { useState } from 'preact/hooks';
import type { SessionView } from '../types.js';
import { postLoginStart, postLoginCode, postLoginCancel } from '../lib/loginAction.js';
import './LoginFlow.css';
import { useState } from "preact/hooks";
import type { SessionView } from "../types.js";
import {
postLoginStart,
postLoginCode,
postLoginCancel,
} from "../lib/loginAction.js";
import "./LoginFlow.css";
function LoginIdle({ onStarted }: { onStarted: () => void }) {
const [busy, setBusy] = useState(false);
@ -32,7 +36,7 @@ function LoginIdle({ onStarted }: { onStarted: () => void }) {
postLoginStart().then((r) => {
setBusy(false);
if (r.ok) onStarted();
else setError(r.detail ?? 'failed');
else setError(r.detail ?? "failed");
});
}
@ -40,8 +44,8 @@ function LoginIdle({ onStarted }: { onStarted: () => void }) {
<div class="login-card">
<p class="status-needs-login"> NEEDS L0G1N</p>
<p>
No Claude session in <code>~/.claude/</code>. The harness is up but the turn loop is
paused until you log in.
No Claude session in <code>~/.claude/</code>. The harness is up but the
turn loop is paused until you log in.
</p>
<form onSubmit={submit}>
<button type="submit" class="btn btn-login" disabled={busy}>
@ -50,15 +54,22 @@ function LoginIdle({ onStarted }: { onStarted: () => void }) {
</form>
{error ? <p class="meta">error: {error}</p> : null}
<p class="meta">
Spawns <code>claude auth login</code> over plain stdio pipes. The OAuth URL will appear
here when claude emits it; paste the resulting code back into the form below.
Spawns <code>claude auth login</code> over plain stdio pipes. The OAuth
URL will appear here when claude emits it; paste the resulting code back
into the form below.
</p>
</div>
);
}
function LoginInProgress({ session, onDone }: { session: SessionView | null; onDone: () => void }) {
const [code, setCode] = useState('');
function LoginInProgress({
session,
onDone,
}: {
session: SessionView | null;
onDone: () => void;
}) {
const [code, setCode] = useState("");
const [reveal, setReveal] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -75,7 +86,7 @@ function LoginInProgress({ session, onDone }: { session: SessionView | null; onD
// nothing local to update here either way.
postLoginCode(code).then((r) => {
setBusy(false);
if (!r.ok) setError(r.detail ?? 'failed');
if (!r.ok) setError(r.detail ?? "failed");
});
}
@ -94,23 +105,26 @@ function LoginInProgress({ session, onDone }: { session: SessionView | null; onD
{session?.url ? (
<>
<p class="login-url">
{' '}
{" "}
<a href={session.url} target="_blank" rel="noreferrer">
{session.url}
</a>
</p>
<p class="meta">
open this URL in a browser, complete the OAuth flow, paste the resulting code below.
open this URL in a browser, complete the OAuth flow, paste the
resulting code below.
</p>
</>
) : (
<p class="meta">waiting for claude to emit an OAuth URL on stdout (output below)</p>
<p class="meta">
waiting for claude to emit an OAuth URL on stdout (output below)
</p>
)}
{!finished ? (
<form class="loginform" onSubmit={submitCode}>
<input
name="code"
type={reveal ? 'text' : 'password'}
type={reveal ? "text" : "password"}
placeholder="paste OAuth code here (hidden)"
required
autocomplete="one-time-code"
@ -133,19 +147,20 @@ function LoginInProgress({ session, onDone }: { session: SessionView | null; onD
</button>
</form>
) : null}
<form style={{ marginTop: '0.4em' }} onSubmit={cancel}>
<form style={{ marginTop: "0.4em" }} onSubmit={cancel}>
<button type="submit" class="btn btn-cancel" disabled={busy}>
cancel + kill
</button>
</form>
{finished ? (
<p class="status-needs-login">
claude process exited: {session?.exit_note || 'exited'}. Start over if needed.
claude process exited: {session?.exit_note || "exited"}. Start over if
needed.
</p>
) : null}
{error ? <p class="meta">error: {error}</p> : null}
<h3>output</h3>
<pre class="diff">{session?.output || ''}</pre>
<pre class="diff">{session?.output || ""}</pre>
</div>
);
}
@ -155,13 +170,13 @@ export function LoginFlow({
session,
onRefresh,
}: {
status: 'needs_login_idle' | 'needs_login_in_progress';
status: "needs_login_idle" | "needs_login_in_progress";
session: SessionView | null;
onRefresh: () => void;
}) {
return (
<div class="agent-status-overlay">
{status === 'needs_login_idle' ? (
{status === "needs_login_idle" ? (
<LoginIdle onStarted={onRefresh} />
) : (
<LoginInProgress session={session} onDone={onRefresh} />

View file

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

View file

@ -6,11 +6,11 @@
// a body is an expandable details row gated by the operator's
// expand-tool-output preference. No separate classification step —
// mara: "StreamRow should now match what the server sends in TermMsg."
import { useEffect, useRef } from 'preact/hooks';
import type { TermRow } from '../lib/termMsg.js';
import { linkifyToNodes } from '../lib/linkify.js';
import { renderMarkdown } from '../lib/markdown.js';
import { getExpandDetailsPref } from '@hive/shared/prefs.js';
import { useEffect, useRef } from "preact/hooks";
import type { TermRow } from "../lib/termMsg.js";
import { linkifyToNodes } from "../lib/linkify.js";
import { renderMarkdown } from "../lib/markdown.js";
import { getExpandDetailsPref } from "@hive/shared/prefs.js";
function MarkdownBody({ text }: { text: string }) {
const ref = useRef<HTMLDivElement>(null);
@ -18,25 +18,31 @@ function MarkdownBody({ text }: { text: string }) {
useEffect(() => {
// marked autolinks URLs but leaves them same-tab — open externally
// so a click never navigates the terminal away.
ref.current?.querySelectorAll('a[href]').forEach((a) => {
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer');
ref.current?.querySelectorAll("a[href]").forEach((a) => {
a.setAttribute("target", "_blank");
a.setAttribute("rel", "noopener noreferrer");
});
}, [html]);
// eslint-disable-next-line react/no-danger -- sanitized by DOMPurify in renderMarkdown
return <div className="md" ref={ref} dangerouslySetInnerHTML={{ __html: html }} />;
return (
<div className="md" ref={ref} dangerouslySetInnerHTML={{ __html: html }} />
);
}
function DiffBody({ text }: { text: string }) {
const lines = String(text).split('\n');
const lines = String(text).split("\n");
return (
<pre className="tool-body diff-body">
{lines.map((line, i) => {
const cls = line.startsWith('+ ') ? 'diff-add' : line.startsWith('- ') ? 'diff-del' : 'diff-ctx';
const cls = line.startsWith("+ ")
? "diff-add"
: line.startsWith("- ")
? "diff-del"
: "diff-ctx";
return (
<span key={i} className={cls}>
{line}
{'\n'}
{"\n"}
</span>
);
})}
@ -45,8 +51,10 @@ function DiffBody({ text }: { text: string }) {
}
export function Row({ row }: { row: TermRow }) {
const cssClass = 'level-' + row.level;
const icon = row.icon != null && row.icon !== '' && <span className="row-glyph">{row.icon}</span>;
const cssClass = "level-" + row.level;
const icon = row.icon != null && row.icon !== "" && (
<span className="row-glyph">{row.icon}</span>
);
if (row.body == null) {
return (
@ -59,7 +67,7 @@ export function Row({ row }: { row: TermRow }) {
// Empty summary + markdown body → the body itself is the whole row
// (assistant text), no summary prefix line, never collapsible.
if (row.body_format === 'markdown' && row.summary === '') {
if (row.body_format === "markdown" && row.summary === "") {
return (
<div className={`row ${cssClass}`}>
{icon}
@ -69,14 +77,19 @@ export function Row({ row }: { row: TermRow }) {
}
return (
<details className={`row ${cssClass}`} open={getExpandDetailsPref() || undefined}>
<details
className={`row ${cssClass}`}
open={getExpandDetailsPref() || undefined}
>
<summary>
{icon}
<span className="summary-text">{row.summary}</span>
</summary>
{row.body_format === 'diff' && <DiffBody text={row.body} />}
{row.body_format === 'markdown' && <MarkdownBody text={row.body} />}
{row.body_format == null && <pre className="tool-body">{linkifyToNodes(row.body)}</pre>}
{row.body_format === "diff" && <DiffBody text={row.body} />}
{row.body_format === "markdown" && <MarkdownBody text={row.body} />}
{row.body_format == null && (
<pre className="tool-body">{linkifyToNodes(row.body)}</pre>
)}
</details>
);
}

View file

@ -11,9 +11,9 @@
// Content styling (`.agent-inbox`, `.inbox-*`, `.side-panel-empty`)
// already ships globally via `agent.css` and is reused verbatim by
// InboxPanel/TodosPanel — only the drawer chrome itself is new here.
import { useEffect } from 'preact/hooks';
import type { ComponentChildren } from 'preact';
import './SidePanel.css';
import { useEffect } from "preact/hooks";
import type { ComponentChildren } from "preact";
import "./SidePanel.css";
export interface SidePanelProps {
open: boolean;
@ -26,17 +26,21 @@ export function SidePanel({ open, title, onClose, children }: SidePanelProps) {
useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
if (e.key === "Escape") onClose();
}
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
return (
<>
<div class={`agent-side-panel-backdrop${open ? ' open' : ''}`} onClick={onClose} aria-hidden="true" />
<div
class={`agent-side-panel-backdrop${open ? " open" : ""}`}
onClick={onClose}
aria-hidden="true"
/>
<aside
class={`agent-side-panel-drawer${open ? ' open' : ''}`}
class={`agent-side-panel-drawer${open ? " open" : ""}`}
role="dialog"
aria-modal="true"
aria-hidden={!open}
@ -44,7 +48,12 @@ export function SidePanel({ open, title, onClose, children }: SidePanelProps) {
>
<div class="agent-side-panel-head">
<span class="agent-side-panel-title">{title}</span>
<button type="button" class="agent-side-panel-close" title="close (esc)" onClick={onClose}>
<button
type="button"
class="agent-side-panel-close"
title="close (esc)"
onClick={onClose}
>
</button>
</div>

View file

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

View file

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

View file

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