Compare commits
5 changed files with 90 additions and 665 deletions
|
|
@ -3,31 +3,10 @@
|
||||||
@import "@hive/shared/base.css";
|
@import "@hive/shared/base.css";
|
||||||
@import "@hive/shared/terminal.css";
|
@import "@hive/shared/terminal.css";
|
||||||
|
|
||||||
/* ─── full-screen vibec0re overhaul (issue #360) ──────────────────
|
body {
|
||||||
Layout shape: fixed-position frosted-glass header at top, fixed-
|
|
||||||
position composer at bottom, full-viewport terminal in between.
|
|
||||||
The terminal scrolls — its text passes BENEATH the floating
|
|
||||||
header/composer with backdrop-filter blur for the frosted look.
|
|
||||||
Inbox + loose-ends move into the side-panel flyout; header pills
|
|
||||||
surface their counts as the only chrome they get. */
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--agent-header-h: 4.6em;
|
|
||||||
--agent-composer-h: 3.6em;
|
|
||||||
--agent-frost-bg: rgba(30, 30, 46, 0.72);
|
|
||||||
--agent-frost-blur: blur(12px) saturate(140%);
|
|
||||||
}
|
|
||||||
|
|
||||||
html, body { height: 100%; margin: 0; }
|
|
||||||
|
|
||||||
/* Legacy in-page layout retained for the sibling stats page
|
|
||||||
(`stats.html`) which doesn't apply `body.agent-shell` and stays
|
|
||||||
on a normal-document scroll. */
|
|
||||||
body:not(.agent-shell) {
|
|
||||||
max-width: 110em;
|
max-width: 110em;
|
||||||
margin: 1.5em auto;
|
margin: 1.5em auto;
|
||||||
padding: 0 1.5em;
|
padding: 0 1.5em;
|
||||||
height: auto;
|
|
||||||
}
|
}
|
||||||
.banner {
|
.banner {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
@ -47,177 +26,30 @@ body:not(.agent-shell) {
|
||||||
color: transparent;
|
color: transparent;
|
||||||
filter: drop-shadow(0 0 6px rgba(203, 166, 247, 0.45));
|
filter: drop-shadow(0 0 6px rgba(203, 166, 247, 0.45));
|
||||||
}
|
}
|
||||||
|
.banner.active {
|
||||||
body.agent-shell {
|
animation: banner-shimmer 1.8s linear infinite;
|
||||||
background: var(--bg);
|
|
||||||
color: var(--fg);
|
|
||||||
/* Body itself doesn't scroll; the terminal does inside .agent-main. */
|
|
||||||
overflow: hidden;
|
|
||||||
/* Subtle radial accent to give the otherwise-flat full-screen
|
|
||||||
surface some depth and reinforce the vibec0re mood. */
|
|
||||||
background:
|
|
||||||
radial-gradient(ellipse 80% 60% at 50% 0%,
|
|
||||||
rgba(203, 166, 247, 0.06) 0%,
|
|
||||||
transparent 60%),
|
|
||||||
var(--bg);
|
|
||||||
}
|
}
|
||||||
|
@keyframes banner-shimmer {
|
||||||
.agent-header {
|
from { background-position: 200% 0; }
|
||||||
position: fixed;
|
to { background-position: -100% 0; }
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 30;
|
|
||||||
min-height: var(--agent-header-h);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1em;
|
|
||||||
padding: 0.55em 1em;
|
|
||||||
background: var(--agent-frost-bg);
|
|
||||||
-webkit-backdrop-filter: var(--agent-frost-blur);
|
|
||||||
backdrop-filter: var(--agent-frost-blur);
|
|
||||||
border-bottom: 1px solid var(--purple-dim);
|
|
||||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-header-title {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.1em;
|
|
||||||
min-width: 0;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
}
|
|
||||||
.agent-header-title h2 {
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
.agent-nav {
|
|
||||||
/* Slim nav row directly under the title — keeps the operator's
|
|
||||||
fingers near stats/screen/forge without dominating the header. */
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.4em 0.8em;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-state-row {
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5em;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2, h3 {
|
h2, h3 {
|
||||||
color: var(--purple);
|
color: var(--purple);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.15em;
|
letter-spacing: 0.15em;
|
||||||
text-shadow: 0 0 8px rgba(203, 166, 247, 0.4);
|
text-shadow: 0 0 8px rgba(203, 166, 247, 0.4);
|
||||||
}
|
}
|
||||||
|
.title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.title-row h2 { margin: 0; }
|
||||||
.agent-icon {
|
.agent-icon {
|
||||||
width: 44px;
|
width: 40px;
|
||||||
height: 44px;
|
height: 40px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
box-shadow: 0 0 14px -2px rgba(203, 166, 247, 0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Header pill — inbox / loose-ends triggers. Compact, count-prominent. */
|
|
||||||
.header-pill {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--purple-dim);
|
|
||||||
color: var(--fg);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 0.85em;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 0.25em 0.7em;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.4em;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, color 0.15s ease;
|
|
||||||
}
|
|
||||||
.header-pill:hover {
|
|
||||||
border-color: var(--purple);
|
|
||||||
color: var(--purple);
|
|
||||||
box-shadow: 0 0 10px -2px var(--purple);
|
|
||||||
}
|
|
||||||
.header-pill-icon { font-size: 1.05em; line-height: 1; }
|
|
||||||
.header-pill-label { color: var(--muted); }
|
|
||||||
.header-pill-count {
|
|
||||||
background: var(--purple-dim);
|
|
||||||
color: var(--purple);
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 0 0.5em;
|
|
||||||
min-width: 1.6em;
|
|
||||||
text-align: center;
|
|
||||||
font-weight: bold;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
.header-pill-inbox .header-pill-count {
|
|
||||||
background: rgba(250, 179, 135, 0.18);
|
|
||||||
color: var(--amber);
|
|
||||||
}
|
|
||||||
.header-pill-loose .header-pill-count {
|
|
||||||
background: rgba(243, 139, 168, 0.18);
|
|
||||||
color: var(--red);
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-main {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Login flow overlay: only rendered when status != online. Sits
|
|
||||||
centred over the (likely-empty) terminal area; doesn't take chrome
|
|
||||||
space in the normal online flow. */
|
|
||||||
.agent-status-overlay {
|
|
||||||
position: absolute;
|
|
||||||
top: calc(var(--agent-header-h) + 1.5em);
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
max-width: 44em;
|
|
||||||
width: calc(100% - 3em);
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
.agent-status-overlay:empty { display: none; }
|
|
||||||
.agent-status-overlay > * {
|
|
||||||
background: var(--bg-elev);
|
|
||||||
border: 1px solid var(--purple-dim);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 1em 1.2em;
|
|
||||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-composer {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 30;
|
|
||||||
min-height: var(--agent-composer-h);
|
|
||||||
background: var(--agent-frost-bg);
|
|
||||||
-webkit-backdrop-filter: var(--agent-frost-blur);
|
|
||||||
backdrop-filter: var(--agent-frost-blur);
|
|
||||||
border-top: 1px solid var(--purple-dim);
|
|
||||||
box-shadow: 0 -6px 18px rgba(0, 0, 0, 0.35);
|
|
||||||
}
|
|
||||||
.agent-composer .term-input {
|
|
||||||
/* The composer is its own chrome now — drop the in-terminal-wrap
|
|
||||||
padding the legacy layout assumed. */
|
|
||||||
padding: 0.45em 1em;
|
|
||||||
}
|
|
||||||
.agent-composer .term-input .sendform-term {
|
|
||||||
/* No dashed top-border in the floating composer — the box-shadow
|
|
||||||
and frosted border already separate it from the terminal. */
|
|
||||||
border-top: 0;
|
|
||||||
padding-top: 0;
|
|
||||||
}
|
}
|
||||||
.meta { color: var(--muted); font-size: 0.85em; }
|
.meta { color: var(--muted); font-size: 0.85em; }
|
||||||
.status-online { color: var(--green); text-shadow: 0 0 6px rgba(166, 227, 161, 0.55); }
|
.status-online { color: var(--green); text-shadow: 0 0 6px rgba(166, 227, 161, 0.55); }
|
||||||
|
|
@ -515,54 +347,14 @@ pre.diff {
|
||||||
60% { box-shadow: 0 0 18px -4px currentColor, 0 0 4px 0 currentColor; }
|
60% { box-shadow: 0 0 18px -4px currentColor, 0 0 4px 0 currentColor; }
|
||||||
100% { box-shadow: 0 0 0 0 currentColor, 0 0 0 0 currentColor; }
|
100% { box-shadow: 0 0 0 0 currentColor, 0 0 0 0 currentColor; }
|
||||||
}
|
}
|
||||||
/* Full-screen overrides for the shared terminal rules. The base
|
/* `.terminal-wrap`, `.live`, `.live.terminal`, row + pill + details
|
||||||
`.terminal-wrap` (in shared/src/terminal.css) ships a crust-on-
|
styling all live in hive-fr0nt::TERMINAL_CSS (prepended by serve_css).
|
||||||
black frame for the in-page case; the agent page now owns the
|
What stays here is the composer chrome that sits inside the wrap. */
|
||||||
whole viewport so the frame chrome would be redundant noise.
|
.term-input { padding: 0.4em 1em 0.8em; }
|
||||||
`.live.terminal` similarly drops the in-page max-height cap so
|
|
||||||
it can fill the main area top-to-bottom; the floating header +
|
|
||||||
composer overlay it via fixed positioning. */
|
|
||||||
.agent-main .terminal-wrap {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
box-shadow: none;
|
|
||||||
border-radius: 0;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.agent-main .live.terminal {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
height: auto;
|
|
||||||
max-height: none;
|
|
||||||
/* Scroll behind the floating header/composer, but keep the first
|
|
||||||
and last rows reachable with extra padding inside the scroll
|
|
||||||
area. scroll-padding-* keeps anchor-jumps (the `↓ N new` pill,
|
|
||||||
focus restore) clear of the floats too. */
|
|
||||||
padding-top: calc(var(--agent-header-h) + 0.8em);
|
|
||||||
padding-bottom: calc(var(--agent-composer-h) + 0.8em);
|
|
||||||
scroll-padding-top: calc(var(--agent-header-h) + 0.8em);
|
|
||||||
scroll-padding-bottom: calc(var(--agent-composer-h) + 0.8em);
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
/* Tail pill (↓ N new): nudged up so it floats clear of the composer
|
|
||||||
rather than colliding with the frosted bar. */
|
|
||||||
.agent-main .tail-pill {
|
|
||||||
bottom: calc(var(--agent-composer-h) + 0.6em);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Composer chrome — used to live inside `.terminal-wrap`; now lives
|
|
||||||
inside the fixed `.agent-composer` defined further up. The base
|
|
||||||
rules below stay scoped to whichever ancestor owns it. */
|
|
||||||
.term-input .sendform-term {
|
.term-input .sendform-term {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 0.5em;
|
gap: 0.5em;
|
||||||
/* The dashed in-frame separator is dropped — see the
|
|
||||||
.agent-composer .term-input override above for the floating-bar
|
|
||||||
variant. */
|
|
||||||
border-top: 1px dashed var(--purple-dim);
|
border-top: 1px dashed var(--purple-dim);
|
||||||
padding-top: 0.5em;
|
padding-top: 0.5em;
|
||||||
}
|
}
|
||||||
|
|
@ -595,97 +387,3 @@ pre.diff {
|
||||||
.term-input.disabled .prompt { color: var(--muted); text-shadow: none; }
|
.term-input.disabled .prompt { color: var(--muted); text-shadow: none; }
|
||||||
.term-input.disabled textarea { color: var(--muted); }
|
.term-input.disabled textarea { color: var(--muted); }
|
||||||
/* Row + pill + details styling moved to hive-fr0nt::TERMINAL_CSS. */
|
/* Row + pill + details styling moved to hive-fr0nt::TERMINAL_CSS. */
|
||||||
|
|
||||||
/* ─── side panel (singleton drawer) ────────────────────────────────
|
|
||||||
Inbox + loose-ends details open here instead of expanding inline
|
|
||||||
(issue #360). Copy of the dashboard's side-panel pattern —
|
|
||||||
candidate for extraction into @hive/shared once both surfaces
|
|
||||||
stabilize. */
|
|
||||||
.side-panel {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 50;
|
|
||||||
/* Closed: ignore pointer events so the agent page underneath stays
|
|
||||||
interactive; `.open` flips it back on. */
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.side-panel-backdrop {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.55);
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
}
|
|
||||||
.side-panel-drawer {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: min(640px, 92vw);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: var(--bg-elev);
|
|
||||||
border-left: 2px solid var(--purple);
|
|
||||||
box-shadow: -10px 0 30px rgba(0, 0, 0, 0.45);
|
|
||||||
transform: translateX(100%);
|
|
||||||
transition: transform 0.25s ease;
|
|
||||||
}
|
|
||||||
.side-panel.open { pointer-events: auto; }
|
|
||||||
.side-panel.open .side-panel-backdrop { opacity: 1; }
|
|
||||||
.side-panel.open .side-panel-drawer { transform: translateX(0); }
|
|
||||||
.side-panel-head {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1em;
|
|
||||||
padding: 0.7em 1em;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.side-panel-title {
|
|
||||||
color: var(--purple);
|
|
||||||
font-weight: bold;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
.side-panel-close {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--fg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 1em;
|
|
||||||
line-height: 1;
|
|
||||||
padding: 0.25em 0.55em;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.side-panel-close:hover { border-color: var(--red); color: var(--red); }
|
|
||||||
.side-panel-body {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
overflow: auto;
|
|
||||||
padding: 0.8em 1em;
|
|
||||||
}
|
|
||||||
/* Inbox / loose-ends lists rendered into the side-panel body. The
|
|
||||||
legacy <details>-collapsible variant of .agent-inbox is gone, so
|
|
||||||
here we strip the inbox-only chrome (background, border-left) and
|
|
||||||
let the panel body's own padding own the framing. */
|
|
||||||
.side-panel-body .agent-inbox {
|
|
||||||
margin: 0;
|
|
||||||
font-size: inherit;
|
|
||||||
color: var(--fg);
|
|
||||||
}
|
|
||||||
.side-panel-body .agent-inbox ul {
|
|
||||||
background: transparent;
|
|
||||||
border-left: 0;
|
|
||||||
padding: 0;
|
|
||||||
max-height: none;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Empty-state placeholders for the side panel (when count drops to 0
|
|
||||||
between the click and the render — rare, but possible). */
|
|
||||||
.side-panel-empty {
|
|
||||||
color: var(--muted);
|
|
||||||
font-style: italic;
|
|
||||||
padding: 1em 0;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -77,69 +77,10 @@ window.marked = marked;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── side panel (singleton drawer for inbox + loose-ends flyouts) ──────
|
|
||||||
// Shared shape with the dashboard's panel. Candidate for extraction
|
|
||||||
// into @hive/shared in a follow-up — keeping the duplication for
|
|
||||||
// now to land #360 without simultaneously refactoring the dashboard.
|
|
||||||
const Panel = (() => {
|
|
||||||
const root = $('side-panel');
|
|
||||||
const titleEl = $('side-panel-title');
|
|
||||||
const bodyEl = $('side-panel-body');
|
|
||||||
/** Owner key (e.g. 'inbox', 'loose-ends'). Refresh hooks check
|
|
||||||
* against this so a live event only re-renders the panel when
|
|
||||||
* the matching view is actually visible. null when closed. */
|
|
||||||
let owner = null;
|
|
||||||
function open(name, title, content) {
|
|
||||||
owner = name;
|
|
||||||
titleEl.textContent = title;
|
|
||||||
bodyEl.replaceChildren(...(content ? [content] : []));
|
|
||||||
root.classList.add('open');
|
|
||||||
root.setAttribute('aria-hidden', 'false');
|
|
||||||
}
|
|
||||||
function close() {
|
|
||||||
owner = null;
|
|
||||||
root.classList.remove('open');
|
|
||||||
root.setAttribute('aria-hidden', 'true');
|
|
||||||
}
|
|
||||||
function refresh(name, title, content) {
|
|
||||||
if (owner !== name) return;
|
|
||||||
titleEl.textContent = title;
|
|
||||||
bodyEl.replaceChildren(...(content ? [content] : []));
|
|
||||||
}
|
|
||||||
function bind() {
|
|
||||||
$('side-panel-close').addEventListener('click', close);
|
|
||||||
$('side-panel-backdrop').addEventListener('click', close);
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Escape' && root.classList.contains('open')) close();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { open, close, refresh, bind, currentOwner: () => owner };
|
|
||||||
})();
|
|
||||||
Panel.bind();
|
|
||||||
|
|
||||||
// Wire the header pills to open the side panel. Pre-built (vs
|
|
||||||
// re-building per-click) so the freshest snapshot already lives
|
|
||||||
// in `lastInbox` / `lastLooseEnds` when the pill is clicked — even
|
|
||||||
// if it fires during a turn the render is the same.
|
|
||||||
(function bindHeaderPills() {
|
|
||||||
const inboxPill = $('inbox-pill');
|
|
||||||
if (inboxPill) {
|
|
||||||
inboxPill.addEventListener('click', () => {
|
|
||||||
Panel.open('inbox', 'inbox · ' + lastInbox.length,
|
|
||||||
buildInboxList(lastInbox));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const loosePill = $('loose-ends-pill');
|
|
||||||
if (loosePill) {
|
|
||||||
loosePill.addEventListener('click', () => {
|
|
||||||
Panel.open('loose-ends', 'loose ends · ' + lastLooseEnds.length,
|
|
||||||
buildLooseEndsList(lastLooseEnds));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
// ─── state rendering ────────────────────────────────────────────────────
|
// ─── state rendering ────────────────────────────────────────────────────
|
||||||
function setHeader(label, dashboardPort) {
|
function setHeader(label, dashboardPort) {
|
||||||
|
$('banner').textContent =
|
||||||
|
`░▒▓█▓▒░ ${label} ░▒▓█▓▒░ hyperhive ag3nt ░▒▓█▓▒░`;
|
||||||
const title = $('title');
|
const title = $('title');
|
||||||
title.textContent = `◆ ${label} ◆ `;
|
title.textContent = `◆ ${label} ◆ `;
|
||||||
// ↑ DASHB04RD — back-link to the host dashboard. Opens in a new
|
// ↑ DASHB04RD — back-link to the host dashboard. Opens in a new
|
||||||
|
|
@ -474,7 +415,8 @@ window.marked = marked;
|
||||||
// Loose-ends section: same data the get_loose_ends MCP tool
|
// Loose-ends section: same data the get_loose_ends MCP tool
|
||||||
// returns. Best-effort fetch on cold load + after every turn_end
|
// returns. Best-effort fetch on cold load + after every turn_end
|
||||||
// (a turn likely answered or asked something). Silent failure
|
// (a turn likely answered or asked something). Silent failure
|
||||||
// keeps the pill count at zero rather than surfacing a stale chrome.
|
// keeps the section hidden rather than surfacing an empty banner.
|
||||||
|
let lastLooseEndsCount = 0;
|
||||||
async function refreshLooseEnds() {
|
async function refreshLooseEnds() {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/loose-ends');
|
const resp = await fetch('/api/loose-ends');
|
||||||
|
|
@ -489,22 +431,24 @@ window.marked = marked;
|
||||||
renderLooseEnds([]);
|
renderLooseEnds([]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/** Latest snapshot kept in module state so the pill click handler
|
function renderLooseEnds(threads) {
|
||||||
* has fresh data to render into the panel without re-fetching. */
|
const root = $('loose-ends-section');
|
||||||
let lastLooseEnds = [];
|
const list = $('loose-ends-list');
|
||||||
let lastInbox = [];
|
const summary = $('loose-ends-summary');
|
||||||
|
if (!root || !list || !summary) return;
|
||||||
function buildLooseEndsList(threads) {
|
|
||||||
// Returns the <div> the side panel renders. The structural shape
|
|
||||||
// mirrors the legacy <details>-collapsible block — same CSS rules
|
|
||||||
// apply via `.side-panel-body .agent-inbox`.
|
|
||||||
const wrap = el('div', { class: 'agent-inbox' });
|
|
||||||
if (!threads.length) {
|
if (!threads.length) {
|
||||||
wrap.append(el('p', { class: 'side-panel-empty' },
|
root.hidden = true;
|
||||||
'no loose ends — every question, approval and reminder is resolved.'));
|
lastLooseEndsCount = 0;
|
||||||
return wrap;
|
return;
|
||||||
}
|
}
|
||||||
const list = el('ul');
|
root.hidden = false;
|
||||||
|
summary.textContent = 'loose ends · ' + threads.length;
|
||||||
|
list.innerHTML = '';
|
||||||
|
// Auto-expand on first appearance of any open thread so the
|
||||||
|
// operator notices new loose ends; collapse only on operator
|
||||||
|
// click (sticky after that).
|
||||||
|
if (lastLooseEndsCount === 0) root.open = true;
|
||||||
|
lastLooseEndsCount = threads.length;
|
||||||
const fmtAge = (s) => {
|
const fmtAge = (s) => {
|
||||||
if (s < 60) return s + 's';
|
if (s < 60) return s + 's';
|
||||||
if (s < 3600) return Math.floor(s / 60) + 'm';
|
if (s < 3600) return Math.floor(s / 60) + 'm';
|
||||||
|
|
@ -548,21 +492,6 @@ window.marked = marked;
|
||||||
}
|
}
|
||||||
list.append(li);
|
list.append(li);
|
||||||
}
|
}
|
||||||
wrap.append(list);
|
|
||||||
return wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Pill-count + open-panel-refresh wiring for loose-ends. The legacy
|
|
||||||
* in-page `<details>` block is gone — operator clicks the header
|
|
||||||
* pill to surface the list in the side panel. */
|
|
||||||
function renderLooseEnds(threads) {
|
|
||||||
lastLooseEnds = threads;
|
|
||||||
const pill = $('loose-ends-pill');
|
|
||||||
const count = $('loose-ends-count');
|
|
||||||
if (count) count.textContent = threads.length;
|
|
||||||
if (pill) pill.hidden = threads.length === 0;
|
|
||||||
Panel.refresh('loose-ends', 'loose ends · ' + threads.length,
|
|
||||||
buildLooseEndsList(threads));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inline "answer as operator" form for a question loose-end. POSTs to
|
// Inline "answer as operator" form for a question loose-end. POSTs to
|
||||||
|
|
@ -601,14 +530,18 @@ window.marked = marked;
|
||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildInboxList(rows) {
|
function renderInbox(rows) {
|
||||||
const wrap = el('div', { class: 'agent-inbox' });
|
const root = $('inbox-section');
|
||||||
|
const list = $('inbox-list');
|
||||||
|
const summary = $('inbox-summary');
|
||||||
|
if (!root || !list || !summary) return;
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
wrap.append(el('p', { class: 'side-panel-empty' },
|
root.hidden = true;
|
||||||
'inbox empty.'));
|
return;
|
||||||
return wrap;
|
|
||||||
}
|
}
|
||||||
const list = el('ul');
|
root.hidden = false;
|
||||||
|
summary.textContent = 'inbox · ' + rows.length;
|
||||||
|
list.innerHTML = '';
|
||||||
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(5, 19);
|
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(5, 19);
|
||||||
for (const m of rows) {
|
for (const m of rows) {
|
||||||
const li = el('li', m.in_reply_to != null ? { class: 'inbox-reply' } : {});
|
const li = el('li', m.in_reply_to != null ? { class: 'inbox-reply' } : {});
|
||||||
|
|
@ -623,18 +556,6 @@ window.marked = marked;
|
||||||
);
|
);
|
||||||
list.append(li);
|
list.append(li);
|
||||||
}
|
}
|
||||||
wrap.append(list);
|
|
||||||
return wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Pill-count + open-panel-refresh wiring for inbox. */
|
|
||||||
function renderInbox(rows) {
|
|
||||||
lastInbox = rows;
|
|
||||||
const pill = $('inbox-pill');
|
|
||||||
const count = $('inbox-count');
|
|
||||||
if (count) count.textContent = rows.length;
|
|
||||||
if (pill) pill.hidden = rows.length === 0;
|
|
||||||
Panel.refresh('inbox', 'inbox · ' + rows.length, buildInboxList(rows));
|
|
||||||
}
|
}
|
||||||
// Harness reachability badge: derived from the same `s.status` the
|
// Harness reachability badge: derived from the same `s.status` the
|
||||||
// status block reads. Each status maps to a glyph + label + colour
|
// status block reads. Each status maps to a glyph + label + colour
|
||||||
|
|
|
||||||
|
|
@ -6,79 +6,43 @@
|
||||||
<link rel="icon" type="image/svg+xml" href="/icon">
|
<link rel="icon" type="image/svg+xml" href="/icon">
|
||||||
<link rel="stylesheet" href="/static/agent.css">
|
<link rel="stylesheet" href="/static/agent.css">
|
||||||
</head>
|
</head>
|
||||||
<body class="agent-shell">
|
<body>
|
||||||
|
<pre class="banner" id="banner">░▒▓█▓▒░ … ░▒▓█▓▒░ hyperhive ag3nt ░▒▓█▓▒░</pre>
|
||||||
<!-- Fixed-overlay header. Frosted glass over the terminal —
|
<div class="title-row">
|
||||||
backdrop-filter blur shows the scrolled terminal text behind. -->
|
|
||||||
<header class="agent-header" id="agent-header">
|
|
||||||
<img class="agent-icon" src="/icon" alt="">
|
<img class="agent-icon" src="/icon" alt="">
|
||||||
<div class="agent-header-title">
|
<h2 id="title">◆ … ◆</h2>
|
||||||
<h2 id="title">◆ … ◆</h2>
|
</div>
|
||||||
<nav class="meta agent-nav" id="meta-links"></nav>
|
<p class="meta" id="meta-links"></p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="state-row" class="agent-state-row">
|
<div id="status">
|
||||||
<span id="alive-badge" class="status-badge status-loading" title="harness reachability">…</span>
|
<p class="meta">loading…</p>
|
||||||
<span id="state-badge" class="state-badge state-loading">… booting</span>
|
</div>
|
||||||
<span id="model-chip" class="model-chip" hidden></span>
|
|
||||||
<span id="ctx-badge" class="ctx-badge" hidden title="tokens used in the current context window"></span>
|
|
||||||
<span id="cost-badge" class="ctx-badge" hidden title="cumulative tokens billed across the last turn (sum across every inference; tool-heavy turns rebill the cached prompt per call)"></span>
|
|
||||||
<span id="last-turn" class="last-turn" hidden></span>
|
|
||||||
<button type="button" id="cancel-btn" class="btn-cancel-turn" hidden>■ cancel turn</button>
|
|
||||||
<button type="button" id="new-session-btn" class="btn-new-session"
|
|
||||||
title="next turn runs without --continue, starting a fresh claude session">↻ new session</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Flyout triggers. The inbox + loose-ends lists live in the
|
<div id="state-row">
|
||||||
side panel now; these pills surface the count and act as the
|
<span id="alive-badge" class="status-badge status-loading" title="harness reachability">…</span>
|
||||||
single click target. The pill stays hidden until there's at
|
<span id="state-badge" class="state-badge state-loading">… booting</span>
|
||||||
least one item to show. -->
|
<span id="model-chip" class="model-chip" hidden></span>
|
||||||
<button type="button" id="inbox-pill" class="header-pill header-pill-inbox" hidden
|
<span id="ctx-badge" class="ctx-badge" hidden title="tokens used in the current context window"></span>
|
||||||
title="open inbox flyout">
|
<span id="cost-badge" class="ctx-badge" hidden title="cumulative tokens billed across the last turn (sum across every inference; tool-heavy turns rebill the cached prompt per call)"></span>
|
||||||
<span class="header-pill-icon" aria-hidden="true">📬</span>
|
<span id="last-turn" class="last-turn" hidden></span>
|
||||||
<span class="header-pill-label">inbox</span>
|
<button type="button" id="cancel-btn" class="btn-cancel-turn" hidden>■ cancel turn</button>
|
||||||
<span class="header-pill-count" id="inbox-count">0</span>
|
<button type="button" id="new-session-btn" class="btn-new-session"
|
||||||
</button>
|
title="next turn runs without --continue, starting a fresh claude session">↻ new session</button>
|
||||||
<button type="button" id="loose-ends-pill" class="header-pill header-pill-loose" hidden
|
</div>
|
||||||
title="open loose-ends flyout">
|
|
||||||
<span class="header-pill-icon" aria-hidden="true">🪢</span>
|
|
||||||
<span class="header-pill-label">loose ends</span>
|
|
||||||
<span class="header-pill-count" id="loose-ends-count">0</span>
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Main content area. The terminal fills it edge-to-edge and
|
<details id="inbox-section" class="agent-inbox" hidden>
|
||||||
scrolls behind the floating header + composer. The `#status`
|
<summary>▸ <span id="inbox-summary">inbox</span></summary>
|
||||||
overlay renders only when login is required (transient first-
|
<ul id="inbox-list"></ul>
|
||||||
time-setup state); otherwise the terminal owns the screen. -->
|
</details>
|
||||||
<main class="agent-main" id="agent-main">
|
|
||||||
<div id="status" class="agent-status-overlay"></div>
|
|
||||||
<div class="terminal-wrap">
|
|
||||||
<div id="live" class="live terminal"><div class="meta">connecting…</div></div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- Fixed-overlay composer. Same frosted-glass treatment as the
|
<details id="loose-ends-section" class="agent-inbox" hidden>
|
||||||
header for symmetric framing. Empty until the harness sets up
|
<summary>▸ <span id="loose-ends-summary">loose ends</span></summary>
|
||||||
the textarea via `renderTermInput`. -->
|
<ul id="loose-ends-list"></ul>
|
||||||
<footer class="agent-composer" id="agent-composer">
|
</details>
|
||||||
|
|
||||||
|
<div class="terminal-wrap">
|
||||||
|
<div id="live" class="live terminal"><div class="meta">connecting…</div></div>
|
||||||
<div id="term-input" class="term-input"></div>
|
<div id="term-input" class="term-input"></div>
|
||||||
</footer>
|
|
||||||
|
|
||||||
<!-- Slide-in side panel. Singleton — JS swaps the title + body
|
|
||||||
and toggles `.open`. Shared shape with the dashboard's panel
|
|
||||||
(candidate for extraction into @hive/shared in a follow-up). -->
|
|
||||||
<div id="side-panel" class="side-panel" aria-hidden="true">
|
|
||||||
<div class="side-panel-backdrop" id="side-panel-backdrop"></div>
|
|
||||||
<aside class="side-panel-drawer" role="dialog" aria-modal="true"
|
|
||||||
aria-labelledby="side-panel-title">
|
|
||||||
<header class="side-panel-head">
|
|
||||||
<span class="side-panel-title" id="side-panel-title"></span>
|
|
||||||
<button type="button" class="side-panel-close" id="side-panel-close"
|
|
||||||
title="close (esc)">✕</button>
|
|
||||||
</header>
|
|
||||||
<div class="side-panel-body" id="side-panel-body"></div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Single bundled entry. esbuild folds @hive/shared/terminal.js and
|
<!-- Single bundled entry. esbuild folds @hive/shared/terminal.js and
|
||||||
|
|
|
||||||
|
|
@ -239,68 +239,6 @@ fn render_flake(
|
||||||
context_window_tokens: &std::collections::HashMap<String, u64>,
|
context_window_tokens: &std::collections::HashMap<String, u64>,
|
||||||
agents: &[AgentSpec],
|
agents: &[AgentSpec],
|
||||||
) -> String {
|
) -> String {
|
||||||
render_flake_with_lookup(
|
|
||||||
hyperhive_flake,
|
|
||||||
dashboard_port,
|
|
||||||
operator_pronouns,
|
|
||||||
context_window_tokens,
|
|
||||||
agents,
|
|
||||||
agent_canonical_inputs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Canonical inputs meta knows how to dedup. An agent that declares one
|
|
||||||
/// of these as a top-level input in its own `flake.nix` will get a
|
|
||||||
/// `follows = "<name>"` line emitted in meta — collapsing the
|
|
||||||
/// otherwise-separate-but-identical `nixpkgs_N` nodes into a single
|
|
||||||
/// meta-level reference (#355).
|
|
||||||
const CANONICAL_INPUTS: &[&str] = &["nixpkgs", "nixpkgs-unstable"];
|
|
||||||
|
|
||||||
/// Read an agent's applied `flake.lock` and return the subset of
|
|
||||||
/// `CANONICAL_INPUTS` it declares as direct (root-level) inputs.
|
|
||||||
/// Returns an empty vec when the lock is missing or unparsable —
|
|
||||||
/// safe degradation, the worst case is no dedup for that agent.
|
|
||||||
fn agent_canonical_inputs(name: &str) -> Vec<&'static str> {
|
|
||||||
let path = std::path::PathBuf::from(format!("{APPLIED_ROOT}/{name}/flake.lock"));
|
|
||||||
let Ok(raw) = std::fs::read_to_string(&path) else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
let Ok(json) = serde_json::from_str::<serde_json::Value>(&raw) else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
let Some(nodes) = json.get("nodes").and_then(|v| v.as_object()) else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
let Some(root_name) = json.get("root").and_then(|v| v.as_str()) else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
let Some(root_inputs) = nodes
|
|
||||||
.get(root_name)
|
|
||||||
.and_then(|n| n.get("inputs"))
|
|
||||||
.and_then(|v| v.as_object())
|
|
||||||
else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
CANONICAL_INPUTS
|
|
||||||
.iter()
|
|
||||||
.copied()
|
|
||||||
.filter(|canon| root_inputs.contains_key(*canon))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Inner render helper accepting a lookup fn so tests can stub the
|
|
||||||
/// agent flake-lock introspection.
|
|
||||||
fn render_flake_with_lookup<F>(
|
|
||||||
hyperhive_flake: &str,
|
|
||||||
dashboard_port: u16,
|
|
||||||
operator_pronouns: &str,
|
|
||||||
context_window_tokens: &std::collections::HashMap<String, u64>,
|
|
||||||
agents: &[AgentSpec],
|
|
||||||
lookup: F,
|
|
||||||
) -> String
|
|
||||||
where
|
|
||||||
F: Fn(&str) -> Vec<&'static str>,
|
|
||||||
{
|
|
||||||
use std::fmt::Write as _;
|
use std::fmt::Write as _;
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
out.push_str("{\n description = \"hyperhive deployed agents\";\n inputs = {\n");
|
out.push_str("{\n description = \"hyperhive deployed agents\";\n inputs = {\n");
|
||||||
|
|
@ -325,21 +263,6 @@ where
|
||||||
" agent-{}.url = \"git+file://{APPLIED_ROOT}/{}\";",
|
" agent-{}.url = \"git+file://{APPLIED_ROOT}/{}\";",
|
||||||
spec.name, spec.name,
|
spec.name, spec.name,
|
||||||
);
|
);
|
||||||
// For each canonical input the agent declares in its own
|
|
||||||
// `flake.nix` (detected by reading its applied `flake.lock`),
|
|
||||||
// emit `inputs.agent-<name>.inputs.<canon>.follows = "<canon>"`.
|
|
||||||
// Collapses three otherwise-separate-but-identical nixpkgs
|
|
||||||
// nodes (root + agent-bitburner's + agent-dmatrix's) into one
|
|
||||||
// (closes #355). Skipped silently for agents that don't
|
|
||||||
// declare the input — emitting follows on a non-existent
|
|
||||||
// input would error at `nix flake lock` time.
|
|
||||||
for canon in lookup(&spec.name) {
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
" agent-{}.inputs.{canon}.follows = \"{canon}\";",
|
|
||||||
spec.name,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
out.push_str(" };\n outputs =\n { self, hyperhive, ... }@inputs:\n let\n");
|
out.push_str(" };\n outputs =\n { self, hyperhive, ... }@inputs:\n let\n");
|
||||||
// Free-text operator string — escape backslash + double-quote so a
|
// Free-text operator string — escape backslash + double-quote so a
|
||||||
|
|
@ -464,68 +387,6 @@ mod tests {
|
||||||
assert!(out.contains("hyperhive.inputs.nixpkgs.follows = \"nixpkgs\""));
|
assert!(out.contains("hyperhive.inputs.nixpkgs.follows = \"nixpkgs\""));
|
||||||
assert!(out.contains("hyperhive.inputs.nixpkgs-unstable.follows = \"nixpkgs-unstable\""));
|
assert!(out.contains("hyperhive.inputs.nixpkgs-unstable.follows = \"nixpkgs-unstable\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn render_flake_emits_follows_for_agents_declaring_nixpkgs() {
|
|
||||||
// Stub lookup: pretend `bitburner` declares `nixpkgs` at its
|
|
||||||
// root, while `argus` has no canonical inputs at all.
|
|
||||||
let lookup = |name: &str| -> Vec<&'static str> {
|
|
||||||
match name {
|
|
||||||
"bitburner" => vec!["nixpkgs"],
|
|
||||||
"dmatrix" => vec!["nixpkgs", "nixpkgs-unstable"],
|
|
||||||
_ => vec![],
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let out = render_flake_with_lookup(
|
|
||||||
"github:example/hyperhive",
|
|
||||||
8000,
|
|
||||||
"she/her",
|
|
||||||
&std::collections::HashMap::new(),
|
|
||||||
&[
|
|
||||||
sample_spec("argus", false, 9001),
|
|
||||||
sample_spec("bitburner", false, 9002),
|
|
||||||
sample_spec("dmatrix", false, 9003),
|
|
||||||
],
|
|
||||||
lookup,
|
|
||||||
);
|
|
||||||
// bitburner declares nixpkgs → follows emitted.
|
|
||||||
assert!(
|
|
||||||
out.contains("agent-bitburner.inputs.nixpkgs.follows = \"nixpkgs\""),
|
|
||||||
"missing bitburner nixpkgs follows:\n{out}"
|
|
||||||
);
|
|
||||||
// dmatrix declares both → both follows emitted.
|
|
||||||
assert!(out.contains("agent-dmatrix.inputs.nixpkgs.follows = \"nixpkgs\""));
|
|
||||||
assert!(
|
|
||||||
out.contains("agent-dmatrix.inputs.nixpkgs-unstable.follows = \"nixpkgs-unstable\"")
|
|
||||||
);
|
|
||||||
// argus declares neither → no follows emitted for it. Asserting
|
|
||||||
// ABSENCE is the important bit: emitting a follows on a
|
|
||||||
// non-existent input errors at `nix flake lock` time.
|
|
||||||
assert!(
|
|
||||||
!out.contains("agent-argus.inputs.nixpkgs"),
|
|
||||||
"argus shouldn't have nixpkgs follows:\n{out}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn render_flake_skips_canonical_follows_when_lookup_returns_empty() {
|
|
||||||
let out = render_flake_with_lookup(
|
|
||||||
"github:example/hyperhive",
|
|
||||||
8000,
|
|
||||||
"she/her",
|
|
||||||
&std::collections::HashMap::new(),
|
|
||||||
&[sample_spec("alice", false, 9001)],
|
|
||||||
|_| Vec::new(),
|
|
||||||
);
|
|
||||||
// No agent-side follows when the lookup reports nothing
|
|
||||||
// declared — protects agents whose flake.lock can't be read
|
|
||||||
// (missing / unparsable) from being broken by a follows on a
|
|
||||||
// non-existent input.
|
|
||||||
assert!(
|
|
||||||
!out.contains("agent-alice.inputs."),
|
|
||||||
"alice shouldn't have any inputs follows:\n{out}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn git_is_clean(dir: &Path) -> Result<bool> {
|
async fn git_is_clean(dir: &Path) -> Result<bool> {
|
||||||
|
|
|
||||||
|
|
@ -27,28 +27,21 @@ pkgs.writeShellApplication {
|
||||||
_token=$(cat "$_token_file")
|
_token=$(cat "$_token_file")
|
||||||
FORGE_API="$HIVE_FORGE_URL/api/v1"
|
FORGE_API="$HIVE_FORGE_URL/api/v1"
|
||||||
|
|
||||||
# `-sS --fail-with-body` makes curl quiet on success, print errors to
|
|
||||||
# stderr on transport failures, AND surface the response body for HTTP
|
|
||||||
# error codes (4xx/5xx) before exiting non-zero. Without this, a 500 /
|
|
||||||
# 404 from forge silently returned an empty stdout — combined with the
|
|
||||||
# script's `set -o pipefail` + the `| jq ...` consumer, the failure
|
|
||||||
# propagated as "no output" with no clue about what went wrong (closes
|
|
||||||
# #353).
|
|
||||||
forge_get() {
|
forge_get() {
|
||||||
${pkgs.curl}/bin/curl -sS --fail-with-body \
|
${pkgs.curl}/bin/curl -sf \
|
||||||
-H "Authorization: token $_token" \
|
-H "Authorization: token $_token" \
|
||||||
-H "Accept: application/json" \
|
-H "Accept: application/json" \
|
||||||
"$1"
|
"$1"
|
||||||
}
|
}
|
||||||
forge_post() {
|
forge_post() {
|
||||||
${pkgs.curl}/bin/curl -sS --fail-with-body -X POST \
|
${pkgs.curl}/bin/curl -sf -X POST \
|
||||||
-H "Authorization: token $_token" \
|
-H "Authorization: token $_token" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-H "Accept: application/json" \
|
-H "Accept: application/json" \
|
||||||
-d "$2" "$1"
|
-d "$2" "$1"
|
||||||
}
|
}
|
||||||
forge_patch() {
|
forge_patch() {
|
||||||
${pkgs.curl}/bin/curl -sS --fail-with-body -X PATCH \
|
${pkgs.curl}/bin/curl -sf -X PATCH \
|
||||||
-H "Authorization: token $_token" \
|
-H "Authorization: token $_token" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-H "Accept: application/json" \
|
-H "Accept: application/json" \
|
||||||
|
|
@ -58,13 +51,13 @@ pkgs.writeShellApplication {
|
||||||
local _url="$1"
|
local _url="$1"
|
||||||
local _body="''${2:-}"
|
local _body="''${2:-}"
|
||||||
if [ -n "$_body" ]; then
|
if [ -n "$_body" ]; then
|
||||||
${pkgs.curl}/bin/curl -sS --fail-with-body -X DELETE \
|
${pkgs.curl}/bin/curl -sf -X DELETE \
|
||||||
-H "Authorization: token $_token" \
|
-H "Authorization: token $_token" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-H "Accept: application/json" \
|
-H "Accept: application/json" \
|
||||||
-d "$_body" "$_url"
|
-d "$_body" "$_url"
|
||||||
else
|
else
|
||||||
${pkgs.curl}/bin/curl -sS --fail-with-body -X DELETE \
|
${pkgs.curl}/bin/curl -sf -X DELETE \
|
||||||
-H "Authorization: token $_token" \
|
-H "Authorization: token $_token" \
|
||||||
-H "Accept: application/json" \
|
-H "Accept: application/json" \
|
||||||
"$_url"
|
"$_url"
|
||||||
|
|
@ -196,29 +189,17 @@ pkgs.writeShellApplication {
|
||||||
cmd_assign() {
|
cmd_assign() {
|
||||||
# assign <number> <user> [--remove]
|
# assign <number> <user> [--remove]
|
||||||
# Assign or unassign a user on an issue or PR.
|
# Assign or unassign a user on an issue or PR.
|
||||||
#
|
|
||||||
# The Forgejo API has no dedicated `POST /issues/{n}/assignees`
|
|
||||||
# endpoint (closes #353) — assignees are an EditIssueOption field
|
|
||||||
# on the issue itself. We patch the full assignee list:
|
|
||||||
# - add: GET current assignees, append `_user`, PATCH back
|
|
||||||
# - remove: GET current assignees, drop `_user`, PATCH back
|
|
||||||
# The PATCH is idempotent: re-adding an existing assignee or
|
|
||||||
# removing one that's not on the list is a no-op (the resulting
|
|
||||||
# assignee list is unchanged).
|
|
||||||
if [ $# -lt 2 ]; then echo "usage: hive-forge assign <number> <user> [--remove]" >&2; exit 1; fi
|
if [ $# -lt 2 ]; then echo "usage: hive-forge assign <number> <user> [--remove]" >&2; exit 1; fi
|
||||||
local _n="$1" _user="$2" _remove="''${3:-}"
|
local _n="$1" _user="$2" _remove="''${3:-}"
|
||||||
local _current _payload
|
local _payload
|
||||||
_current=$(forge_get "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$_n" \
|
_payload=$(jq -n --arg u "$_user" '{assignees:[$u]}')
|
||||||
| jq -c '[.assignees[]?.login]')
|
|
||||||
if [ "$_remove" = "--remove" ]; then
|
if [ "$_remove" = "--remove" ]; then
|
||||||
_payload=$(jq -n --argjson cur "$_current" --arg u "$_user" \
|
forge_delete "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$_n/assignees" "$_payload" \
|
||||||
'{assignees: ($cur - [$u])}')
|
| jq '{number,assignees:[.assignees[]?.login]}'
|
||||||
else
|
else
|
||||||
_payload=$(jq -n --argjson cur "$_current" --arg u "$_user" \
|
forge_post "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$_n/assignees" "$_payload" \
|
||||||
'{assignees: (($cur + [$u]) | unique)}')
|
| jq '{number,assignees:[.assignees[]?.login]}'
|
||||||
fi
|
fi
|
||||||
forge_patch "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$_n" "$_payload" \
|
|
||||||
| jq '{number,assignees:[.assignees[]?.login]}'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd_close() {
|
cmd_close() {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue