treefmt: apply prettier
Pure `nix fmt` output from the commit before this one — no hand edits. 203 files: 52 md, 42 tsx, 32 js, 32 css, 21 ts, 13 html, 8 json, 3 mjs. Reproduce with `nix develop -c nix fmt` on the parent commit; the result should be byte-identical to this tree. None of the 13 `.prettierignore` entries appears here — verified by intersecting the changed-file list against the ignore file, with a control proving the intersection finds a match when one exists.
This commit is contained in:
parent
5d24bedd60
commit
39b95c2ede
203 changed files with 10090 additions and 6085 deletions
|
|
@ -20,10 +20,10 @@
|
|||
// Same shared-component shape as `JobqRollup`/`JobqGraph`:
|
||||
// `render(h(ApiErrorPanel, { problem }), container)` from vanilla JS, or
|
||||
// `<ApiErrorPanel problem={...} />` from swarm-ui's JSX.
|
||||
import { useState } from 'preact/hooks';
|
||||
import { WarnBanner } from '../warn-banner/WarnBanner.js';
|
||||
import type { ProblemDetails } from '../api-error.js';
|
||||
import './api-error-panel.css';
|
||||
import { useState } from "preact/hooks";
|
||||
import { WarnBanner } from "../warn-banner/WarnBanner.js";
|
||||
import type { ProblemDetails } from "../api-error.js";
|
||||
import "./api-error-panel.css";
|
||||
|
||||
export interface ApiErrorPanelProps {
|
||||
problem: ProblemDetails;
|
||||
|
|
@ -39,12 +39,16 @@ function formatForCopy(p: ProblemDetails): string {
|
|||
if (p.title) lines.push(`title: ${p.title}`);
|
||||
if (p.type) lines.push(`type: ${p.type}`);
|
||||
if (p.detail) lines.push(`detail: ${p.detail}`);
|
||||
return lines.length ? lines.join('\n') : 'request failed';
|
||||
return lines.length ? lines.join("\n") : "request failed";
|
||||
}
|
||||
|
||||
export function ApiErrorPanel({ problem, context }: ApiErrorPanelProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const heading = problem.title || (problem.status !== undefined ? `http ${problem.status}` : 'request failed');
|
||||
const heading =
|
||||
problem.title ||
|
||||
(problem.status !== undefined
|
||||
? `http ${problem.status}`
|
||||
: "request failed");
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
|
|
@ -62,11 +66,11 @@ export function ApiErrorPanel({ problem, context }: ApiErrorPanelProps) {
|
|||
<WarnBanner level="error" class="api-error-panel">
|
||||
<div class="api-error-heading">
|
||||
<span class="api-error-title">
|
||||
{context ? `${context}: ` : ''}
|
||||
{context ? `${context}: ` : ""}
|
||||
{heading}
|
||||
</span>
|
||||
<button type="button" class="api-error-copy" onClick={copy}>
|
||||
{copied ? 'copied' : 'copy'}
|
||||
{copied ? "copied" : "copy"}
|
||||
</button>
|
||||
</div>
|
||||
{problem.detail ? <p class="api-error-detail">{problem.detail}</p> : null}
|
||||
|
|
|
|||
|
|
@ -33,17 +33,17 @@ export async function readApiError(resp: Response): Promise<ProblemDetails> {
|
|||
} catch {
|
||||
return { status };
|
||||
}
|
||||
if (raw && (raw[0] === '{' || raw[0] === '[')) {
|
||||
if (raw && (raw[0] === "{" || raw[0] === "[")) {
|
||||
try {
|
||||
const body = JSON.parse(raw) as Record<string, unknown>;
|
||||
return {
|
||||
type: typeof body.type === 'string' ? body.type : undefined,
|
||||
title: typeof body.title === 'string' ? body.title : undefined,
|
||||
status: typeof body.status === 'number' ? body.status : status,
|
||||
type: typeof body.type === "string" ? body.type : undefined,
|
||||
title: typeof body.title === "string" ? body.title : undefined,
|
||||
status: typeof body.status === "number" ? body.status : status,
|
||||
detail:
|
||||
typeof body.detail === 'string'
|
||||
typeof body.detail === "string"
|
||||
? body.detail
|
||||
: typeof body.error === 'string'
|
||||
: typeof body.error === "string"
|
||||
? body.error
|
||||
: undefined,
|
||||
};
|
||||
|
|
@ -59,5 +59,9 @@ export async function readApiError(resp: Response): Promise<ProblemDetails> {
|
|||
// same fallback order `readErrorBody` used: detail (or its `error` alias,
|
||||
// already folded in above) → title → a bare status line.
|
||||
export function problemMessage(p: ProblemDetails): string {
|
||||
return p.detail || p.title || (p.status !== undefined ? `http ${p.status}` : 'request failed');
|
||||
return (
|
||||
p.detail ||
|
||||
p.title ||
|
||||
(p.status !== undefined ? `http ${p.status}` : "request failed")
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,12 +51,12 @@
|
|||
the settings/links "should not have the badge bg" bug on those
|
||||
triggers. Verified against a real repro before landing, not just
|
||||
the specificity arithmetic. */
|
||||
:root[data-theme='light'] .ui-badge:not(.ui-badge-quiet) {
|
||||
:root[data-theme="light"] .ui-badge:not(.ui-badge-quiet) {
|
||||
background: var(--purple-dim);
|
||||
-webkit-backdrop-filter: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
:root[data-theme='dark'] .ui-badge:not(.ui-badge-quiet) {
|
||||
:root[data-theme="dark"] .ui-badge:not(.ui-badge-quiet) {
|
||||
background: color-mix(in srgb, var(--purple-dim) 65%, transparent);
|
||||
-webkit-backdrop-filter: blur(6px) saturate(140%);
|
||||
backdrop-filter: blur(6px) saturate(140%);
|
||||
|
|
@ -81,12 +81,14 @@
|
|||
.ui-badge-quiet {
|
||||
background: none;
|
||||
}
|
||||
.ui-badge-interactive[aria-expanded='true'] {
|
||||
.ui-badge-interactive[aria-expanded="true"] {
|
||||
background: var(--bg-elev);
|
||||
outline: 1px solid var(--purple);
|
||||
}
|
||||
.ui-badge-label {
|
||||
color: var(--muted-on-dim); /* --muted alone is too low-contrast on this fill, see theme.css */
|
||||
color: var(
|
||||
--muted-on-dim
|
||||
); /* --muted alone is too low-contrast on this fill, see theme.css */
|
||||
}
|
||||
.ui-badge-value {
|
||||
color: var(--fg);
|
||||
|
|
|
|||
|
|
@ -18,10 +18,15 @@
|
|||
// swarm-ui *and* the per-agent page need the interactive shape, and
|
||||
// `shared` is the one package both already depend on (see
|
||||
// `docs/web-ui/design-guide.md`'s component-first + junk-drawer rules).
|
||||
import type { ComponentChildren } from 'preact';
|
||||
import './Badge.css';
|
||||
import type { ComponentChildren } from "preact";
|
||||
import "./Badge.css";
|
||||
|
||||
export type BadgeTone = 'neutral' | 'positive' | 'warning' | 'negative' | 'accent';
|
||||
export type BadgeTone =
|
||||
| "neutral"
|
||||
| "positive"
|
||||
| "warning"
|
||||
| "negative"
|
||||
| "accent";
|
||||
/**
|
||||
* `'default'` — the filled pill every status/picker badge has always
|
||||
* been. `'quiet'` — no permanent fill, only a background on hover/
|
||||
|
|
@ -34,7 +39,7 @@ export type BadgeTone = 'neutral' | 'positive' | 'warning' | 'negative' | 'accen
|
|||
* weight is orthogonal to `tone` (color semantics), so it's its own prop
|
||||
* rather than a new `tone` value.
|
||||
*/
|
||||
export type BadgeVariant = 'default' | 'quiet';
|
||||
export type BadgeVariant = "default" | "quiet";
|
||||
|
||||
export interface BadgeProps {
|
||||
/** Dim prefix text, e.g. "model". Omit for a single-value badge like "alive". */
|
||||
|
|
@ -63,8 +68,8 @@ export interface BadgeProps {
|
|||
export function Badge({
|
||||
label,
|
||||
value,
|
||||
tone = 'neutral',
|
||||
variant = 'default',
|
||||
tone = "neutral",
|
||||
variant = "default",
|
||||
icon,
|
||||
onClick,
|
||||
expanded,
|
||||
|
|
@ -73,14 +78,14 @@ export function Badge({
|
|||
title,
|
||||
}: BadgeProps) {
|
||||
const classes = [
|
||||
'ui-badge',
|
||||
"ui-badge",
|
||||
`ui-badge-${tone}`,
|
||||
variant === 'quiet' && 'ui-badge-quiet',
|
||||
onClick && 'ui-badge-interactive',
|
||||
variant === "quiet" && "ui-badge-quiet",
|
||||
onClick && "ui-badge-interactive",
|
||||
extraClass,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
.join(" ");
|
||||
const content = (
|
||||
<>
|
||||
{icon ? (
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ body {
|
|||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
|
||||
font-family:
|
||||
"JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
|
|
@ -33,4 +34,11 @@ body {
|
|||
animation: spin 1s linear infinite;
|
||||
color: var(--amber);
|
||||
}
|
||||
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,9 @@
|
|||
white-space: nowrap;
|
||||
flex: none;
|
||||
}
|
||||
.page-back:hover { text-decoration: underline; }
|
||||
.page-back:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
color: var(--subtext0);
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@
|
|||
both blocks below just re-point at the same `--mocha-baseNN` /
|
||||
`--latte-baseNN` custom properties declared once at the top of this
|
||||
file. */
|
||||
:root[data-theme='light'] {
|
||||
:root[data-theme="light"] {
|
||||
--base00: var(--latte-base00);
|
||||
--base01: var(--latte-base01);
|
||||
--base02: var(--latte-base02);
|
||||
|
|
@ -160,7 +160,7 @@
|
|||
--base0E: var(--latte-base0E);
|
||||
--base0F: var(--latte-base0F);
|
||||
}
|
||||
:root[data-theme='dark'] {
|
||||
:root[data-theme="dark"] {
|
||||
--base00: var(--mocha-base00);
|
||||
--base01: var(--mocha-base01);
|
||||
--base02: var(--mocha-base02);
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@
|
|||
export const el = (tag, attrs = {}, ...children) => {
|
||||
const e = document.createElement(tag);
|
||||
for (const [k, v] of Object.entries(attrs)) {
|
||||
if (k === 'class') e.className = v;
|
||||
else if (k === 'html') e.innerHTML = v;
|
||||
if (k === "class") e.className = v;
|
||||
else if (k === "html") e.innerHTML = v;
|
||||
else e.setAttribute(k, v);
|
||||
}
|
||||
for (const c of children) {
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@
|
|||
background: var(--purple-dim);
|
||||
}
|
||||
.ui-dropdown-item-active .ui-dropdown-item-label::before {
|
||||
content: '✓ ';
|
||||
content: "✓ ";
|
||||
color: var(--purple);
|
||||
}
|
||||
/* Destructive action row (cancel turn) — ported from the old standalone
|
||||
|
|
|
|||
|
|
@ -14,9 +14,9 @@
|
|||
// to that box's bottom-left via CSS. No portal — a badge in a normal
|
||||
// document-flow header never needs one, and skipping it keeps focus
|
||||
// management simple (no re-parenting to `<body>` to reason about).
|
||||
import { useEffect, useRef } from 'preact/hooks';
|
||||
import type { ComponentChildren, RefObject } from 'preact';
|
||||
import './Dropdown.css';
|
||||
import { useEffect, useRef } from "preact/hooks";
|
||||
import type { ComponentChildren, RefObject } from "preact";
|
||||
import "./Dropdown.css";
|
||||
|
||||
export interface DropdownOption {
|
||||
value: string;
|
||||
|
|
@ -53,7 +53,15 @@ export interface DropdownProps {
|
|||
anchorRef?: RefObject<HTMLElement>;
|
||||
}
|
||||
|
||||
export function Dropdown({ open, options, activeValue, onSelect, onClose, label, anchorRef }: DropdownProps) {
|
||||
export function Dropdown({
|
||||
open,
|
||||
options,
|
||||
activeValue,
|
||||
onSelect,
|
||||
onClose,
|
||||
label,
|
||||
anchorRef,
|
||||
}: DropdownProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -65,16 +73,16 @@ export function Dropdown({ open, options, activeValue, onSelect, onClose, label,
|
|||
onClose();
|
||||
}
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
// `pointerdown` (not `click`) so a drag-to-select that ends outside
|
||||
// still closes; capture phase so this sees the event before a
|
||||
// stopPropagation() elsewhere in the tree could swallow it.
|
||||
document.addEventListener('pointerdown', handlePointerDown, true);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
document.addEventListener("pointerdown", handlePointerDown, true);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', handlePointerDown, true);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.removeEventListener("pointerdown", handlePointerDown, true);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
|
|
@ -89,14 +97,16 @@ export function Dropdown({ open, options, activeValue, onSelect, onClose, label,
|
|||
role="menuitemradio"
|
||||
aria-checked={opt.value === activeValue}
|
||||
class={
|
||||
'ui-dropdown-item' +
|
||||
(opt.value === activeValue ? ' ui-dropdown-item-active' : '') +
|
||||
(opt.danger ? ' ui-dropdown-item-danger' : '')
|
||||
"ui-dropdown-item" +
|
||||
(opt.value === activeValue ? " ui-dropdown-item-active" : "") +
|
||||
(opt.danger ? " ui-dropdown-item-danger" : "")
|
||||
}
|
||||
onClick={() => onSelect(opt.value)}
|
||||
>
|
||||
<span class="ui-dropdown-item-label">{opt.label}</span>
|
||||
{opt.description ? <span class="ui-dropdown-item-desc">{opt.description}</span> : null}
|
||||
{opt.description ? (
|
||||
<span class="ui-dropdown-item-desc">{opt.description}</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -51,30 +51,37 @@ export function asyncBtn(btn, fn) {
|
|||
// `data-prompt-field`) are surfaced via the themed dialogs in `modal.js`
|
||||
// rather than native `confirm()`/`prompt()`, and errors via `themedToast`
|
||||
// rather than `alert()`, so every page gets the same in-theme experience.
|
||||
import { themedConfirm, themedPrompt, themedToast } from './modal.js';
|
||||
import { themedConfirm, themedPrompt, themedToast } from "./modal.js";
|
||||
|
||||
export function bindAsyncForms(onSuccess) {
|
||||
document.addEventListener('submit', async (e) => {
|
||||
document.addEventListener("submit", async (e) => {
|
||||
const f = e.target;
|
||||
if (!(f instanceof HTMLFormElement) || !f.hasAttribute('data-async')) return;
|
||||
if (!(f instanceof HTMLFormElement) || !f.hasAttribute("data-async"))
|
||||
return;
|
||||
e.preventDefault();
|
||||
if (f.dataset.confirm && !(await themedConfirm({ message: f.dataset.confirm }))) return;
|
||||
if (
|
||||
f.dataset.confirm &&
|
||||
!(await themedConfirm({ message: f.dataset.confirm }))
|
||||
)
|
||||
return;
|
||||
if (f.dataset.prompt) {
|
||||
const ans = await themedPrompt({ message: f.dataset.prompt });
|
||||
if (ans === null) return; // operator hit Cancel
|
||||
// Drop into a hidden input named after `data-prompt-field` (or
|
||||
// 'note' by default) so the value rides along on the POST.
|
||||
const field = f.dataset.promptField || 'note';
|
||||
const field = f.dataset.promptField || "note";
|
||||
let input = f.querySelector(`input[name="${field}"]`);
|
||||
if (!input) {
|
||||
input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input = document.createElement("input");
|
||||
input.type = "hidden";
|
||||
input.name = field;
|
||||
f.append(input);
|
||||
}
|
||||
input.value = ans;
|
||||
}
|
||||
const btn = f.querySelector('button[type="submit"], button:not([type]), .btn-inline');
|
||||
const btn = f.querySelector(
|
||||
'button[type="submit"], button:not([type]), .btn-inline',
|
||||
);
|
||||
// Inner action: POST, clear inputs, call onSuccess.
|
||||
// Errors are surfaced via themedToast; the caller does not re-throw
|
||||
// so asyncBtn's finally always runs (restoring the button).
|
||||
|
|
@ -82,25 +89,37 @@ export function bindAsyncForms(onSuccess) {
|
|||
let resp;
|
||||
try {
|
||||
resp = await fetch(f.action, {
|
||||
method: f.method || 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
method: f.method || "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams(new FormData(f)),
|
||||
redirect: 'manual',
|
||||
redirect: "manual",
|
||||
});
|
||||
} catch (err) {
|
||||
themedToast('action failed: ' + err, { type: 'error' });
|
||||
themedToast("action failed: " + err, { type: "error" });
|
||||
return;
|
||||
}
|
||||
const ok = resp.ok || resp.type === 'opaqueredirect'
|
||||
|| (resp.status >= 200 && resp.status < 400);
|
||||
const ok =
|
||||
resp.ok ||
|
||||
resp.type === "opaqueredirect" ||
|
||||
(resp.status >= 200 && resp.status < 400);
|
||||
if (!ok) {
|
||||
const text = await resp.text().catch(() => '');
|
||||
themedToast('action failed: ' + resp.status + (text ? '\n\n' + text : ''), { type: 'error' });
|
||||
const text = await resp.text().catch(() => "");
|
||||
themedToast(
|
||||
"action failed: " + resp.status + (text ? "\n\n" + text : ""),
|
||||
{ type: "error" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Clear text inputs whose value was just submitted.
|
||||
f.querySelectorAll('input[type="text"], input:not([type]), textarea').forEach((i) => { i.value = ''; });
|
||||
if (!f.hasAttribute('data-no-refresh') && typeof onSuccess === 'function') {
|
||||
f.querySelectorAll(
|
||||
'input[type="text"], input:not([type]), textarea',
|
||||
).forEach((i) => {
|
||||
i.value = "";
|
||||
});
|
||||
if (
|
||||
!f.hasAttribute("data-no-refresh") &&
|
||||
typeof onSuccess === "function"
|
||||
) {
|
||||
onSuccess();
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,9 +12,15 @@
|
|||
:host {
|
||||
display: contents;
|
||||
}
|
||||
:host([variant="cancel"]) { color: var(--subtext0); }
|
||||
:host([variant="confirm"]) { color: var(--green); }
|
||||
:host([variant="danger"]) { color: var(--red); }
|
||||
:host([variant="cancel"]) {
|
||||
color: var(--subtext0);
|
||||
}
|
||||
:host([variant="confirm"]) {
|
||||
color: var(--green);
|
||||
}
|
||||
:host([variant="danger"]) {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
|
|
|
|||
|
|
@ -20,10 +20,10 @@
|
|||
// `disabled`/`type` are likewise plain attributes on the host, mirrored
|
||||
// onto the inner button on connect and on every attribute change.
|
||||
|
||||
import { attachShadowCss } from '../shadow-css.js';
|
||||
import hiveBtnCss from './hive-btn.css';
|
||||
import { attachShadowCss } from "../shadow-css.js";
|
||||
import hiveBtnCss from "./hive-btn.css";
|
||||
|
||||
const OBSERVED = ['disabled', 'type'];
|
||||
const OBSERVED = ["disabled", "type"];
|
||||
|
||||
class HiveBtn extends HTMLElement {
|
||||
static get observedAttributes() {
|
||||
|
|
@ -36,8 +36,8 @@ class HiveBtn extends HTMLElement {
|
|||
return; // already built (e.g. re-parenting re-fires connectedCallback)
|
||||
}
|
||||
const root = attachShadowCss(this, hiveBtnCss, { delegatesFocus: true });
|
||||
const btn = document.createElement('button');
|
||||
btn.append(document.createElement('slot'));
|
||||
const btn = document.createElement("button");
|
||||
btn.append(document.createElement("slot"));
|
||||
root.append(btn);
|
||||
this._btn = btn;
|
||||
this._sync();
|
||||
|
|
@ -49,8 +49,8 @@ class HiveBtn extends HTMLElement {
|
|||
|
||||
_sync() {
|
||||
if (!this._btn) return;
|
||||
this._btn.disabled = this.hasAttribute('disabled');
|
||||
this._btn.type = this.getAttribute('type') || 'button';
|
||||
this._btn.disabled = this.hasAttribute("disabled");
|
||||
this._btn.type = this.getAttribute("type") || "button";
|
||||
}
|
||||
}
|
||||
customElements.define('hive-btn', HiveBtn);
|
||||
customElements.define("hive-btn", HiveBtn);
|
||||
|
|
|
|||
|
|
@ -35,8 +35,15 @@
|
|||
letter-spacing: 0.08em;
|
||||
color: var(--subtext0);
|
||||
}
|
||||
.message { color: var(--fg); line-height: 1.45; }
|
||||
.checks { display: flex; flex-direction: column; gap: 0.4em; }
|
||||
.message {
|
||||
color: var(--fg);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.checks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4em;
|
||||
}
|
||||
.checkrow {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
|
@ -46,15 +53,25 @@
|
|||
font-size: 0.92em;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.checkrow .check { margin-top: 0.2em; flex: 0 0 auto; }
|
||||
.checkrow .check {
|
||||
margin-top: 0.2em;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.6em;
|
||||
margin-top: 0.2em;
|
||||
}
|
||||
.promptfield { display: flex; flex-direction: column; gap: 0.4em; }
|
||||
.promptlabel { color: var(--subtext0); font-size: 0.9em; }
|
||||
.promptfield {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4em;
|
||||
}
|
||||
.promptlabel {
|
||||
color: var(--subtext0);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.input {
|
||||
font-family: inherit;
|
||||
font-size: 1em;
|
||||
|
|
@ -65,7 +82,9 @@
|
|||
border: 1px solid var(--purple-dim);
|
||||
padding: 0.4em 0.6em;
|
||||
}
|
||||
.input:focus { outline: 1px solid var(--green); }
|
||||
.input:focus {
|
||||
outline: 1px solid var(--green);
|
||||
}
|
||||
.textarea {
|
||||
resize: vertical;
|
||||
min-height: 4.5em;
|
||||
|
|
|
|||
|
|
@ -14,17 +14,20 @@
|
|||
// Imports `../hive-btn/hive-btn.js` for its side effect (registers
|
||||
// `<hive-btn>`) since the dialog's own buttons are `<hive-btn>` elements.
|
||||
|
||||
import { el } from '../dom.js';
|
||||
import { attachShadowCss } from '../shadow-css.js';
|
||||
import '../hive-btn/hive-btn.js'; // registers <hive-btn> — side-effect import, no named export needed
|
||||
import dialogCss from './hive-dialog.css';
|
||||
import { el } from "../dom.js";
|
||||
import { attachShadowCss } from "../shadow-css.js";
|
||||
import "../hive-btn/hive-btn.js"; // registers <hive-btn> — side-effect import, no named export needed
|
||||
import dialogCss from "./hive-dialog.css";
|
||||
|
||||
class HiveDialog extends HTMLElement {
|
||||
connectedCallback() {
|
||||
const {
|
||||
title = '', message = '', content = null,
|
||||
buttons = [{ label: 'ok', value: true }],
|
||||
danger = false, dismissable = true,
|
||||
title = "",
|
||||
message = "",
|
||||
content = null,
|
||||
buttons = [{ label: "ok", value: true }],
|
||||
danger = false,
|
||||
dismissable = true,
|
||||
} = this._opts || {};
|
||||
|
||||
const root = attachShadowCss(this, dialogCss);
|
||||
|
|
@ -33,12 +36,14 @@ class HiveDialog extends HTMLElement {
|
|||
const done = (value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
document.removeEventListener('keydown', onKey, true);
|
||||
this.dispatchEvent(new CustomEvent('hive-dialog-close', { detail: value }));
|
||||
document.removeEventListener("keydown", onKey, true);
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("hive-dialog-close", { detail: value }),
|
||||
);
|
||||
this.remove();
|
||||
};
|
||||
const onKey = (e) => {
|
||||
if (dismissable && e.key === 'Escape') {
|
||||
if (dismissable && e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
done(null);
|
||||
|
|
@ -50,12 +55,16 @@ class HiveDialog extends HTMLElement {
|
|||
// `b.class` names the variant ('cancel' | 'confirm'); `b.danger`
|
||||
// overrides it to the 'danger' look regardless (a destructive
|
||||
// confirm button reads as danger, not as a plain confirm).
|
||||
const variant = b.danger ? 'danger' : b.class;
|
||||
const btn = el('hive-btn', {
|
||||
type: 'button',
|
||||
...(variant ? { variant } : {}),
|
||||
}, b.label);
|
||||
btn.addEventListener('click', () => done(b.value));
|
||||
const variant = b.danger ? "danger" : b.class;
|
||||
const btn = el(
|
||||
"hive-btn",
|
||||
{
|
||||
type: "button",
|
||||
...(variant ? { variant } : {}),
|
||||
},
|
||||
b.label,
|
||||
);
|
||||
btn.addEventListener("click", () => done(b.value));
|
||||
return { spec: b, btn };
|
||||
});
|
||||
|
||||
|
|
@ -65,21 +74,30 @@ class HiveDialog extends HTMLElement {
|
|||
// this shadow root, so no cross-instance collision risk even without
|
||||
// the random suffix — kept anyway since it costs nothing and guards
|
||||
// against a future shared-DOM edge case (e.g. `::part()` piercing).
|
||||
const labelId = 'dlg-' + Math.random().toString(36).slice(2, 9);
|
||||
const titleEl = title ? el('div', { class: 'title', id: labelId }, title) : null;
|
||||
const messageEl = message
|
||||
? el('div', title ? { class: 'message' } : { class: 'message', id: labelId }, message)
|
||||
const labelId = "dlg-" + Math.random().toString(36).slice(2, 9);
|
||||
const titleEl = title
|
||||
? el("div", { class: "title", id: labelId }, title)
|
||||
: null;
|
||||
const boxAttrs = { class: 'box', role: 'dialog', 'aria-modal': 'true' };
|
||||
if (titleEl || messageEl) boxAttrs['aria-labelledby'] = labelId;
|
||||
const box = el('div', boxAttrs,
|
||||
const messageEl = message
|
||||
? el(
|
||||
"div",
|
||||
title ? { class: "message" } : { class: "message", id: labelId },
|
||||
message,
|
||||
)
|
||||
: null;
|
||||
const boxAttrs = { class: "box", role: "dialog", "aria-modal": "true" };
|
||||
if (titleEl || messageEl) boxAttrs["aria-labelledby"] = labelId;
|
||||
const box = el(
|
||||
"div",
|
||||
boxAttrs,
|
||||
titleEl,
|
||||
messageEl,
|
||||
content || null,
|
||||
el('div', { class: 'actions' }, ...btnEls.map((b) => b.btn)));
|
||||
el("div", { class: "actions" }, ...btnEls.map((b) => b.btn)),
|
||||
);
|
||||
root.append(box);
|
||||
|
||||
this.addEventListener('click', (e) => {
|
||||
this.addEventListener("click", (e) => {
|
||||
// `e.target` is retargeted to `this` (the host) for ANY click that
|
||||
// originated inside the shadow tree, once it bubbles out to a
|
||||
// listener attached on the host itself — per spec, retargeting
|
||||
|
|
@ -94,12 +112,13 @@ class HiveDialog extends HTMLElement {
|
|||
// tree was actually under the cursor, i.e. a real backdrop click.
|
||||
if (dismissable && e.composedPath()[0] === this) done(null);
|
||||
});
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
document.addEventListener("keydown", onKey, true);
|
||||
|
||||
const focusTarget = btnEls.find((b) => b.spec.autofocus)
|
||||
|| (danger ? btnEls.find((b) => !b.spec.danger) : null)
|
||||
|| btnEls[btnEls.length - 1];
|
||||
const focusTarget =
|
||||
btnEls.find((b) => b.spec.autofocus) ||
|
||||
(danger ? btnEls.find((b) => !b.spec.danger) : null) ||
|
||||
btnEls[btnEls.length - 1];
|
||||
if (focusTarget) focusTarget.btn.focus();
|
||||
}
|
||||
}
|
||||
customElements.define('hive-dialog', HiveDialog);
|
||||
customElements.define("hive-dialog", HiveDialog);
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@
|
|||
/* The trigger is the top-level `slot="trigger"` node — reachable, so its
|
||||
base icon-button chrome (invisible until hover/open, via
|
||||
`--menu-btn-opacity`) lives here rather than duplicated per caller. */
|
||||
::slotted([slot='trigger']) {
|
||||
::slotted([slot="trigger"]) {
|
||||
display: block;
|
||||
background: none;
|
||||
border: none;
|
||||
|
|
@ -45,14 +45,17 @@
|
|||
padding: 0.1em 0.4em;
|
||||
border-radius: 4px;
|
||||
opacity: var(--menu-btn-opacity, 0);
|
||||
transition: opacity 120ms, background 120ms, color 120ms;
|
||||
transition:
|
||||
opacity 120ms,
|
||||
background 120ms,
|
||||
color 120ms;
|
||||
}
|
||||
::slotted([slot='trigger']:hover),
|
||||
::slotted([slot='trigger']:focus-visible) {
|
||||
::slotted([slot="trigger"]:hover),
|
||||
::slotted([slot="trigger"]:focus-visible) {
|
||||
background: color-mix(in srgb, var(--purple) 10%, transparent);
|
||||
color: var(--purple);
|
||||
outline: none;
|
||||
}
|
||||
::slotted([slot='trigger']:focus-visible) {
|
||||
::slotted([slot="trigger"]:focus-visible) {
|
||||
outline: 1px solid var(--purple);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,9 +29,9 @@
|
|||
// slotted content) tests "did this land inside any open menu" — same
|
||||
// reasoning as `<hive-dialog>`'s backdrop-click check.
|
||||
|
||||
import { el } from '../dom.js';
|
||||
import { attachShadowCss } from '../shadow-css.js';
|
||||
import hiveMenuCss from './hive-menu.css';
|
||||
import { el } from "../dom.js";
|
||||
import { attachShadowCss } from "../shadow-css.js";
|
||||
import hiveMenuCss from "./hive-menu.css";
|
||||
|
||||
const openInstances = new Set();
|
||||
|
||||
|
|
@ -46,22 +46,30 @@ export function closeAllMenus() {
|
|||
}
|
||||
|
||||
// Close on any click outside every currently-open instance.
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!openInstances.size) return;
|
||||
const path = e.composedPath();
|
||||
for (const inst of [...openInstances]) {
|
||||
if (!path.includes(inst)) inst.close();
|
||||
}
|
||||
}, true);
|
||||
document.addEventListener(
|
||||
"click",
|
||||
(e) => {
|
||||
if (!openInstances.size) return;
|
||||
const path = e.composedPath();
|
||||
for (const inst of [...openInstances]) {
|
||||
if (!path.includes(inst)) inst.close();
|
||||
}
|
||||
},
|
||||
true,
|
||||
);
|
||||
// Close on Escape. stopImmediatePropagation so a caller's own
|
||||
// selection-clear Escape handler (e.g. swarm.js's) doesn't also fire
|
||||
// while a menu is open.
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && openInstances.size) {
|
||||
closeAll();
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
}, true);
|
||||
document.addEventListener(
|
||||
"keydown",
|
||||
(e) => {
|
||||
if (e.key === "Escape" && openInstances.size) {
|
||||
closeAll();
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
class HiveMenu extends HTMLElement {
|
||||
connectedCallback() {
|
||||
|
|
@ -80,18 +88,18 @@ class HiveMenu extends HTMLElement {
|
|||
|
||||
// Project the caller's opaque nodes via named slots — see module
|
||||
// header for why this has to be slotting, not a shadow-root append.
|
||||
trigger.slot = 'trigger';
|
||||
content.slot = 'content';
|
||||
trigger.slot = "trigger";
|
||||
content.slot = "content";
|
||||
this.append(trigger, content);
|
||||
|
||||
const dropdown = el('div', { class: 'menu-dropdown', hidden: true });
|
||||
dropdown.append(el('slot', { name: 'content' }));
|
||||
root.append(el('slot', { name: 'trigger' }), dropdown);
|
||||
const dropdown = el("div", { class: "menu-dropdown", hidden: true });
|
||||
dropdown.append(el("slot", { name: "content" }));
|
||||
root.append(el("slot", { name: "trigger" }), dropdown);
|
||||
|
||||
this._trigger = trigger;
|
||||
this._dropdown = dropdown;
|
||||
|
||||
trigger.addEventListener('click', (e) => {
|
||||
trigger.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const wasOpen = openInstances.has(this);
|
||||
closeAll();
|
||||
|
|
@ -107,17 +115,17 @@ class HiveMenu extends HTMLElement {
|
|||
|
||||
open() {
|
||||
this._dropdown.hidden = false;
|
||||
this._trigger.setAttribute('aria-expanded', 'true');
|
||||
this.style.setProperty('--menu-btn-opacity', '1');
|
||||
this._trigger.setAttribute("aria-expanded", "true");
|
||||
this.style.setProperty("--menu-btn-opacity", "1");
|
||||
openInstances.add(this);
|
||||
}
|
||||
|
||||
close() {
|
||||
if (!openInstances.has(this)) return;
|
||||
this._dropdown.hidden = true;
|
||||
this._trigger.setAttribute('aria-expanded', 'false');
|
||||
this.style.removeProperty('--menu-btn-opacity');
|
||||
this._trigger.setAttribute("aria-expanded", "false");
|
||||
this.style.removeProperty("--menu-btn-opacity");
|
||||
openInstances.delete(this);
|
||||
}
|
||||
}
|
||||
customElements.define('hive-menu', HiveMenu);
|
||||
customElements.define("hive-menu", HiveMenu);
|
||||
|
|
|
|||
|
|
@ -18,9 +18,20 @@
|
|||
color: var(--fg);
|
||||
white-space: pre-wrap;
|
||||
opacity: 1;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
:host(.out) {
|
||||
opacity: 0;
|
||||
transform: translateX(0.5em);
|
||||
}
|
||||
:host(.error) {
|
||||
border-left-color: var(--red);
|
||||
}
|
||||
:host(.info) {
|
||||
border-left-color: var(--purple-dim);
|
||||
}
|
||||
:host(.ok) {
|
||||
border-left-color: var(--green);
|
||||
}
|
||||
:host(.out) { opacity: 0; transform: translateX(0.5em); }
|
||||
:host(.error) { border-left-color: var(--red); }
|
||||
:host(.info) { border-left-color: var(--purple-dim); }
|
||||
:host(.ok) { border-left-color: var(--green); }
|
||||
|
|
|
|||
|
|
@ -9,29 +9,29 @@
|
|||
// directly in the shadow root rather than via a `<slot>`, since there's no
|
||||
// external light-DOM content to project.
|
||||
|
||||
import { attachShadowCss } from '../shadow-css.js';
|
||||
import toastCss from './hive-toast.css';
|
||||
import { attachShadowCss } from "../shadow-css.js";
|
||||
import toastCss from "./hive-toast.css";
|
||||
|
||||
class HiveToast extends HTMLElement {
|
||||
connectedCallback() {
|
||||
const { type = 'info', duration } = this._opts || {};
|
||||
const ms = duration != null ? duration : (type === 'error' ? 8000 : 4000);
|
||||
const { type = "info", duration } = this._opts || {};
|
||||
const ms = duration != null ? duration : type === "error" ? 8000 : 4000;
|
||||
const root = attachShadowCss(this, toastCss);
|
||||
this.classList.add(type);
|
||||
this.setAttribute('role', type === 'error' ? 'alert' : 'status');
|
||||
root.textContent = this._message || '';
|
||||
this.setAttribute("role", type === "error" ? "alert" : "status");
|
||||
root.textContent = this._message || "";
|
||||
let removed = false;
|
||||
this._remove = () => {
|
||||
if (removed) return;
|
||||
removed = true;
|
||||
this.classList.add('out');
|
||||
this.classList.add("out");
|
||||
setTimeout(() => this.remove(), 200);
|
||||
};
|
||||
this.addEventListener('click', this._remove);
|
||||
this.addEventListener("click", this._remove);
|
||||
if (ms > 0) this._timer = setTimeout(this._remove, ms);
|
||||
}
|
||||
disconnectedCallback() {
|
||||
clearTimeout(this._timer);
|
||||
}
|
||||
}
|
||||
customElements.define('hive-toast', HiveToast);
|
||||
customElements.define("hive-toast", HiveToast);
|
||||
|
|
|
|||
|
|
@ -42,8 +42,13 @@
|
|||
animation: hive-warn-pulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes hive-warn-pulse {
|
||||
0%, 100% { box-shadow: 0 0 12px -4px color-mix(in srgb, currentColor 55%, transparent); }
|
||||
50% { box-shadow: 0 0 22px -2px color-mix(in srgb, currentColor 95%, transparent); }
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 12px -4px color-mix(in srgb, currentColor 55%, transparent);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 22px -2px color-mix(in srgb, currentColor 95%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
/* Explicit rather than relying on plain inheritance — matches what
|
||||
|
|
|
|||
|
|
@ -27,15 +27,15 @@
|
|||
// `el('hive-warn', { level: 'warning' }, ...)` (JS-built)
|
||||
// `el('hive-warn', { level: 'error' }, ...)` (active incident, pulses)
|
||||
|
||||
import { attachShadowCss } from '../shadow-css.js';
|
||||
import hiveWarnCss from './hive-warn.css';
|
||||
import { attachShadowCss } from "../shadow-css.js";
|
||||
import hiveWarnCss from "./hive-warn.css";
|
||||
|
||||
class HiveWarn extends HTMLElement {
|
||||
connectedCallback() {
|
||||
if (this._built) return; // re-parenting re-fires connectedCallback
|
||||
const root = attachShadowCss(this, hiveWarnCss);
|
||||
root.append(document.createElement('slot'));
|
||||
root.append(document.createElement("slot"));
|
||||
this._built = true;
|
||||
}
|
||||
}
|
||||
customElements.define('hive-warn', HiveWarn);
|
||||
customElements.define("hive-warn", HiveWarn);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
// Convenience re-export so consumers can `import { create, linkify }
|
||||
// from '@hive/shared'` without naming the sub-module path.
|
||||
export { create, linkify } from './terminal/terminal.js';
|
||||
export { create, linkify } from "./terminal/terminal.js";
|
||||
|
|
|
|||
|
|
@ -28,21 +28,28 @@
|
|||
// loader is `text` (for unrelated shadow-DOM components' CSS-as-string
|
||||
// needs), and that loader is global per call, not per-module.
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'preact/hooks';
|
||||
import { useState, useEffect, useCallback, useRef } from "preact/hooks";
|
||||
|
||||
// Mirrors `hive_jobq_wire::StateSchema` verbatim (variant names, no
|
||||
// `rename_all`) — see that enum's own doc comment for why it's kept in
|
||||
// an exhaustive match on the Rust side; this union is this file's
|
||||
// equivalent contract.
|
||||
type NodeState = 'Pending' | 'Running' | 'Finishing' | 'Done' | 'Failed' | 'Cancelled' | 'Skipped';
|
||||
type NodeState =
|
||||
| "Pending"
|
||||
| "Running"
|
||||
| "Finishing"
|
||||
| "Done"
|
||||
| "Failed"
|
||||
| "Cancelled"
|
||||
| "Skipped";
|
||||
|
||||
type TerminalState = 'Done' | 'Failed' | 'Cancelled' | 'Skipped';
|
||||
type TerminalState = "Done" | "Failed" | "Cancelled" | "Skipped";
|
||||
|
||||
// Mirrors `hive_jobq_wire::GraphDep` — externally tagged on `kind`,
|
||||
// values are the Rust variant names verbatim.
|
||||
type GraphDep =
|
||||
| { kind: 'Node'; id: number; accepts: TerminalState[] }
|
||||
| { kind: 'Resource'; name: string; count: number };
|
||||
| { kind: "Node"; id: number; accepts: TerminalState[] }
|
||||
| { kind: "Resource"; name: string; count: number };
|
||||
|
||||
interface NodePayload {
|
||||
label: string;
|
||||
|
|
@ -69,13 +76,13 @@ interface TreeNode extends GraphNode {
|
|||
}
|
||||
|
||||
const STATE_GLYPH: Record<NodeState, string> = {
|
||||
Pending: '⏸',
|
||||
Running: '▶',
|
||||
Finishing: '◐',
|
||||
Done: '✔',
|
||||
Failed: '✖',
|
||||
Cancelled: '⊘',
|
||||
Skipped: '·',
|
||||
Pending: "⏸",
|
||||
Running: "▶",
|
||||
Finishing: "◐",
|
||||
Done: "✔",
|
||||
Failed: "✖",
|
||||
Cancelled: "⊘",
|
||||
Skipped: "·",
|
||||
};
|
||||
|
||||
// Declaration order doubles as render order for the filter checkboxes —
|
||||
|
|
@ -84,12 +91,16 @@ const STATE_GLYPH: Record<NodeState, string> = {
|
|||
const ALL_STATES = Object.keys(STATE_GLYPH) as NodeState[];
|
||||
|
||||
// Product call: "default selection filters out skipped and done."
|
||||
const DEFAULT_HIDDEN_STATES = new Set<NodeState>(['Done', 'Skipped']);
|
||||
const DEFAULT_HIDDEN_STATES = new Set<NodeState>(["Done", "Skipped"]);
|
||||
|
||||
// Non-terminal states a cancel button makes sense on. Finishing is
|
||||
// included — "own logic done, children still running" is still a subtree
|
||||
// worth stopping early.
|
||||
const CANCELLABLE_STATES = new Set<NodeState>(['Pending', 'Running', 'Finishing']);
|
||||
const CANCELLABLE_STATES = new Set<NodeState>([
|
||||
"Pending",
|
||||
"Running",
|
||||
"Finishing",
|
||||
]);
|
||||
|
||||
// Build a parent/child tree from the flat wire array. `parent` (structural
|
||||
// grouping) defines tree shape. Sibling order follows array order, which
|
||||
|
|
@ -117,7 +128,9 @@ function buildTree(nodes: GraphNode[]): TreeNode[] {
|
|||
}
|
||||
for (const n of byId.values()) {
|
||||
n._waitsOn = (n.deps || [])
|
||||
.filter((d): d is Extract<GraphDep, { kind: 'Node' }> => d.kind === 'Node')
|
||||
.filter(
|
||||
(d): d is Extract<GraphDep, { kind: "Node" }> => d.kind === "Node",
|
||||
)
|
||||
.map((d) => byId.get(d.id))
|
||||
.filter((dep): dep is TreeNode => dep != null)
|
||||
.map((dep) => dep.payload.label);
|
||||
|
|
@ -131,17 +144,19 @@ function buildTree(nodes: GraphNode[]): TreeNode[] {
|
|||
// single stringified row rather than silently dropping it).
|
||||
function DataList({ data }: { data: unknown }) {
|
||||
if (data == null) return null;
|
||||
const isPlainObject = typeof data === 'object' && !Array.isArray(data);
|
||||
const isPlainObject = typeof data === "object" && !Array.isArray(data);
|
||||
const entries: [string, unknown][] = isPlainObject
|
||||
? Object.entries(data as Record<string, unknown>)
|
||||
: [['data', data]];
|
||||
: [["data", data]];
|
||||
if (!entries.length) return null;
|
||||
return (
|
||||
<dl class="jg-data">
|
||||
{entries.map(([k, v]) => (
|
||||
<>
|
||||
<dt key={k + '-dt'}>{k}</dt>
|
||||
<dd key={k + '-dd'}>{typeof v === 'string' ? v : JSON.stringify(v)}</dd>
|
||||
<dt key={k + "-dt"}>{k}</dt>
|
||||
<dd key={k + "-dd"}>
|
||||
{typeof v === "string" ? v : JSON.stringify(v)}
|
||||
</dd>
|
||||
</>
|
||||
))}
|
||||
</dl>
|
||||
|
|
@ -157,7 +172,7 @@ function NodeView({
|
|||
cancellable: boolean;
|
||||
onCancel?: (id: number) => void;
|
||||
}) {
|
||||
const glyph = STATE_GLYPH[n.state] || '?';
|
||||
const glyph = STATE_GLYPH[n.state] || "?";
|
||||
const showCancel = cancellable && CANCELLABLE_STATES.has(n.state);
|
||||
// Flash the state glyph on a genuine state change (Pending → Running,
|
||||
// etc.), not on mount — `prevState` starts at the node's own initial
|
||||
|
|
@ -176,27 +191,41 @@ function NodeView({
|
|||
return (
|
||||
<div class="jg-node">
|
||||
<div class="jg-row">
|
||||
<span class={'jg-state jg-state-' + n.state.toLowerCase() + (flashing ? ' jg-state-flash' : '')}
|
||||
title={n.state + (n.error ? ' — ' + n.error : '')}
|
||||
onAnimationEnd={() => setFlashing(false)}>
|
||||
<span
|
||||
class={
|
||||
"jg-state jg-state-" +
|
||||
n.state.toLowerCase() +
|
||||
(flashing ? " jg-state-flash" : "")
|
||||
}
|
||||
title={n.state + (n.error ? " — " + n.error : "")}
|
||||
onAnimationEnd={() => setFlashing(false)}
|
||||
>
|
||||
{glyph}
|
||||
</span>
|
||||
{' '}
|
||||
</span>{" "}
|
||||
<span class="jg-label">{n.payload.label}</span>
|
||||
{showCancel && (
|
||||
<button type="button" class="jg-cancel-btn" title={'cancel ' + n.payload.label}
|
||||
onClick={() => onCancel && onCancel(n.id)}>
|
||||
<button
|
||||
type="button"
|
||||
class="jg-cancel-btn"
|
||||
title={"cancel " + n.payload.label}
|
||||
onClick={() => onCancel && onCancel(n.id)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{n._waitsOn && n._waitsOn.length > 0 && (
|
||||
<div class="jg-waits-on">waits on: {n._waitsOn.join(', ')}</div>
|
||||
<div class="jg-waits-on">waits on: {n._waitsOn.join(", ")}</div>
|
||||
)}
|
||||
<DataList data={n.payload.data} />
|
||||
{n.error && <pre class="jg-error">{n.error}</pre>}
|
||||
{n._children.map((child) => (
|
||||
<NodeView key={child.id} n={child} cancellable={cancellable} onCancel={onCancel} />
|
||||
<NodeView
|
||||
key={child.id}
|
||||
n={child}
|
||||
cancellable={cancellable}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -212,12 +241,20 @@ function FilterBar({
|
|||
return (
|
||||
<div class="jg-filter">
|
||||
{ALL_STATES.map((state) => {
|
||||
const id = 'jg-filter-' + state.toLowerCase();
|
||||
const id = "jg-filter-" + state.toLowerCase();
|
||||
return (
|
||||
<label key={state} for={id} class={'jg-filter-label jg-state-' + state.toLowerCase()}>
|
||||
<input type="checkbox" id={id} checked={selectedStates.has(state)}
|
||||
onChange={() => onToggle(state)} />
|
||||
{' '}{STATE_GLYPH[state] + ' ' + state}
|
||||
<label
|
||||
key={state}
|
||||
for={id}
|
||||
class={"jg-filter-label jg-state-" + state.toLowerCase()}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
id={id}
|
||||
checked={selectedStates.has(state)}
|
||||
onChange={() => onToggle(state)}
|
||||
/>{" "}
|
||||
{STATE_GLYPH[state] + " " + state}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
|
|
@ -229,11 +266,14 @@ function FilterBar({
|
|||
// param — omitted entirely when every state is checked, so the
|
||||
// unfiltered default case sends the exact same request as before this
|
||||
// filter existed.
|
||||
function fetchUrl(endpoint: string | undefined, selectedStates: Set<NodeState>): string | null {
|
||||
function fetchUrl(
|
||||
endpoint: string | undefined,
|
||||
selectedStates: Set<NodeState>,
|
||||
): string | null {
|
||||
if (!endpoint) return null;
|
||||
if (selectedStates.size >= ALL_STATES.length) return endpoint;
|
||||
const url = new URL(endpoint, window.location.origin);
|
||||
url.searchParams.set('states', Array.from(selectedStates).join(','));
|
||||
url.searchParams.set("states", Array.from(selectedStates).join(","));
|
||||
return url.pathname + url.search;
|
||||
}
|
||||
|
||||
|
|
@ -249,7 +289,13 @@ export interface JobqGraphProps {
|
|||
// change identity so the effect below re-runs, giving a host an
|
||||
// explicit "refetch now" lever (bump it and re-render) without an
|
||||
// imperative ref into this component.
|
||||
export function JobqGraph({ endpoint, cancellable = false, onUpdate, onCancel, refreshToken = 0 }: JobqGraphProps) {
|
||||
export function JobqGraph({
|
||||
endpoint,
|
||||
cancellable = false,
|
||||
onUpdate,
|
||||
onCancel,
|
||||
refreshToken = 0,
|
||||
}: JobqGraphProps) {
|
||||
const [selectedStates, setSelectedStates] = useState<Set<NodeState>>(
|
||||
() => new Set(ALL_STATES.filter((s) => !DEFAULT_HIDDEN_STATES.has(s))),
|
||||
);
|
||||
|
|
@ -259,7 +305,8 @@ export function JobqGraph({ endpoint, cancellable = false, onUpdate, onCancel, r
|
|||
const toggleState = useCallback((state: NodeState) => {
|
||||
setSelectedStates((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(state)) next.delete(state); else next.add(state);
|
||||
if (next.has(state)) next.delete(state);
|
||||
else next.add(state);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
|
@ -271,7 +318,7 @@ export function JobqGraph({ endpoint, cancellable = false, onUpdate, onCancel, r
|
|||
(async () => {
|
||||
try {
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) throw new Error('http ' + r.status);
|
||||
if (!r.ok) throw new Error("http " + r.status);
|
||||
const data = (await r.json()) as GraphNode[];
|
||||
if (cancelled) return;
|
||||
setNodes(data);
|
||||
|
|
@ -282,11 +329,13 @@ export function JobqGraph({ endpoint, cancellable = false, onUpdate, onCancel, r
|
|||
setError(String(err));
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- selectedStates is a Set;
|
||||
// its *contents* are what should retrigger the fetch, not its identity, and the
|
||||
// string form below already changes identity exactly when contents do.
|
||||
}, [endpoint, Array.from(selectedStates).sort().join(','), refreshToken]);
|
||||
}, [endpoint, Array.from(selectedStates).sort().join(","), refreshToken]);
|
||||
|
||||
return (
|
||||
<div class="jg-root">
|
||||
|
|
@ -300,7 +349,12 @@ export function JobqGraph({ endpoint, cancellable = false, onUpdate, onCancel, r
|
|||
<p class="jg-empty">empty</p>
|
||||
) : (
|
||||
buildTree(nodes).map((root) => (
|
||||
<NodeView key={root.id} n={root} cancellable={cancellable} onCancel={onCancel} />
|
||||
<NodeView
|
||||
key={root.id}
|
||||
n={root}
|
||||
cancellable={cancellable}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -65,10 +65,10 @@
|
|||
animation: none;
|
||||
}
|
||||
}
|
||||
:root[data-motion='reduce'] .jg-node {
|
||||
:root[data-motion="reduce"] .jg-node {
|
||||
animation: none;
|
||||
}
|
||||
:root[data-motion='allow'] .jg-node {
|
||||
:root[data-motion="allow"] .jg-node {
|
||||
animation: jg-node-enter 160ms ease;
|
||||
}
|
||||
|
||||
|
|
@ -93,13 +93,30 @@
|
|||
min-width: 1.2em;
|
||||
text-align: center;
|
||||
}
|
||||
.jg-state-pending { color: var(--muted); }
|
||||
.jg-state-running { color: var(--cyan); }
|
||||
.jg-state-finishing { color: var(--cyan); opacity: 0.75; }
|
||||
.jg-state-done { color: var(--green); }
|
||||
.jg-state-failed { color: var(--red); }
|
||||
.jg-state-cancelled { color: var(--muted); text-decoration: line-through; }
|
||||
.jg-state-skipped { color: var(--muted); opacity: 0.5; }
|
||||
.jg-state-pending {
|
||||
color: var(--muted);
|
||||
}
|
||||
.jg-state-running {
|
||||
color: var(--cyan);
|
||||
}
|
||||
.jg-state-finishing {
|
||||
color: var(--cyan);
|
||||
opacity: 0.75;
|
||||
}
|
||||
.jg-state-done {
|
||||
color: var(--green);
|
||||
}
|
||||
.jg-state-failed {
|
||||
color: var(--red);
|
||||
}
|
||||
.jg-state-cancelled {
|
||||
color: var(--muted);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.jg-state-skipped {
|
||||
color: var(--muted);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Short-lived pulse applied by `NodeView` (JobqGraph.tsx) exactly when
|
||||
a node's own `state` value changes on an existing DOM node — a mount
|
||||
|
|
@ -123,10 +140,10 @@
|
|||
animation: none;
|
||||
}
|
||||
}
|
||||
:root[data-motion='reduce'] .jg-state-flash {
|
||||
:root[data-motion="reduce"] .jg-state-flash {
|
||||
animation: none;
|
||||
}
|
||||
:root[data-motion='allow'] .jg-state-flash {
|
||||
:root[data-motion="allow"] .jg-state-flash {
|
||||
animation: jg-state-flash 350ms ease;
|
||||
}
|
||||
|
||||
|
|
@ -149,7 +166,9 @@
|
|||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.15s ease, border-color 0.15s ease;
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
.jg-cancel-btn:hover,
|
||||
.jg-cancel-btn:focus-visible {
|
||||
|
|
@ -166,8 +185,13 @@
|
|||
grid-template-columns: auto 1fr;
|
||||
gap: 0 0.5em;
|
||||
}
|
||||
.jg-data dt { font-weight: 600; }
|
||||
.jg-data dd { margin: 0; word-break: break-word; }
|
||||
.jg-data dt {
|
||||
font-weight: 600;
|
||||
}
|
||||
.jg-data dd {
|
||||
margin: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.jg-waits-on {
|
||||
margin: 0.1em 0 0 1.6em;
|
||||
|
|
|
|||
|
|
@ -28,12 +28,19 @@
|
|||
// `refreshToken` to force a refetch. No mount wrapper: `render` is
|
||||
// already the re-render/diff entry point.
|
||||
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { useState, useEffect } from "preact/hooks";
|
||||
|
||||
// Mirrors `hive_jobq_wire::StateSchema` — only the subset this banner
|
||||
// cares about, not the full union `JobqGraph.tsx` mirrors, since a
|
||||
// rollup row's `state` is read by exact string match, not rendered.
|
||||
type NodeState = 'Pending' | 'Running' | 'Finishing' | 'Done' | 'Failed' | 'Cancelled' | 'Skipped';
|
||||
type NodeState =
|
||||
| "Pending"
|
||||
| "Running"
|
||||
| "Finishing"
|
||||
| "Done"
|
||||
| "Failed"
|
||||
| "Cancelled"
|
||||
| "Skipped";
|
||||
|
||||
interface StateCount {
|
||||
state: NodeState;
|
||||
|
|
@ -47,7 +54,11 @@ export interface JobqRollupProps {
|
|||
refreshToken?: number;
|
||||
}
|
||||
|
||||
export function JobqRollup({ endpoint, queueHref, refreshToken = 0 }: JobqRollupProps) {
|
||||
export function JobqRollup({
|
||||
endpoint,
|
||||
queueHref,
|
||||
refreshToken = 0,
|
||||
}: JobqRollupProps) {
|
||||
const [counts, setCounts] = useState<StateCount[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -66,12 +77,16 @@ export function JobqRollup({ endpoint, queueHref, refreshToken = 0 }: JobqRollup
|
|||
// ignore — keep the previous snapshot
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [endpoint, refreshToken]);
|
||||
|
||||
const byState = new Map(counts.map((c) => [c.state, c]));
|
||||
const running = (byState.get('Running')?.roots ?? 0) + (byState.get('Finishing')?.roots ?? 0);
|
||||
const queued = byState.get('Pending')?.roots ?? 0;
|
||||
const running =
|
||||
(byState.get("Running")?.roots ?? 0) +
|
||||
(byState.get("Finishing")?.roots ?? 0);
|
||||
const queued = byState.get("Pending")?.roots ?? 0;
|
||||
if (!running && !queued) return null;
|
||||
|
||||
const parts: string[] = [];
|
||||
|
|
@ -80,10 +95,12 @@ export function JobqRollup({ endpoint, queueHref, refreshToken = 0 }: JobqRollup
|
|||
|
||||
return (
|
||||
<div class="jqr-summary">
|
||||
<span class="jqr-glyph spinner">◐</span>{' '}
|
||||
<strong>build queue</strong> — {parts.join(' · ')}{' '}
|
||||
<span class="jqr-glyph spinner">◐</span> <strong>build queue</strong> —{" "}
|
||||
{parts.join(" · ")}{" "}
|
||||
{queueHref && (
|
||||
<a class="jqr-link" href={queueHref}>view queue →</a>
|
||||
<a class="jqr-link" href={queueHref}>
|
||||
view queue →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@
|
|||
margin-bottom: 0.6em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.jqr-summary strong { color: var(--amber); }
|
||||
.jqr-summary strong {
|
||||
color: var(--amber);
|
||||
}
|
||||
.jqr-link {
|
||||
margin-left: auto;
|
||||
color: var(--amber);
|
||||
|
|
@ -22,4 +24,6 @@
|
|||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.jqr-link:hover { text-decoration: underline; }
|
||||
.jqr-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,9 +19,9 @@
|
|||
// Other `.btn` consumers across the app stay on the light-DOM `.btn`
|
||||
// class for now — migrating them is a separate follow-up.
|
||||
|
||||
import { el } from './dom.js';
|
||||
import './hive-dialog/hive-dialog.js'; // registers <hive-dialog> — side-effect import
|
||||
import './hive-toast/hive-toast.js'; // registers <hive-toast> — side-effect import
|
||||
import { el } from "./dom.js";
|
||||
import "./hive-dialog/hive-dialog.js"; // registers <hive-dialog> — side-effect import
|
||||
import "./hive-toast/hive-toast.js"; // registers <hive-toast> — side-effect import
|
||||
|
||||
// openDialog({ title, message, content, buttons, danger, dismissable })
|
||||
// → Promise resolving to the clicked button's `value`, or `null` when the
|
||||
|
|
@ -37,9 +37,11 @@ import './hive-toast/hive-toast.js'; // registers <hive-toast> — side-effect i
|
|||
// stray Enter can't fire the destructive path), else the last button.
|
||||
export function openDialog(opts = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const dlg = document.createElement('hive-dialog');
|
||||
const dlg = document.createElement("hive-dialog");
|
||||
dlg._opts = opts;
|
||||
dlg.addEventListener('hive-dialog-close', (e) => resolve(e.detail), { once: true });
|
||||
dlg.addEventListener("hive-dialog-close", (e) => resolve(e.detail), {
|
||||
once: true,
|
||||
});
|
||||
document.body.append(dlg);
|
||||
});
|
||||
}
|
||||
|
|
@ -54,19 +56,31 @@ export function openDialog(opts = {}) {
|
|||
// doStop(r.graceful);
|
||||
export function themedConfirm(opts = {}) {
|
||||
const {
|
||||
title = '', message = '', danger = false,
|
||||
confirmLabel = 'confirm', cancelLabel = 'cancel', checkboxes = [],
|
||||
title = "",
|
||||
message = "",
|
||||
danger = false,
|
||||
confirmLabel = "confirm",
|
||||
cancelLabel = "cancel",
|
||||
checkboxes = [],
|
||||
} = opts;
|
||||
|
||||
const boxes = checkboxes.map((cb) => {
|
||||
const input = el('input', { type: 'checkbox', class: 'check', name: cb.name });
|
||||
const input = el("input", {
|
||||
type: "checkbox",
|
||||
class: "check",
|
||||
name: cb.name,
|
||||
});
|
||||
if (cb.checked) input.checked = true;
|
||||
const row = el('label', { class: 'checkrow' },
|
||||
input, el('span', {}, cb.label || cb.name));
|
||||
const row = el(
|
||||
"label",
|
||||
{ class: "checkrow" },
|
||||
input,
|
||||
el("span", {}, cb.label || cb.name),
|
||||
);
|
||||
return { input, row };
|
||||
});
|
||||
const content = boxes.length
|
||||
? el('div', { class: 'checks' }, ...boxes.map((b) => b.row))
|
||||
? el("div", { class: "checks" }, ...boxes.map((b) => b.row))
|
||||
: null;
|
||||
|
||||
return openDialog({
|
||||
|
|
@ -75,13 +89,20 @@ export function themedConfirm(opts = {}) {
|
|||
content,
|
||||
danger,
|
||||
buttons: [
|
||||
{ label: cancelLabel, value: null, class: 'cancel', autofocus: danger },
|
||||
{ label: confirmLabel, value: 'confirm', danger, class: 'confirm', autofocus: !danger },
|
||||
{ label: cancelLabel, value: null, class: "cancel", autofocus: danger },
|
||||
{
|
||||
label: confirmLabel,
|
||||
value: "confirm",
|
||||
danger,
|
||||
class: "confirm",
|
||||
autofocus: !danger,
|
||||
},
|
||||
],
|
||||
}).then((v) => {
|
||||
if (v !== 'confirm') return null;
|
||||
if (v !== "confirm") return null;
|
||||
const out = {};
|
||||
for (let i = 0; i < boxes.length; i++) out[checkboxes[i].name] = boxes[i].input.checked;
|
||||
for (let i = 0; i < boxes.length; i++)
|
||||
out[checkboxes[i].name] = boxes[i].input.checked;
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
|
@ -95,10 +116,19 @@ export function themedConfirm(opts = {}) {
|
|||
// (via openDialog).
|
||||
export function themedPrompt(opts = {}) {
|
||||
const {
|
||||
title = '', message = '', label = '', placeholder = '', value = '',
|
||||
confirmLabel = 'ok', cancelLabel = 'cancel',
|
||||
title = "",
|
||||
message = "",
|
||||
label = "",
|
||||
placeholder = "",
|
||||
value = "",
|
||||
confirmLabel = "ok",
|
||||
cancelLabel = "cancel",
|
||||
} = opts;
|
||||
const input = el('textarea', { class: 'input textarea', rows: '3', placeholder });
|
||||
const input = el("textarea", {
|
||||
class: "input textarea",
|
||||
rows: "3",
|
||||
placeholder,
|
||||
});
|
||||
if (value) input.value = value;
|
||||
// Enter submits (clicks the confirm button mounted by openDialog);
|
||||
// Shift+Enter falls through to the textarea's default newline insert.
|
||||
|
|
@ -106,24 +136,32 @@ export function themedPrompt(opts = {}) {
|
|||
// lives in, so this resolves to the dialog's own `.box`/confirm button
|
||||
// without leaking across instances. The confirm button is selected by
|
||||
// its `variant` attribute now (hive-btn.js), not a CSS class.
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
input.closest('.box')?.querySelector('[variant="confirm"]')?.click();
|
||||
input.closest(".box")?.querySelector('[variant="confirm"]')?.click();
|
||||
}
|
||||
});
|
||||
const content = el('div', { class: 'promptfield' },
|
||||
label ? el('label', { class: 'promptlabel' }, label) : null,
|
||||
input);
|
||||
const content = el(
|
||||
"div",
|
||||
{ class: "promptfield" },
|
||||
label ? el("label", { class: "promptlabel" }, label) : null,
|
||||
input,
|
||||
);
|
||||
const result = openDialog({
|
||||
title,
|
||||
message,
|
||||
content,
|
||||
buttons: [
|
||||
{ label: cancelLabel, value: '__cancel__', class: 'cancel' },
|
||||
{ label: confirmLabel, value: '__ok__', class: 'confirm', autofocus: true },
|
||||
{ label: cancelLabel, value: "__cancel__", class: "cancel" },
|
||||
{
|
||||
label: confirmLabel,
|
||||
value: "__ok__",
|
||||
class: "confirm",
|
||||
autofocus: true,
|
||||
},
|
||||
],
|
||||
}).then((v) => (v === '__ok__' ? input.value : null));
|
||||
}).then((v) => (v === "__ok__" ? input.value : null));
|
||||
// Prefer focusing the field over the OK button once the dialog has mounted.
|
||||
setTimeout(() => input.focus(), 0);
|
||||
return result;
|
||||
|
|
@ -138,24 +176,24 @@ export function themedPrompt(opts = {}) {
|
|||
// component — no theming, no encapsulation need), so it's styled with a
|
||||
// one-off inline style rather than a stylesheet.
|
||||
export function themedToast(message, opts = {}) {
|
||||
let container = document.getElementById('tc-toasts');
|
||||
let container = document.getElementById("tc-toasts");
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.id = 'tc-toasts';
|
||||
container = document.createElement("div");
|
||||
container.id = "tc-toasts";
|
||||
Object.assign(container.style, {
|
||||
position: 'fixed',
|
||||
top: '1em',
|
||||
right: '1em',
|
||||
zIndex: '1100',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5em',
|
||||
maxWidth: 'min(28em, 92vw)',
|
||||
pointerEvents: 'none',
|
||||
position: "fixed",
|
||||
top: "1em",
|
||||
right: "1em",
|
||||
zIndex: "1100",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.5em",
|
||||
maxWidth: "min(28em, 92vw)",
|
||||
pointerEvents: "none",
|
||||
});
|
||||
document.body.append(container);
|
||||
}
|
||||
const toast = document.createElement('hive-toast');
|
||||
const toast = document.createElement("hive-toast");
|
||||
toast._message = message;
|
||||
toast._opts = opts;
|
||||
container.append(toast);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
// `ExpandDetailsSetting` (the per-agent page's own settings popover) is
|
||||
// the only writer today; `Row.tsx` is the only reader.
|
||||
|
||||
const EXPAND_DETAILS_KEY = 'hive-agent-expand-details';
|
||||
const EXPAND_DETAILS_KEY = "hive-agent-expand-details";
|
||||
|
||||
// Whether a per-agent terminal's otherwise-collapsed `<details>` panels
|
||||
// (long tool-results, Write/Edit diffs, …) should default open. Pure
|
||||
|
|
@ -16,14 +16,14 @@ const EXPAND_DETAILS_KEY = 'hive-agent-expand-details';
|
|||
// any already-open agent tab without a reload.
|
||||
export function getExpandDetailsPref(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(EXPAND_DETAILS_KEY) === '1';
|
||||
return localStorage.getItem(EXPAND_DETAILS_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
export function setExpandDetailsPref(v: boolean): void {
|
||||
try {
|
||||
if (v) localStorage.setItem(EXPAND_DETAILS_KEY, '1');
|
||||
if (v) localStorage.setItem(EXPAND_DETAILS_KEY, "1");
|
||||
else localStorage.removeItem(EXPAND_DETAILS_KEY);
|
||||
} catch {
|
||||
/* localStorage unavailable — preference is session-only */
|
||||
|
|
|
|||
|
|
@ -23,16 +23,16 @@
|
|||
// once, high in its tree, with the SAME key strings passed to this
|
||||
// component, so the two stay in sync without this component owning any
|
||||
// page-specific naming decision.
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import type { ComponentChildren } from 'preact';
|
||||
import { Badge } from '../badge/Badge.js';
|
||||
import { GearIcon } from '../icons.js';
|
||||
import { useThemeOverride, type ThemeOverride } from './theme-apply.js';
|
||||
import { useMotionOverride, type MotionOverride } from './motion-apply.js';
|
||||
import './SettingsMenu.css';
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import type { ComponentChildren } from "preact";
|
||||
import { Badge } from "../badge/Badge.js";
|
||||
import { GearIcon } from "../icons.js";
|
||||
import { useThemeOverride, type ThemeOverride } from "./theme-apply.js";
|
||||
import { useMotionOverride, type MotionOverride } from "./motion-apply.js";
|
||||
import "./SettingsMenu.css";
|
||||
|
||||
const THEME_OPTIONS: ThemeOverride[] = ['system', 'light', 'dark'];
|
||||
const MOTION_OPTIONS: MotionOverride[] = ['system', 'allow', 'reduce'];
|
||||
const THEME_OPTIONS: ThemeOverride[] = ["system", "light", "dark"];
|
||||
const MOTION_OPTIONS: MotionOverride[] = ["system", "allow", "reduce"];
|
||||
|
||||
export interface SettingsMenuProps {
|
||||
themeKey: string;
|
||||
|
|
@ -51,7 +51,11 @@ export interface SettingsMenuProps {
|
|||
children?: ComponentChildren;
|
||||
}
|
||||
|
||||
export function SettingsMenu({ themeKey, motionKey, children }: SettingsMenuProps) {
|
||||
export function SettingsMenu({
|
||||
themeKey,
|
||||
motionKey,
|
||||
children,
|
||||
}: SettingsMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [theme, setTheme] = useThemeOverride(themeKey);
|
||||
|
|
@ -61,16 +65,21 @@ export function SettingsMenu({ themeKey, motionKey, children }: SettingsMenuProp
|
|||
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]);
|
||||
|
||||
|
|
@ -89,7 +98,9 @@ export function SettingsMenu({ themeKey, motionKey, children }: SettingsMenuProp
|
|||
<span>theme</span>
|
||||
<select
|
||||
value={theme}
|
||||
onChange={(e) => setTheme((e.target as HTMLSelectElement).value as ThemeOverride)}
|
||||
onChange={(e) =>
|
||||
setTheme((e.target as HTMLSelectElement).value as ThemeOverride)
|
||||
}
|
||||
>
|
||||
{THEME_OPTIONS.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
|
|
@ -102,7 +113,11 @@ export function SettingsMenu({ themeKey, motionKey, children }: SettingsMenuProp
|
|||
<span>motion</span>
|
||||
<select
|
||||
value={motion}
|
||||
onChange={(e) => setMotion((e.target as HTMLSelectElement).value as MotionOverride)}
|
||||
onChange={(e) =>
|
||||
setMotion(
|
||||
(e.target as HTMLSelectElement).value as MotionOverride,
|
||||
)
|
||||
}
|
||||
>
|
||||
{MOTION_OPTIONS.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
|
|
|
|||
|
|
@ -12,22 +12,28 @@
|
|||
// (see swarm-ui's `shell/Shell.css` file-top comment for a worked
|
||||
// example). A page with no motion-gated animation yet can still mount
|
||||
// this — the attribute is simply inert until something reads it.
|
||||
import { useEffect } from 'preact/hooks';
|
||||
import { useLocalSetting } from './settings-storage.js';
|
||||
import { useEffect } from "preact/hooks";
|
||||
import { useLocalSetting } from "./settings-storage.js";
|
||||
|
||||
export type MotionOverride = 'system' | 'reduce' | 'allow';
|
||||
export type MotionOverride = "system" | "reduce" | "allow";
|
||||
|
||||
export function useMotionOverride(key: string, fallback: MotionOverride = 'system') {
|
||||
export function useMotionOverride(
|
||||
key: string,
|
||||
fallback: MotionOverride = "system",
|
||||
) {
|
||||
return useLocalSetting<MotionOverride>(key, fallback);
|
||||
}
|
||||
|
||||
// Mounted once alongside `useApplyThemeOverride` — same single-mount-
|
||||
// point rationale.
|
||||
export function useApplyMotionOverride(key: string, fallback: MotionOverride = 'system'): void {
|
||||
export function useApplyMotionOverride(
|
||||
key: string,
|
||||
fallback: MotionOverride = "system",
|
||||
): void {
|
||||
const [override] = useMotionOverride(key, fallback);
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (override === 'system') {
|
||||
if (override === "system") {
|
||||
delete root.dataset.motion;
|
||||
} else {
|
||||
root.dataset.motion = override;
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
// reading) need their own same-tab signal. The tiny module-level pub/sub
|
||||
// below is that signal; it's deliberately not exported, callers only see
|
||||
// the hook.
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
const listeners = new Map<string, Set<() => void>>();
|
||||
|
||||
|
|
@ -70,10 +70,16 @@ function writeLocalSetting<T>(key: string, value: T): void {
|
|||
// this component's state through the identical "react to a change"
|
||||
// path every other subscriber uses — one path, not two that have to
|
||||
// agree.
|
||||
export function useLocalSetting<T>(key: string, fallback: T): [T, (value: T) => void] {
|
||||
export function useLocalSetting<T>(
|
||||
key: string,
|
||||
fallback: T,
|
||||
): [T, (value: T) => void] {
|
||||
const [value, setValue] = useState<T>(() => readLocalSetting(key, fallback));
|
||||
|
||||
useEffect(() => subscribe(key, () => setValue(readLocalSetting(key, fallback))), [key]);
|
||||
useEffect(
|
||||
() => subscribe(key, () => setValue(readLocalSetting(key, fallback))),
|
||||
[key],
|
||||
);
|
||||
|
||||
return [value, (next: T) => writeLocalSetting(key, next)];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,12 +13,15 @@
|
|||
// — `'system'` as a default silently reads as light-by-default for most
|
||||
// first-time visitors (mara: "light mode seems to be default"). Still
|
||||
// fully overridable per caller if a page ever wants a different default.
|
||||
import { useEffect } from 'preact/hooks';
|
||||
import { useLocalSetting } from './settings-storage.js';
|
||||
import { useEffect } from "preact/hooks";
|
||||
import { useLocalSetting } from "./settings-storage.js";
|
||||
|
||||
export type ThemeOverride = 'system' | 'light' | 'dark';
|
||||
export type ThemeOverride = "system" | "light" | "dark";
|
||||
|
||||
export function useThemeOverride(key: string, fallback: ThemeOverride = 'dark') {
|
||||
export function useThemeOverride(
|
||||
key: string,
|
||||
fallback: ThemeOverride = "dark",
|
||||
) {
|
||||
return useLocalSetting<ThemeOverride>(key, fallback);
|
||||
}
|
||||
|
||||
|
|
@ -27,11 +30,14 @@ export function useThemeOverride(key: string, fallback: ThemeOverride = 'dark')
|
|||
// same-tab subscription means `SettingsMenu` changing the value re-runs
|
||||
// this effect without either component needing to know about the other
|
||||
// directly.
|
||||
export function useApplyThemeOverride(key: string, fallback: ThemeOverride = 'dark'): void {
|
||||
export function useApplyThemeOverride(
|
||||
key: string,
|
||||
fallback: ThemeOverride = "dark",
|
||||
): void {
|
||||
const [override] = useThemeOverride(key, fallback);
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (override === 'system') {
|
||||
if (override === "system") {
|
||||
delete root.dataset.theme;
|
||||
} else {
|
||||
root.dataset.theme = override;
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@
|
|||
// one parsed stylesheet object across many instances — that's the case it
|
||||
// exists for.
|
||||
export function attachShadowCss(host, cssText, shadowInit = {}) {
|
||||
const root = host.attachShadow({ mode: 'open', ...shadowInit });
|
||||
const style = document.createElement('style');
|
||||
const root = host.attachShadow({ mode: "open", ...shadowInit });
|
||||
const style = document.createElement("style");
|
||||
style.textContent = cssText;
|
||||
root.append(style);
|
||||
return root;
|
||||
|
|
|
|||
|
|
@ -50,9 +50,15 @@
|
|||
transition: transform 220ms ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
:host(.open) { pointer-events: auto; }
|
||||
:host(.open) .side-panel-backdrop { opacity: 1; }
|
||||
:host(.open) .side-panel-drawer { transform: translateX(0); }
|
||||
:host(.open) {
|
||||
pointer-events: auto;
|
||||
}
|
||||
:host(.open) .side-panel-backdrop {
|
||||
opacity: 1;
|
||||
}
|
||||
:host(.open) .side-panel-drawer {
|
||||
transform: translateX(0);
|
||||
}
|
||||
.side-panel-resize {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
|
|
@ -100,9 +106,14 @@
|
|||
line-height: 1;
|
||||
padding: 0.15em 0.4em;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, color 0.15s ease;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
}
|
||||
.side-panel-close:hover {
|
||||
border-color: var(--red);
|
||||
color: var(--red);
|
||||
}
|
||||
.side-panel-close:hover { border-color: var(--red); color: var(--red); }
|
||||
.side-panel-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
|
|
|||
|
|
@ -27,54 +27,74 @@
|
|||
// had it) — making it available to every consumer (the agent UI didn't
|
||||
// have it before) is a deliberate behavior widening, not incidental.
|
||||
|
||||
import { el } from '../dom.js';
|
||||
import { attachShadowCss } from '../shadow-css.js';
|
||||
import sidePanelCss from './hive-side-panel.css';
|
||||
import { el } from "../dom.js";
|
||||
import { attachShadowCss } from "../shadow-css.js";
|
||||
import sidePanelCss from "./hive-side-panel.css";
|
||||
|
||||
// See docs/web-ui.md::Side panel for the hit-strip + pointer-capture +
|
||||
// localStorage persistence model; CSS clamps the stored value to min
|
||||
// 320px / max 96vw and out-of-range stored values are dropped silently.
|
||||
// Shared by both packages deliberately — see module header.
|
||||
const WIDTH_KEY = 'hyperhive:side-panel-width';
|
||||
const WIDTH_KEY = "hyperhive:side-panel-width";
|
||||
const WIDTH_MIN = 320;
|
||||
|
||||
class HiveSidePanel extends HTMLElement {
|
||||
connectedCallback() {
|
||||
const root = attachShadowCss(this, sidePanelCss);
|
||||
this.setAttribute('aria-hidden', 'true');
|
||||
this.setAttribute("aria-hidden", "true");
|
||||
this._owner = null;
|
||||
|
||||
this._titleEl = el('span', { class: 'side-panel-title', id: 'side-panel-title' });
|
||||
this._closeBtn = el('button', {
|
||||
type: 'button', class: 'side-panel-close', title: 'close (esc)',
|
||||
}, '✕');
|
||||
const head = el('header', { class: 'side-panel-head' }, this._titleEl, this._closeBtn);
|
||||
this._titleEl = el("span", {
|
||||
class: "side-panel-title",
|
||||
id: "side-panel-title",
|
||||
});
|
||||
this._closeBtn = el(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
class: "side-panel-close",
|
||||
title: "close (esc)",
|
||||
},
|
||||
"✕",
|
||||
);
|
||||
const head = el(
|
||||
"header",
|
||||
{ class: "side-panel-head" },
|
||||
this._titleEl,
|
||||
this._closeBtn,
|
||||
);
|
||||
|
||||
this._resizeHandle = el('div', {
|
||||
class: 'side-panel-resize',
|
||||
role: 'separator',
|
||||
'aria-orientation': 'vertical',
|
||||
'aria-label': 'drag to resize side panel',
|
||||
title: 'drag to resize',
|
||||
this._resizeHandle = el("div", {
|
||||
class: "side-panel-resize",
|
||||
role: "separator",
|
||||
"aria-orientation": "vertical",
|
||||
"aria-label": "drag to resize side panel",
|
||||
title: "drag to resize",
|
||||
});
|
||||
|
||||
this._bodyEl = el('div', { class: 'side-panel-body' }, el('slot'));
|
||||
this._bodyEl = el("div", { class: "side-panel-body" }, el("slot"));
|
||||
|
||||
this._drawer = el('aside', {
|
||||
class: 'side-panel-drawer',
|
||||
role: 'dialog',
|
||||
'aria-modal': 'true',
|
||||
'aria-labelledby': 'side-panel-title',
|
||||
}, this._resizeHandle, head, this._bodyEl);
|
||||
this._drawer = el(
|
||||
"aside",
|
||||
{
|
||||
class: "side-panel-drawer",
|
||||
role: "dialog",
|
||||
"aria-modal": "true",
|
||||
"aria-labelledby": "side-panel-title",
|
||||
},
|
||||
this._resizeHandle,
|
||||
head,
|
||||
this._bodyEl,
|
||||
);
|
||||
|
||||
this._backdrop = el('div', { class: 'side-panel-backdrop' });
|
||||
this._backdrop = el("div", { class: "side-panel-backdrop" });
|
||||
|
||||
root.append(this._backdrop, this._drawer);
|
||||
|
||||
this._closeBtn.addEventListener('click', () => this.close());
|
||||
this._backdrop.addEventListener('click', () => this.close());
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && this.classList.contains('open')) this.close();
|
||||
this._closeBtn.addEventListener("click", () => this.close());
|
||||
this._backdrop.addEventListener("click", () => this.close());
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && this.classList.contains("open")) this.close();
|
||||
});
|
||||
|
||||
this._applyStoredWidth();
|
||||
|
|
@ -89,8 +109,8 @@ class HiveSidePanel extends HTMLElement {
|
|||
this._owner = name;
|
||||
this._titleEl.textContent = title;
|
||||
this.replaceChildren(...(content ? [content] : []));
|
||||
this.classList.add('open');
|
||||
this.setAttribute('aria-hidden', 'false');
|
||||
this.classList.add("open");
|
||||
this.setAttribute("aria-hidden", "false");
|
||||
}
|
||||
|
||||
refresh(name, title, content) {
|
||||
|
|
@ -101,8 +121,8 @@ class HiveSidePanel extends HTMLElement {
|
|||
|
||||
close() {
|
||||
this._owner = null;
|
||||
this.classList.remove('open');
|
||||
this.setAttribute('aria-hidden', 'true');
|
||||
this.classList.remove("open");
|
||||
this.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
|
||||
currentOwner() {
|
||||
|
|
@ -120,64 +140,79 @@ class HiveSidePanel extends HTMLElement {
|
|||
|
||||
_applyStoredWidth() {
|
||||
const raw = (() => {
|
||||
try { return localStorage.getItem(WIDTH_KEY); }
|
||||
catch { return null; }
|
||||
try {
|
||||
return localStorage.getItem(WIDTH_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
if (!raw) return;
|
||||
const parsed = parseInt(raw, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return;
|
||||
this._drawer.style.setProperty('--side-panel-w', this._clampWidth(parsed) + 'px');
|
||||
this._drawer.style.setProperty(
|
||||
"--side-panel-w",
|
||||
this._clampWidth(parsed) + "px",
|
||||
);
|
||||
}
|
||||
|
||||
_bindResize() {
|
||||
const handle = this._resizeHandle;
|
||||
const drawer = this._drawer;
|
||||
let dragging = false;
|
||||
handle.addEventListener('pointerdown', (e) => {
|
||||
handle.addEventListener("pointerdown", (e) => {
|
||||
e.preventDefault();
|
||||
dragging = true;
|
||||
document.body.classList.add('side-panel-resizing');
|
||||
document.body.classList.add("side-panel-resizing");
|
||||
// Shadow-scoped counterpart of the global `body.side-panel-resizing`
|
||||
// class below — the resize handle's own hover/drag appearance lives
|
||||
// inside this element's shadow tree, which the global class (a
|
||||
// light-DOM-only escape hatch for the page-wide cursor override)
|
||||
// can't reach.
|
||||
this.classList.add('resizing');
|
||||
this.classList.add("resizing");
|
||||
// Capture so we keep getting pointermove even when the cursor
|
||||
// outpaces the handle band (drag-fast-then-pause loses the
|
||||
// handle's :hover state otherwise).
|
||||
try { handle.setPointerCapture(e.pointerId); } catch { /* legacy */ }
|
||||
try {
|
||||
handle.setPointerCapture(e.pointerId);
|
||||
} catch {
|
||||
/* legacy */
|
||||
}
|
||||
});
|
||||
document.addEventListener('pointermove', (e) => {
|
||||
document.addEventListener("pointermove", (e) => {
|
||||
if (!dragging) return;
|
||||
// Drawer is anchored to the right edge — width = viewport - pointer X.
|
||||
const w = this._clampWidth(document.documentElement.clientWidth - e.clientX);
|
||||
drawer.style.setProperty('--side-panel-w', w + 'px');
|
||||
const w = this._clampWidth(
|
||||
document.documentElement.clientWidth - e.clientX,
|
||||
);
|
||||
drawer.style.setProperty("--side-panel-w", w + "px");
|
||||
});
|
||||
const stopDrag = () => {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
document.body.classList.remove('side-panel-resizing');
|
||||
this.classList.remove('resizing');
|
||||
document.body.classList.remove("side-panel-resizing");
|
||||
this.classList.remove("resizing");
|
||||
// Persist the final width. Read the actual rendered width
|
||||
// rather than re-deriving so the stored value matches what
|
||||
// the operator saw at mouseup.
|
||||
const w = drawer.getBoundingClientRect().width;
|
||||
try { localStorage.setItem(WIDTH_KEY, String(Math.round(w))); }
|
||||
catch { /* localStorage unavailable — width is session-only */ }
|
||||
try {
|
||||
localStorage.setItem(WIDTH_KEY, String(Math.round(w)));
|
||||
} catch {
|
||||
/* localStorage unavailable — width is session-only */
|
||||
}
|
||||
};
|
||||
document.addEventListener('pointerup', stopDrag);
|
||||
document.addEventListener('pointercancel', stopDrag);
|
||||
document.addEventListener("pointerup", stopDrag);
|
||||
document.addEventListener("pointercancel", stopDrag);
|
||||
// Re-clamp on viewport resize so a persisted width that exceeds
|
||||
// 96vw doesn't push the drawer off-screen after a window shrink.
|
||||
window.addEventListener('resize', () => {
|
||||
window.addEventListener("resize", () => {
|
||||
if (dragging) return;
|
||||
const cur = drawer.getBoundingClientRect().width;
|
||||
const clamped = this._clampWidth(cur);
|
||||
if (clamped !== Math.round(cur)) {
|
||||
drawer.style.setProperty('--side-panel-w', clamped + 'px');
|
||||
drawer.style.setProperty("--side-panel-w", clamped + "px");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
customElements.define('hive-side-panel', HiveSidePanel);
|
||||
customElements.define("hive-side-panel", HiveSidePanel);
|
||||
|
|
|
|||
|
|
@ -27,29 +27,37 @@
|
|||
// call site keeps working unchanged. Call configure() exactly once per
|
||||
// element (throws if called twice), mirroring the old call sites.
|
||||
|
||||
import { el } from '../dom.js';
|
||||
import { createTabStrip } from './tabs.js';
|
||||
import { el } from "../dom.js";
|
||||
import { createTabStrip } from "./tabs.js";
|
||||
|
||||
class HiveTabStrip extends HTMLElement {
|
||||
configure({ tabs, defaultId, onShow }) {
|
||||
if (this._api) {
|
||||
throw new Error('hive-tab-strip: configure() called twice on the same element');
|
||||
throw new Error(
|
||||
"hive-tab-strip: configure() called twice on the same element",
|
||||
);
|
||||
}
|
||||
const prefix = this.getAttribute('prefix');
|
||||
const prefix = this.getAttribute("prefix");
|
||||
if (!prefix) {
|
||||
throw new Error('hive-tab-strip: missing required `prefix` attribute');
|
||||
throw new Error("hive-tab-strip: missing required `prefix` attribute");
|
||||
}
|
||||
for (const tab of tabs) {
|
||||
const a = el('a', {
|
||||
class: 'hive-tab',
|
||||
id: `${prefix}-tab-${tab.id}`,
|
||||
href: `#${tab.id}`,
|
||||
role: 'tab',
|
||||
'aria-controls': `${prefix}-pane-${tab.id}`,
|
||||
'data-tab': tab.id,
|
||||
}, tab.label);
|
||||
const a = el(
|
||||
"a",
|
||||
{
|
||||
class: "hive-tab",
|
||||
id: `${prefix}-tab-${tab.id}`,
|
||||
href: `#${tab.id}`,
|
||||
role: "tab",
|
||||
"aria-controls": `${prefix}-pane-${tab.id}`,
|
||||
"data-tab": tab.id,
|
||||
},
|
||||
tab.label,
|
||||
);
|
||||
if (tab.badgeId) {
|
||||
a.append(el('span', { class: 'tab-count', id: tab.badgeId, hidden: '' }));
|
||||
a.append(
|
||||
el("span", { class: "tab-count", id: tab.badgeId, hidden: "" }),
|
||||
);
|
||||
}
|
||||
this.append(a);
|
||||
}
|
||||
|
|
@ -57,4 +65,4 @@ class HiveTabStrip extends HTMLElement {
|
|||
return this._api;
|
||||
}
|
||||
}
|
||||
customElements.define('hive-tab-strip', HiveTabStrip);
|
||||
customElements.define("hive-tab-strip", HiveTabStrip);
|
||||
|
|
|
|||
|
|
@ -23,7 +23,9 @@
|
|||
font-size: 0.85em;
|
||||
letter-spacing: 0.05em;
|
||||
cursor: pointer;
|
||||
transition: background 100ms, color 100ms;
|
||||
transition:
|
||||
background 100ms,
|
||||
color 100ms;
|
||||
}
|
||||
|
||||
.hive-tab:hover {
|
||||
|
|
|
|||
|
|
@ -27,44 +27,44 @@ export function createTabStrip(tabbar, opts = {}) {
|
|||
if (!tabbar) {
|
||||
return { show() {}, active: () => null };
|
||||
}
|
||||
const tabs = Array.from(tabbar.querySelectorAll('[data-tab]'));
|
||||
const ids = tabs.map((t) => t.getAttribute('data-tab'));
|
||||
const tabs = Array.from(tabbar.querySelectorAll("[data-tab]"));
|
||||
const ids = tabs.map((t) => t.getAttribute("data-tab"));
|
||||
const fallback = defaultId && ids.includes(defaultId) ? defaultId : ids[0];
|
||||
|
||||
const paneFor = (id) => document.querySelector(`[data-tab-pane="${id}"]`);
|
||||
|
||||
const active = () => {
|
||||
const hash = location.hash.replace(/^#/, '');
|
||||
const hash = location.hash.replace(/^#/, "");
|
||||
return ids.includes(hash) ? hash : fallback;
|
||||
};
|
||||
|
||||
const show = (id) => {
|
||||
const target = ids.includes(id) ? id : fallback;
|
||||
for (const tab of tabs) {
|
||||
const tid = tab.getAttribute('data-tab');
|
||||
const tid = tab.getAttribute("data-tab");
|
||||
const on = tid === target;
|
||||
tab.classList.toggle('hive-tab--active', on);
|
||||
tab.setAttribute('aria-selected', String(on));
|
||||
tab.classList.toggle("hive-tab--active", on);
|
||||
tab.setAttribute("aria-selected", String(on));
|
||||
const pane = paneFor(tid);
|
||||
if (pane) pane.hidden = !on;
|
||||
}
|
||||
if (typeof onShow === 'function') onShow(target);
|
||||
if (typeof onShow === "function") onShow(target);
|
||||
};
|
||||
|
||||
// Drive every activation through the hash so deep-links + back/forward
|
||||
// stay authoritative; the hashchange listener does the actual showing.
|
||||
for (const tab of tabs) {
|
||||
tab.addEventListener('click', (e) => {
|
||||
tab.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
const id = tab.getAttribute('data-tab');
|
||||
if (location.hash.replace(/^#/, '') === id) {
|
||||
const id = tab.getAttribute("data-tab");
|
||||
if (location.hash.replace(/^#/, "") === id) {
|
||||
show(id); // same hash → no hashchange, re-show directly
|
||||
} else {
|
||||
location.hash = id;
|
||||
}
|
||||
});
|
||||
}
|
||||
window.addEventListener('hashchange', () => show(active()));
|
||||
window.addEventListener("hashchange", () => show(active()));
|
||||
|
||||
show(active()); // initial render from the current hash
|
||||
return { show, active };
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@
|
|||
border: 1px solid var(--purple-dim);
|
||||
box-shadow: inset 0 0 24px rgba(0, 0, 0, 0.7);
|
||||
border-radius: 4px;
|
||||
font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
|
||||
font-family:
|
||||
"JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
|
||||
font-size: 0.92em;
|
||||
color: var(--fg);
|
||||
margin-top: 0.6em;
|
||||
|
|
@ -64,8 +65,14 @@
|
|||
animation: none;
|
||||
}
|
||||
@keyframes row-fade-in {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
/* Unified prefix column for every row kind. The glyph (`→ ← · ◆ ✓ ✗ ⌁ !`)
|
||||
is the first character of the row's text content; `padding-left` reserves
|
||||
|
|
@ -83,7 +90,9 @@
|
|||
text-indent: -1.4em;
|
||||
margin: 0.1em 0;
|
||||
}
|
||||
.live .row + .row { border-top: 0; }
|
||||
.live .row + .row {
|
||||
border-top: 0;
|
||||
}
|
||||
/* Fixed-width icon column. Rows built with an `icon` (see terminal.js
|
||||
`row()` / `details()`) put it in a `.row-glyph` element instead of as a
|
||||
bare first character. `inline-block` with a fixed `width` means the icon
|
||||
|
|
@ -107,10 +116,20 @@
|
|||
identity (this is a turn boundary, this is a tool call) is carried by
|
||||
the row's icon (`◆`, `🔧`, `✅`, …) and summary text instead of colour —
|
||||
see docs/web-ui/terminal-rendering.md. */
|
||||
.live .level-debug { color: var(--muted); }
|
||||
.live .level-info { color: var(--fg); }
|
||||
.live .level-warn { color: var(--amber); border-left-color: var(--amber); }
|
||||
.live .level-error { color: var(--red); border-left-color: var(--red); }
|
||||
.live .level-debug {
|
||||
color: var(--muted);
|
||||
}
|
||||
.live .level-info {
|
||||
color: var(--fg);
|
||||
}
|
||||
.live .level-warn {
|
||||
color: var(--amber);
|
||||
border-left-color: var(--amber);
|
||||
}
|
||||
.live .level-error {
|
||||
color: var(--red);
|
||||
border-left-color: var(--red);
|
||||
}
|
||||
/* `badge-pulse` itself is no longer used by any terminal row (the
|
||||
turn-start `unread` count it animated is gone — see term_msg.rs's
|
||||
module doc), but agent.css's `.state-badge.state-thinking`/
|
||||
|
|
@ -118,13 +137,23 @@
|
|||
`@import "@hive/shared/terminal.css"` — keep the definition here, drop
|
||||
only the terminal-specific `.unread-badge` selector that used it. */
|
||||
@keyframes badge-pulse {
|
||||
0%, 100% { opacity: 1; text-shadow: 0 0 6px color-mix(in srgb, var(--amber) 55%, transparent); }
|
||||
50% { opacity: 0.7; text-shadow: 0 0 14px color-mix(in srgb, var(--amber) 95%, transparent); }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
text-shadow: 0 0 6px color-mix(in srgb, var(--amber) 55%, transparent);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
text-shadow: 0 0 14px color-mix(in srgb, var(--amber) 95%, transparent);
|
||||
}
|
||||
}
|
||||
/* Any child block (markdown body, nested details) resets the parent
|
||||
row's hanging indent so the content lays out from column 0 of the
|
||||
body area. */
|
||||
.live .row .md, .live .row > details { text-indent: 0; }
|
||||
.live .row .md,
|
||||
.live .row > details {
|
||||
text-indent: 0;
|
||||
}
|
||||
/* "↓ N new" pill: shown when new rows arrive while the operator is
|
||||
scrolled up; click to jump to bottom. Positioned by the wrapper's
|
||||
`position: relative` (terminal-wrap supplies it; pages that skip the
|
||||
|
|
@ -147,14 +176,18 @@
|
|||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
pointer-events: none;
|
||||
transition: opacity 160ms ease, transform 160ms ease;
|
||||
transition:
|
||||
opacity 160ms ease,
|
||||
transform 160ms ease;
|
||||
}
|
||||
.tail-pill.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
.tail-pill:hover { filter: brightness(1.1); }
|
||||
.tail-pill:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
/* "↑ load older" pill: sits inline at the top of the log (not
|
||||
absolutely positioned) so it scrolls with the content. Appears
|
||||
when `has_more` is true after initial history load. */
|
||||
|
|
@ -172,7 +205,9 @@
|
|||
padding: 0.4em 1em;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: color 120ms ease, background 120ms ease;
|
||||
transition:
|
||||
color 120ms ease,
|
||||
background 120ms ease;
|
||||
}
|
||||
.load-more-pill:hover:not(:disabled) {
|
||||
color: var(--fg);
|
||||
|
|
@ -235,10 +270,12 @@ details.row > summary > .summary-text:only-child {
|
|||
text-indent: -1.4em;
|
||||
}
|
||||
details.row > summary > .summary-text::before {
|
||||
content: '▸ ';
|
||||
content: "▸ ";
|
||||
color: inherit;
|
||||
}
|
||||
details.row[open] > summary > .summary-text::before { content: '▾ '; }
|
||||
details.row[open] > summary > .summary-text::before {
|
||||
content: "▾ ";
|
||||
}
|
||||
details.row > pre.diff-body,
|
||||
details.row > pre.tool-body {
|
||||
margin: 0.3em 0 0.4em 0;
|
||||
|
|
@ -251,16 +288,30 @@ details.row > pre.tool-body {
|
|||
max-height: 22em;
|
||||
overflow-y: auto;
|
||||
}
|
||||
details.row > pre.tool-body { color: var(--fg); }
|
||||
details.row > pre.diff-body .diff-add { color: var(--green); }
|
||||
details.row > pre.diff-body .diff-del { color: var(--red); }
|
||||
details.row > pre.diff-body .diff-ctx { color: var(--fg); }
|
||||
details.row > pre.tool-body {
|
||||
color: var(--fg);
|
||||
}
|
||||
details.row > pre.diff-body .diff-add {
|
||||
color: var(--green);
|
||||
}
|
||||
details.row > pre.diff-body .diff-del {
|
||||
color: var(--red);
|
||||
}
|
||||
details.row > pre.diff-body .diff-ctx {
|
||||
color: var(--fg);
|
||||
}
|
||||
/* Markdown body inside a row (assistant text, send/recv message
|
||||
bodies). Inline elements get muted accents; block elements
|
||||
reset the parent row's hanging indent so content lays out cleanly. */
|
||||
.live .row .md p { margin: 0.2em 0; }
|
||||
.live .row .md p:first-child { margin-top: 0; }
|
||||
.live .row .md p:last-child { margin-bottom: 0; }
|
||||
.live .row .md p {
|
||||
margin: 0.2em 0;
|
||||
}
|
||||
.live .row .md p:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.live .row .md p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.live .row .md code {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
padding: 0.05em 0.3em;
|
||||
|
|
@ -281,14 +332,34 @@ details.row > pre.diff-body .diff-ctx { color: var(--fg); }
|
|||
padding: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
.live .row .md a { color: var(--cyan); text-decoration: underline; }
|
||||
.live .row .md a {
|
||||
color: var(--cyan);
|
||||
text-decoration: underline;
|
||||
}
|
||||
/* Auto-linkified bare URLs in plain rows + tool-body blocks. */
|
||||
.live .row a { color: var(--cyan); text-decoration: underline; }
|
||||
.live .row a:hover { color: var(--fg); }
|
||||
.live .row .md strong { color: inherit; font-weight: bold; }
|
||||
.live .row .md em { color: inherit; font-style: italic; }
|
||||
.live .row .md ul, .live .row .md ol { margin: 0.2em 0 0.2em 1.4em; padding: 0; }
|
||||
.live .row .md li { margin: 0.05em 0; }
|
||||
.live .row a {
|
||||
color: var(--cyan);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.live .row a:hover {
|
||||
color: var(--fg);
|
||||
}
|
||||
.live .row .md strong {
|
||||
color: inherit;
|
||||
font-weight: bold;
|
||||
}
|
||||
.live .row .md em {
|
||||
color: inherit;
|
||||
font-style: italic;
|
||||
}
|
||||
.live .row .md ul,
|
||||
.live .row .md ol {
|
||||
margin: 0.2em 0 0.2em 1.4em;
|
||||
padding: 0;
|
||||
}
|
||||
.live .row .md li {
|
||||
margin: 0.05em 0;
|
||||
}
|
||||
.live .row .md blockquote {
|
||||
margin: 0.2em 0;
|
||||
padding-left: 0.6em;
|
||||
|
|
|
|||
|
|
@ -23,11 +23,11 @@ export function create(opts) {
|
|||
// row/details/etc. into a detached temp element while prepending older
|
||||
// history (restored before any scrollTop adjustments).
|
||||
let log = opts.logEl;
|
||||
if (!log) throw new Error('HiveTerminal.create: logEl is required');
|
||||
if (!log) throw new Error("HiveTerminal.create: logEl is required");
|
||||
const rootLog = log; // always the real DOM element — never reassigned
|
||||
const renderers = opts.renderers || {};
|
||||
const defaultRender = renderers._default
|
||||
|| ((ev, api) => api.row('note', JSON.stringify(ev)));
|
||||
const defaultRender =
|
||||
renderers._default || ((ev, api) => api.row("note", JSON.stringify(ev)));
|
||||
const pillAnchor = opts.pillAnchor || log.parentElement || log;
|
||||
|
||||
let placeholderEl = null;
|
||||
|
|
@ -55,7 +55,9 @@ export function create(opts) {
|
|||
let scrollAnimRaf = 0;
|
||||
|
||||
function isNearBottom() {
|
||||
return log.scrollHeight - log.scrollTop - log.clientHeight <= NEAR_BOTTOM_PX;
|
||||
return (
|
||||
log.scrollHeight - log.scrollTop - log.clientHeight <= NEAR_BOTTOM_PX
|
||||
);
|
||||
}
|
||||
// Snap the log to the bottom with a brief eased animation. Cancels
|
||||
// any in-flight frame loop so back-to-back snaps coalesce; falls
|
||||
|
|
@ -99,23 +101,23 @@ export function create(opts) {
|
|||
}
|
||||
function ensurePill() {
|
||||
if (pill) return pill;
|
||||
pill = document.createElement('button');
|
||||
pill.type = 'button';
|
||||
pill.className = 'tail-pill';
|
||||
pill.addEventListener('click', () => snapToBottom());
|
||||
pill = document.createElement("button");
|
||||
pill.type = "button";
|
||||
pill.className = "tail-pill";
|
||||
pill.addEventListener("click", () => snapToBottom());
|
||||
pillAnchor.appendChild(pill);
|
||||
return pill;
|
||||
}
|
||||
function updatePill() {
|
||||
if (unseen <= 0) {
|
||||
if (pill) pill.classList.remove('visible');
|
||||
if (pill) pill.classList.remove("visible");
|
||||
return;
|
||||
}
|
||||
ensurePill();
|
||||
pill.textContent = '↓ ' + unseen + ' new';
|
||||
pill.classList.add('visible');
|
||||
pill.textContent = "↓ " + unseen + " new";
|
||||
pill.classList.add("visible");
|
||||
}
|
||||
log.addEventListener('scroll', () => {
|
||||
log.addEventListener("scroll", () => {
|
||||
// Sticky-bottom intent tracking. Outside an animation this is
|
||||
// straightforward — stickToBottom = isNearBottom(). During a
|
||||
// smooth-snap animation we swallow most of the event to avoid a
|
||||
|
|
@ -137,11 +139,18 @@ export function create(opts) {
|
|||
// MO stops calling snapToBottom() and the gate expires.
|
||||
stickToBottom = false;
|
||||
}
|
||||
if (stickToBottom) { unseen = 0; updatePill(); }
|
||||
if (stickToBottom) {
|
||||
unseen = 0;
|
||||
updatePill();
|
||||
}
|
||||
if (inAnim) return;
|
||||
// Auto-fetch older history when the user scrolls near the top — no
|
||||
// click required; the load-more pill stays as a visual indicator.
|
||||
if (rootLog.scrollTop <= LOAD_MORE_SCROLL_PX && histHasMore && !histLoading) {
|
||||
if (
|
||||
rootLog.scrollTop <= LOAD_MORE_SCROLL_PX &&
|
||||
histHasMore &&
|
||||
!histLoading
|
||||
) {
|
||||
loadMore();
|
||||
}
|
||||
});
|
||||
|
|
@ -174,8 +183,8 @@ export function create(opts) {
|
|||
}
|
||||
function placeholder(text) {
|
||||
clearPlaceholder();
|
||||
const e = document.createElement('div');
|
||||
e.className = 'row note';
|
||||
const e = document.createElement("div");
|
||||
e.className = "row note";
|
||||
e.textContent = text;
|
||||
log.appendChild(e);
|
||||
placeholderEl = e;
|
||||
|
|
@ -186,8 +195,8 @@ export function create(opts) {
|
|||
// Optional: callers that pass no `icon` keep the bare first-character
|
||||
// prefix the older rows rely on.
|
||||
function glyphSpan(icon) {
|
||||
const g = document.createElement('span');
|
||||
g.className = 'row-glyph';
|
||||
const g = document.createElement("span");
|
||||
g.className = "row-glyph";
|
||||
g.textContent = icon;
|
||||
return g;
|
||||
}
|
||||
|
|
@ -196,10 +205,10 @@ export function create(opts) {
|
|||
// caret (CSS `.summary-text::before`) then leads the text, not the icon,
|
||||
// so the icon stays aligned with flat-row icons.
|
||||
function buildSummary(summary, icon) {
|
||||
const s = document.createElement('summary');
|
||||
if (icon != null && icon !== '') s.appendChild(glyphSpan(icon));
|
||||
const st = document.createElement('span');
|
||||
st.className = 'summary-text';
|
||||
const s = document.createElement("summary");
|
||||
if (icon != null && icon !== "") s.appendChild(glyphSpan(icon));
|
||||
const st = document.createElement("span");
|
||||
st.className = "summary-text";
|
||||
st.textContent = summary;
|
||||
s.appendChild(st);
|
||||
return s;
|
||||
|
|
@ -207,9 +216,9 @@ export function create(opts) {
|
|||
function row(cls, text, icon) {
|
||||
clearPlaceholder();
|
||||
const wasNearBottom = isNearBottom();
|
||||
const e = document.createElement('div');
|
||||
e.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
|
||||
if (icon != null && icon !== '') e.appendChild(glyphSpan(icon));
|
||||
const e = document.createElement("div");
|
||||
e.className = "row " + (cls || "") + (currentNoAnim ? " no-anim" : "");
|
||||
if (icon != null && icon !== "") e.appendChild(glyphSpan(icon));
|
||||
e.appendChild(linkify(text));
|
||||
log.appendChild(e);
|
||||
afterAppend(wasNearBottom);
|
||||
|
|
@ -222,10 +231,10 @@ export function create(opts) {
|
|||
function mutableRow(cls, text, icon) {
|
||||
clearPlaceholder();
|
||||
const wasNearBottom = isNearBottom();
|
||||
const e = document.createElement('div');
|
||||
e.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
|
||||
if (icon != null && icon !== '') e.appendChild(glyphSpan(icon));
|
||||
const tn = document.createTextNode(text == null ? '' : String(text));
|
||||
const e = document.createElement("div");
|
||||
e.className = "row " + (cls || "") + (currentNoAnim ? " no-anim" : "");
|
||||
if (icon != null && icon !== "") e.appendChild(glyphSpan(icon));
|
||||
const tn = document.createTextNode(text == null ? "" : String(text));
|
||||
e.appendChild(tn);
|
||||
log.appendChild(e);
|
||||
afterAppend(wasNearBottom);
|
||||
|
|
@ -240,17 +249,19 @@ export function create(opts) {
|
|||
// already set `d.open = true` themselves after calling this — this only
|
||||
// changes the *default* for panels that would otherwise start closed.
|
||||
function wantsExpandedDefault() {
|
||||
return typeof opts.expandDetails === 'function' ? !!opts.expandDetails() : !!opts.expandDetails;
|
||||
return typeof opts.expandDetails === "function"
|
||||
? !!opts.expandDetails()
|
||||
: !!opts.expandDetails;
|
||||
}
|
||||
function details(cls, summary, body, icon) {
|
||||
clearPlaceholder();
|
||||
const wasNearBottom = isNearBottom();
|
||||
const d = document.createElement('details');
|
||||
d.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
|
||||
const d = document.createElement("details");
|
||||
d.className = "row " + (cls || "") + (currentNoAnim ? " no-anim" : "");
|
||||
if (wantsExpandedDefault()) d.open = true;
|
||||
d.appendChild(buildSummary(summary, icon));
|
||||
const pre = document.createElement('pre');
|
||||
pre.className = 'tool-body';
|
||||
const pre = document.createElement("pre");
|
||||
pre.className = "tool-body";
|
||||
pre.appendChild(linkify(body));
|
||||
d.appendChild(pre);
|
||||
log.appendChild(d);
|
||||
|
|
@ -260,18 +271,18 @@ export function create(opts) {
|
|||
function detailsDiff(cls, summary, body, icon) {
|
||||
clearPlaceholder();
|
||||
const wasNearBottom = isNearBottom();
|
||||
const d = document.createElement('details');
|
||||
d.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
|
||||
const d = document.createElement("details");
|
||||
d.className = "row " + (cls || "") + (currentNoAnim ? " no-anim" : "");
|
||||
if (wantsExpandedDefault()) d.open = true;
|
||||
d.appendChild(buildSummary(summary, icon));
|
||||
const pre = document.createElement('pre');
|
||||
pre.className = 'tool-body diff-body';
|
||||
for (const line of String(body).split('\n')) {
|
||||
const span = document.createElement('span');
|
||||
if (line.startsWith('+ ')) span.className = 'diff-add';
|
||||
else if (line.startsWith('- ')) span.className = 'diff-del';
|
||||
else span.className = 'diff-ctx';
|
||||
span.textContent = line + '\n';
|
||||
const pre = document.createElement("pre");
|
||||
pre.className = "tool-body diff-body";
|
||||
for (const line of String(body).split("\n")) {
|
||||
const span = document.createElement("span");
|
||||
if (line.startsWith("+ ")) span.className = "diff-add";
|
||||
else if (line.startsWith("- ")) span.className = "diff-del";
|
||||
else span.className = "diff-ctx";
|
||||
span.textContent = line + "\n";
|
||||
pre.appendChild(span);
|
||||
}
|
||||
d.appendChild(pre);
|
||||
|
|
@ -281,22 +292,33 @@ export function create(opts) {
|
|||
}
|
||||
|
||||
function api(extra) {
|
||||
return Object.assign({
|
||||
row, mutableRow, details, detailsDiff, placeholder, linkify,
|
||||
fromHistory: false,
|
||||
}, extra || {});
|
||||
return Object.assign(
|
||||
{
|
||||
row,
|
||||
mutableRow,
|
||||
details,
|
||||
detailsDiff,
|
||||
placeholder,
|
||||
linkify,
|
||||
fromHistory: false,
|
||||
},
|
||||
extra || {},
|
||||
);
|
||||
}
|
||||
function dispatch(ev, fromHistory) {
|
||||
const r = renderers[ev.kind] || defaultRender;
|
||||
try {
|
||||
r(ev, api({ fromHistory }));
|
||||
} catch (err) {
|
||||
console.error('terminal renderer threw', ev, err);
|
||||
row('note', '[render err] ' + (err && err.message ? err.message : err));
|
||||
console.error("terminal renderer threw", ev, err);
|
||||
row("note", "[render err] " + (err && err.message ? err.message : err));
|
||||
}
|
||||
if (opts.onAnyEvent) {
|
||||
try { opts.onAnyEvent(ev, { fromHistory }); }
|
||||
catch (err) { console.error('onAnyEvent threw', err); }
|
||||
try {
|
||||
opts.onAnyEvent(ev, { fromHistory });
|
||||
} catch (err) {
|
||||
console.error("onAnyEvent threw", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -317,29 +339,36 @@ export function create(opts) {
|
|||
return;
|
||||
}
|
||||
if (!loadMoreBtn) {
|
||||
loadMoreBtn = document.createElement('button');
|
||||
loadMoreBtn.type = 'button';
|
||||
loadMoreBtn.className = 'load-more-pill';
|
||||
loadMoreBtn.addEventListener('click', loadMore);
|
||||
loadMoreBtn = document.createElement("button");
|
||||
loadMoreBtn.type = "button";
|
||||
loadMoreBtn.className = "load-more-pill";
|
||||
loadMoreBtn.addEventListener("click", loadMore);
|
||||
rootLog.prepend(loadMoreBtn);
|
||||
}
|
||||
loadMoreBtn.textContent = '↑ load older';
|
||||
loadMoreBtn.textContent = "↑ load older";
|
||||
loadMoreBtn.disabled = false;
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (!histHasMore || histLoading || !opts.historyUrl || histMinId === null) return;
|
||||
if (!histHasMore || histLoading || !opts.historyUrl || histMinId === null)
|
||||
return;
|
||||
histLoading = true;
|
||||
if (loadMoreBtn) { loadMoreBtn.textContent = '↑ loading…'; loadMoreBtn.disabled = true; }
|
||||
if (loadMoreBtn) {
|
||||
loadMoreBtn.textContent = "↑ loading…";
|
||||
loadMoreBtn.disabled = true;
|
||||
}
|
||||
try {
|
||||
const sep = opts.historyUrl.includes('?') ? '&' : '?';
|
||||
const url = opts.historyUrl + sep + 'before=' + histMinId;
|
||||
const sep = opts.historyUrl.includes("?") ? "&" : "?";
|
||||
const url = opts.historyUrl + sep + "before=" + histMinId;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) { updateLoadMoreBtn(); return; }
|
||||
if (!resp.ok) {
|
||||
updateLoadMoreBtn();
|
||||
return;
|
||||
}
|
||||
const body = await resp.json();
|
||||
const events = Array.isArray(body) ? body : (body.events || []);
|
||||
const events = Array.isArray(body) ? body : body.events || [];
|
||||
histHasMore = body.has_more || false;
|
||||
if (typeof body.min_id === 'number') histMinId = body.min_id;
|
||||
if (typeof body.min_id === "number") histMinId = body.min_id;
|
||||
|
||||
// Resolve load-more button state before capturing the scroll
|
||||
// baseline so that any button removal is already reflected in
|
||||
|
|
@ -350,7 +379,7 @@ export function create(opts) {
|
|||
if (events.length > 0) {
|
||||
// Render into a detached element; `log` is temporarily redirected
|
||||
// so that row/details/etc. append there instead of rootLog.
|
||||
const tempEl = document.createElement('div');
|
||||
const tempEl = document.createElement("div");
|
||||
log = tempEl;
|
||||
currentNoAnim = true;
|
||||
for (const ev of events) dispatch(ev, true);
|
||||
|
|
@ -358,23 +387,26 @@ export function create(opts) {
|
|||
log = rootLog;
|
||||
|
||||
// Separator to mark the boundary between loaded-older and newer.
|
||||
const sepEl = document.createElement('div');
|
||||
sepEl.className = 'row note no-anim';
|
||||
sepEl.textContent = '─── older above ───';
|
||||
const sepEl = document.createElement("div");
|
||||
sepEl.className = "row note no-anim";
|
||||
sepEl.textContent = "─── older above ───";
|
||||
tempEl.appendChild(sepEl);
|
||||
|
||||
// Insert before the "live" divider (i.e. right after the load-more
|
||||
// button if present, else at the very top of rootLog).
|
||||
const anchor = loadMoreBtn ? loadMoreBtn.nextSibling : rootLog.firstChild;
|
||||
const anchor = loadMoreBtn
|
||||
? loadMoreBtn.nextSibling
|
||||
: rootLog.firstChild;
|
||||
const beforeH = rootLog.scrollHeight;
|
||||
while (tempEl.firstChild) rootLog.insertBefore(tempEl.firstChild, anchor);
|
||||
while (tempEl.firstChild)
|
||||
rootLog.insertBefore(tempEl.firstChild, anchor);
|
||||
// Compensate scroll so the viewport stays on the same content.
|
||||
// overflow-anchor: none on .live ensures the browser does not
|
||||
// also auto-adjust scrollTop (which would double the delta).
|
||||
rootLog.scrollTop += rootLog.scrollHeight - beforeH;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('loadMore failed', err);
|
||||
console.warn("loadMore failed", err);
|
||||
updateLoadMoreBtn();
|
||||
} finally {
|
||||
histLoading = false;
|
||||
|
|
@ -414,21 +446,31 @@ export function create(opts) {
|
|||
: new EventSource(opts.streamUrl);
|
||||
es.onmessage = (e) => {
|
||||
let ev;
|
||||
try { ev = JSON.parse(e.data); }
|
||||
catch (err) { row('note', '[parse err] ' + e.data); return; }
|
||||
if (!live) { buffered.push(ev); return; }
|
||||
try {
|
||||
ev = JSON.parse(e.data);
|
||||
} catch (err) {
|
||||
row("note", "[parse err] " + e.data);
|
||||
return;
|
||||
}
|
||||
if (!live) {
|
||||
buffered.push(ev);
|
||||
return;
|
||||
}
|
||||
dispatch(ev, false);
|
||||
if (opts.onLiveEvent) {
|
||||
try { opts.onLiveEvent(ev); }
|
||||
catch (err) { console.error('onLiveEvent threw', err); }
|
||||
try {
|
||||
opts.onLiveEvent(ev);
|
||||
} catch (err) {
|
||||
console.error("onLiveEvent threw", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
es.onerror = () => {
|
||||
// SharedWorker-backed facades expose `readyState` mirroring the
|
||||
// upstream EventSource state; the native EventSource exposes the
|
||||
// same. Either way the CONNECTING vs. closed distinction works.
|
||||
if (es.readyState === 0 /* CONNECTING */) row('note', '[reconnecting…]');
|
||||
else row('note', '[disconnected]');
|
||||
if (es.readyState === 0 /* CONNECTING */) row("note", "[reconnecting…]");
|
||||
else row("note", "[disconnected]");
|
||||
};
|
||||
es.onopen = () => {
|
||||
// Fires on the initial connect and on every automatic
|
||||
|
|
@ -438,8 +480,11 @@ export function create(opts) {
|
|||
// must re-sync here or it shows stale state until a manual
|
||||
// reload.
|
||||
if (opts.onStreamOpen) {
|
||||
try { opts.onStreamOpen(); }
|
||||
catch (err) { console.error('onStreamOpen threw', err); }
|
||||
try {
|
||||
opts.onStreamOpen();
|
||||
} catch (err) {
|
||||
console.error("onStreamOpen threw", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -456,15 +501,22 @@ export function create(opts) {
|
|||
// broker-history seq would wrongly drop ones that fired
|
||||
// between a consumer's own snapshot read and this history
|
||||
// fetch. ev.seq absent/0 → no dedupe possible.
|
||||
if (boundarySeq != null
|
||||
&& typeof ev.seq === 'number' && ev.seq <= boundarySeq
|
||||
&& historyKinds && historyKinds.has(ev.kind)) {
|
||||
if (
|
||||
boundarySeq != null &&
|
||||
typeof ev.seq === "number" &&
|
||||
ev.seq <= boundarySeq &&
|
||||
historyKinds &&
|
||||
historyKinds.has(ev.kind)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
dispatch(ev, false);
|
||||
if (opts.onLiveEvent) {
|
||||
try { opts.onLiveEvent(ev); }
|
||||
catch (err) { console.error('onLiveEvent threw', err); }
|
||||
try {
|
||||
opts.onLiveEvent(ev);
|
||||
} catch (err) {
|
||||
console.error("onLiveEvent threw", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -486,12 +538,12 @@ export function create(opts) {
|
|||
// Accept the envelope `{ seq, events, min_id?, has_more? }`.
|
||||
// A bare array means the server hasn't been updated — treat it
|
||||
// as "no dedupe possible, no pagination."
|
||||
const events = Array.isArray(body) ? body : (body.events || []);
|
||||
const events = Array.isArray(body) ? body : body.events || [];
|
||||
const boundarySeq = Array.isArray(body) ? null : (body.seq ?? null);
|
||||
// Pagination cursors — set on the outer load-more state.
|
||||
if (!Array.isArray(body)) {
|
||||
histHasMore = body.has_more || false;
|
||||
if (typeof body.min_id === 'number') histMinId = body.min_id;
|
||||
if (typeof body.min_id === "number") histMinId = body.min_id;
|
||||
}
|
||||
// Kinds present in the history replay — the only kinds that
|
||||
// can double and therefore the only ones to seq-dedupe.
|
||||
|
|
@ -499,14 +551,14 @@ export function create(opts) {
|
|||
currentNoAnim = true;
|
||||
for (const ev of events) dispatch(ev, true);
|
||||
currentNoAnim = false;
|
||||
if (events.length) row('note', '─── live (older above) ───');
|
||||
else placeholder('(connected — waiting for events)');
|
||||
if (events.length) row("note", "─── live (older above) ───");
|
||||
else placeholder("(connected — waiting for events)");
|
||||
flushBuffered(boundarySeq, historyKinds);
|
||||
// Show load-older button if the server reports more history.
|
||||
updateLoadMoreBtn();
|
||||
if (opts.onBackfillDone) opts.onBackfillDone(events.length);
|
||||
} catch (err) {
|
||||
console.warn('history backfill failed', err);
|
||||
console.warn("history backfill failed", err);
|
||||
flushBuffered(null);
|
||||
if (opts.onBackfillDone) opts.onBackfillDone(0);
|
||||
}
|
||||
|
|
@ -524,9 +576,10 @@ export function create(opts) {
|
|||
// XSS-safety + trailing-punctuation strip.
|
||||
const LINKIFY_URL_RE = /https?:\/\/[^\s<>"']+/g;
|
||||
export function linkify(text) {
|
||||
const str = text == null ? '' : String(text);
|
||||
const str = text == null ? "" : String(text);
|
||||
const frag = document.createDocumentFragment();
|
||||
if (str.indexOf('://') === -1) { // fast path: no URLs
|
||||
if (str.indexOf("://") === -1) {
|
||||
// fast path: no URLs
|
||||
if (str) frag.appendChild(document.createTextNode(str));
|
||||
return frag;
|
||||
}
|
||||
|
|
@ -537,20 +590,20 @@ export function linkify(text) {
|
|||
let url = m[0];
|
||||
// Don't swallow trailing punctuation that's really sentence text.
|
||||
const trail = url.match(/[.,;:!?)\]}'"]+$/);
|
||||
const tail = trail ? trail[0] : '';
|
||||
const tail = trail ? trail[0] : "";
|
||||
if (tail) url = url.slice(0, -tail.length);
|
||||
if (m.index > last) {
|
||||
frag.appendChild(document.createTextNode(str.slice(last, m.index)));
|
||||
}
|
||||
if (!url.slice(url.indexOf('://') + 3)) {
|
||||
if (!url.slice(url.indexOf("://") + 3)) {
|
||||
// Nothing past the scheme — not a real URL, emit verbatim.
|
||||
frag.appendChild(document.createTextNode(m[0]));
|
||||
} else {
|
||||
const a = document.createElement('a');
|
||||
a.href = url; // regex only matches https?:// — safe
|
||||
const a = document.createElement("a");
|
||||
a.href = url; // regex only matches https?:// — safe
|
||||
a.textContent = url;
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener noreferrer';
|
||||
a.target = "_blank";
|
||||
a.rel = "noopener noreferrer";
|
||||
frag.appendChild(a);
|
||||
if (tail) frag.appendChild(document.createTextNode(tail));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,9 +29,21 @@
|
|||
a swap. Each is pixel-identical to its prior literal under the
|
||||
default palette: --crust is a darkened bg; --muted / --subtext0 are
|
||||
foreground↔background blends (two levels of dimmed text). */
|
||||
--crust: color-mix(in srgb, var(--base00) 58%, #000000); /* terminal / code bg, below --bg */
|
||||
--muted: color-mix(in srgb, var(--base05) 55.5%, var(--base00)); /* secondary / dimmed text */
|
||||
--subtext0: color-mix(in srgb, var(--base05) 77.7%, var(--base00)); /* toolbar/status text; dimmer than --fg, lighter than --muted */
|
||||
--crust: color-mix(
|
||||
in srgb,
|
||||
var(--base00) 58%,
|
||||
#000000
|
||||
); /* terminal / code bg, below --bg */
|
||||
--muted: color-mix(
|
||||
in srgb,
|
||||
var(--base05) 55.5%,
|
||||
var(--base00)
|
||||
); /* secondary / dimmed text */
|
||||
--subtext0: color-mix(
|
||||
in srgb,
|
||||
var(--base05) 77.7%,
|
||||
var(--base00)
|
||||
); /* toolbar/status text; dimmer than --fg, lighter than --muted */
|
||||
|
||||
/* `--muted`, above, is mixed toward `--base00`/`--bg` — correct for
|
||||
dimmed text on the page background, wrong for dimmed text sitting on
|
||||
|
|
|
|||
|
|
@ -28,8 +28,13 @@
|
|||
animation: warn-banner-pulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes warn-banner-pulse {
|
||||
0%, 100% { box-shadow: 0 0 12px -4px color-mix(in srgb, currentColor 55%, transparent); }
|
||||
50% { box-shadow: 0 0 22px -2px color-mix(in srgb, currentColor 95%, transparent); }
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 12px -4px color-mix(in srgb, currentColor 55%, transparent);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 22px -2px color-mix(in srgb, currentColor 95%, transparent);
|
||||
}
|
||||
}
|
||||
.warn-banner strong {
|
||||
color: inherit;
|
||||
|
|
|
|||
|
|
@ -13,10 +13,10 @@
|
|||
// `level` defaults to 'warning', matching `hive-warn.js`'s own default.
|
||||
// Only `error` pulses — an active incident, not a standing caveat, same
|
||||
// rule `hive-warn.css`'s own comment states.
|
||||
import type { ComponentChildren } from 'preact';
|
||||
import './WarnBanner.css';
|
||||
import type { ComponentChildren } from "preact";
|
||||
import "./WarnBanner.css";
|
||||
|
||||
export type WarnLevel = 'info' | 'warning' | 'error';
|
||||
export type WarnLevel = "info" | "warning" | "error";
|
||||
|
||||
export interface WarnBannerProps {
|
||||
level?: WarnLevel;
|
||||
|
|
@ -28,10 +28,16 @@ export interface WarnBannerProps {
|
|||
children?: ComponentChildren;
|
||||
}
|
||||
|
||||
export function WarnBanner({ level = 'warning', class: extraClass, children }: WarnBannerProps) {
|
||||
const classes = ['warn-banner', `warn-banner-${level}`, extraClass].filter(Boolean).join(' ');
|
||||
export function WarnBanner({
|
||||
level = "warning",
|
||||
class: extraClass,
|
||||
children,
|
||||
}: WarnBannerProps) {
|
||||
const classes = ["warn-banner", `warn-banner-${level}`, extraClass]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return (
|
||||
<div class={classes} role={level === 'error' ? 'alert' : undefined}>
|
||||
<div class={classes} role={level === "error" ? "alert" : undefined}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue