From 8e5112aa272377303092bb91771c665828f294d6 Mon Sep 17 00:00:00 2001 From: damocles Date: Sat, 23 May 2026 15:53:06 +0200 Subject: [PATCH 1/3] hive-forge: fix assign endpoint + surface API errors via --fail-with-body (#353) --- nix/packages/hive-forge-tools.nix | 41 ++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/nix/packages/hive-forge-tools.nix b/nix/packages/hive-forge-tools.nix index 188b2795..33eb6e52 100644 --- a/nix/packages/hive-forge-tools.nix +++ b/nix/packages/hive-forge-tools.nix @@ -27,21 +27,28 @@ pkgs.writeShellApplication { _token=$(cat "$_token_file") 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() { - ${pkgs.curl}/bin/curl -sf \ + ${pkgs.curl}/bin/curl -sS --fail-with-body \ -H "Authorization: token $_token" \ -H "Accept: application/json" \ "$1" } forge_post() { - ${pkgs.curl}/bin/curl -sf -X POST \ + ${pkgs.curl}/bin/curl -sS --fail-with-body -X POST \ -H "Authorization: token $_token" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d "$2" "$1" } forge_patch() { - ${pkgs.curl}/bin/curl -sf -X PATCH \ + ${pkgs.curl}/bin/curl -sS --fail-with-body -X PATCH \ -H "Authorization: token $_token" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ @@ -51,13 +58,13 @@ pkgs.writeShellApplication { local _url="$1" local _body="''${2:-}" if [ -n "$_body" ]; then - ${pkgs.curl}/bin/curl -sf -X DELETE \ + ${pkgs.curl}/bin/curl -sS --fail-with-body -X DELETE \ -H "Authorization: token $_token" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d "$_body" "$_url" else - ${pkgs.curl}/bin/curl -sf -X DELETE \ + ${pkgs.curl}/bin/curl -sS --fail-with-body -X DELETE \ -H "Authorization: token $_token" \ -H "Accept: application/json" \ "$_url" @@ -189,17 +196,29 @@ pkgs.writeShellApplication { cmd_assign() { # assign [--remove] # 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 [--remove]" >&2; exit 1; fi local _n="$1" _user="$2" _remove="''${3:-}" - local _payload - _payload=$(jq -n --arg u "$_user" '{assignees:[$u]}') + local _current _payload + _current=$(forge_get "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$_n" \ + | jq -c '[.assignees[]?.login]') if [ "$_remove" = "--remove" ]; then - forge_delete "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$_n/assignees" "$_payload" \ - | jq '{number,assignees:[.assignees[]?.login]}' + _payload=$(jq -n --argjson cur "$_current" --arg u "$_user" \ + '{assignees: ($cur - [$u])}') else - forge_post "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$_n/assignees" "$_payload" \ - | jq '{number,assignees:[.assignees[]?.login]}' + _payload=$(jq -n --argjson cur "$_current" --arg u "$_user" \ + '{assignees: (($cur + [$u]) | unique)}') fi + forge_patch "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$_n" "$_payload" \ + | jq '{number,assignees:[.assignees[]?.login]}' } cmd_close() { From 4d7c767eb045a76fb4c237b93d04357bbdbe8120 Mon Sep 17 00:00:00 2001 From: damocles Date: Sun, 24 May 2026 02:45:11 +0200 Subject: [PATCH 2/3] meta: emit nixpkgs follows for agents that declare it (closes #355) --- hive-c0re/src/meta.rs | 139 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index edffa4f6..8d869d1d 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -239,6 +239,68 @@ fn render_flake( context_window_tokens: &std::collections::HashMap, agents: &[AgentSpec], ) -> 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 = ""` 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::(&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( + hyperhive_flake: &str, + dashboard_port: u16, + operator_pronouns: &str, + context_window_tokens: &std::collections::HashMap, + agents: &[AgentSpec], + lookup: F, +) -> String +where + F: Fn(&str) -> Vec<&'static str>, +{ use std::fmt::Write as _; let mut out = String::new(); out.push_str("{\n description = \"hyperhive deployed agents\";\n inputs = {\n"); @@ -263,6 +325,21 @@ fn render_flake( " agent-{}.url = \"git+file://{APPLIED_ROOT}/{}\";", 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-.inputs..follows = ""`. + // 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"); // Free-text operator string — escape backslash + double-quote so a @@ -387,6 +464,68 @@ mod tests { assert!(out.contains("hyperhive.inputs.nixpkgs.follows = \"nixpkgs\"")); 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 { From e931c0873908ae054031b80212c1dc12af9ec067 Mon Sep 17 00:00:00 2001 From: iris Date: Sun, 24 May 2026 03:49:26 +0200 Subject: [PATCH 3/3] frontend: vibec0re terminal overhaul (#360) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per operator spec at #360#issuecomment-3333: - full-screen terminal - frosted-glass header overlaid on top - inbox + loose-ends → flyout - no a/b flag, just ship it ## Layout `frontend/packages/agent/src/index.html` restructured to a three-zone fixed-overlay shape: - `` — fixed top, frosted glass via `backdrop-filter: blur(12px) saturate(140%)`. Holds icon + title + nav links + state-row (badges/buttons) + two new pill buttons that surface inbox / loose-ends counts (and open the side panel on click). - `` — fills the viewport. Terminal positioned absolute inset:0 with padding-top/-bottom + scroll-padding equal to the floating header/composer heights so first/last rows stay reachable and `↓ N new` pill anchors land in the visible scroll zone. - `` — fixed bottom, mirror-frosted. Owns `#term-input`; dropped the in-frame dashed separator (border-top + box-shadow on the bar already separate it from the terminal). - `` — singleton drawer (copy of the dashboard pattern, candidate for extraction into @hive/shared). Inbox + loose-ends details render here instead of expanding inline. Dropped from the page: the pre-banner ASCII shimmer (``) and the in-page `
` collapsibles for inbox + loose-ends. The banner JS path (`setBannerActive`) is now a no-op (early-returns on missing element); kept as dead code rather than ripped out to keep the diff focused. ## JS `frontend/packages/agent/src/app.js`: - New `Panel` singleton with `open(name, title, content)` + `close()` + `refresh(name, title, content)` (no-op if a different view owns the panel — lets live updates re-render an open view without grabbing focus from a closed one). Mirror of the dashboard's Panel module; the duplication is intentional for now. - `renderInbox` + `renderLooseEnds` refactored: update the header pill counts, hide/show the pills, and `Panel.refresh` if the matching view is open. The list-building DOM logic moved into `buildInboxList` + `buildLooseEndsList` so the pill click handler can call them on the latest snapshot kept in `lastInbox` / `lastLooseEnds` module state. - Pill click handlers `Panel.open(...)` with the freshly built list. - Auto-expand behavior on first appearance dropped (the pill + count badge is the discoverable signal; auto-popping the flyout would interrupt whatever the operator is doing). - `setHeader` no longer touches `#banner` (element removed); title + dashboard back-link + rebuild button still get appended to `#title`. ## CSS `frontend/packages/agent/src/agent.css` major additions, scoped `body.agent-shell` so the sibling `stats.html` (which doesn't apply the shell class) keeps its normal-document scroll + `.banner` ASCII header via a `body:not(.agent-shell)` block. New CSS custom properties on :root: `--agent-header-h`, `--agent-composer-h`, `--agent-frost-bg`, `--agent-frost-blur`. The terminal's padding + scroll-padding derive from these so a single height tweak ripples consistently. Added `.header-pill` (inbox/loose-ends triggers) + `.agent-status-overlay` (centred login card when status != online). Side-panel rules copied from `dashboard.css` with one delta: width caps at 640px (vs dashboard's 760px) since per-agent inbox / loose- ends rows are narrower than approval diffs / file previews. ## Validation - `npm run build` — succeeds both workspaces. - agent: `dist/static/{app.js (115kb), stats.js (435kb), agent.css (21kb)}` - dashboard unchanged (no shared sources touched). - Browser smoke test isn't possible from inside iris's container (no JS engine) — op-side check on next deploy. Closes #360. --- frontend/packages/agent/src/agent.css | 338 +++++++++++++++++++++++-- frontend/packages/agent/src/app.js | 139 +++++++--- frontend/packages/agent/src/index.html | 98 ++++--- 3 files changed, 496 insertions(+), 79 deletions(-) diff --git a/frontend/packages/agent/src/agent.css b/frontend/packages/agent/src/agent.css index 68103202..6222bcb2 100644 --- a/frontend/packages/agent/src/agent.css +++ b/frontend/packages/agent/src/agent.css @@ -3,10 +3,31 @@ @import "@hive/shared/base.css"; @import "@hive/shared/terminal.css"; -body { +/* ─── full-screen vibec0re overhaul (issue #360) ────────────────── + 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; margin: 1.5em auto; padding: 0 1.5em; + height: auto; } .banner { text-align: center; @@ -26,30 +47,177 @@ body { color: transparent; filter: drop-shadow(0 0 6px rgba(203, 166, 247, 0.45)); } -.banner.active { - animation: banner-shimmer 1.8s linear infinite; + +body.agent-shell { + 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 { - from { background-position: 200% 0; } - to { background-position: -100% 0; } + +.agent-header { + position: fixed; + 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 { color: var(--purple); text-transform: uppercase; letter-spacing: 0.15em; 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 { - width: 40px; - height: 40px; + width: 44px; + height: 44px; border-radius: 6px; 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; } .status-online { color: var(--green); text-shadow: 0 0 6px rgba(166, 227, 161, 0.55); } @@ -347,14 +515,54 @@ pre.diff { 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; } } -/* `.terminal-wrap`, `.live`, `.live.terminal`, row + pill + details - styling all live in hive-fr0nt::TERMINAL_CSS (prepended by serve_css). - What stays here is the composer chrome that sits inside the wrap. */ -.term-input { padding: 0.4em 1em 0.8em; } +/* Full-screen overrides for the shared terminal rules. The base + `.terminal-wrap` (in shared/src/terminal.css) ships a crust-on- + black frame for the in-page case; the agent page now owns the + whole viewport so the frame chrome would be redundant noise. + `.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 { display: flex; align-items: flex-start; 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); padding-top: 0.5em; } @@ -387,3 +595,97 @@ pre.diff { .term-input.disabled .prompt { color: var(--muted); text-shadow: none; } .term-input.disabled textarea { color: var(--muted); } /* 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
-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; +} diff --git a/frontend/packages/agent/src/app.js b/frontend/packages/agent/src/app.js index c2a56c55..605078ce 100644 --- a/frontend/packages/agent/src/app.js +++ b/frontend/packages/agent/src/app.js @@ -77,10 +77,69 @@ 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 ──────────────────────────────────────────────────── function setHeader(label, dashboardPort) { - $('banner').textContent = - `░▒▓█▓▒░ ${label} ░▒▓█▓▒░ hyperhive ag3nt ░▒▓█▓▒░`; const title = $('title'); title.textContent = `◆ ${label} ◆ `; // ↑ DASHB04RD — back-link to the host dashboard. Opens in a new @@ -415,8 +474,7 @@ window.marked = marked; // Loose-ends section: same data the get_loose_ends MCP tool // returns. Best-effort fetch on cold load + after every turn_end // (a turn likely answered or asked something). Silent failure - // keeps the section hidden rather than surfacing an empty banner. - let lastLooseEndsCount = 0; + // keeps the pill count at zero rather than surfacing a stale chrome. async function refreshLooseEnds() { try { const resp = await fetch('/api/loose-ends'); @@ -431,24 +489,22 @@ window.marked = marked; renderLooseEnds([]); } } - function renderLooseEnds(threads) { - const root = $('loose-ends-section'); - const list = $('loose-ends-list'); - const summary = $('loose-ends-summary'); - if (!root || !list || !summary) return; + /** Latest snapshot kept in module state so the pill click handler + * has fresh data to render into the panel without re-fetching. */ + let lastLooseEnds = []; + let lastInbox = []; + + function buildLooseEndsList(threads) { + // Returns the
the side panel renders. The structural shape + // mirrors the legacy
-collapsible block — same CSS rules + // apply via `.side-panel-body .agent-inbox`. + const wrap = el('div', { class: 'agent-inbox' }); if (!threads.length) { - root.hidden = true; - lastLooseEndsCount = 0; - return; + wrap.append(el('p', { class: 'side-panel-empty' }, + 'no loose ends — every question, approval and reminder is resolved.')); + return wrap; } - 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 list = el('ul'); const fmtAge = (s) => { if (s < 60) return s + 's'; if (s < 3600) return Math.floor(s / 60) + 'm'; @@ -492,6 +548,21 @@ window.marked = marked; } list.append(li); } + wrap.append(list); + return wrap; + } + + /** Pill-count + open-panel-refresh wiring for loose-ends. The legacy + * in-page `
` 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 @@ -530,18 +601,14 @@ window.marked = marked; return wrap; } - function renderInbox(rows) { - const root = $('inbox-section'); - const list = $('inbox-list'); - const summary = $('inbox-summary'); - if (!root || !list || !summary) return; + function buildInboxList(rows) { + const wrap = el('div', { class: 'agent-inbox' }); if (!rows.length) { - root.hidden = true; - return; + wrap.append(el('p', { class: 'side-panel-empty' }, + 'inbox empty.')); + return wrap; } - root.hidden = false; - summary.textContent = 'inbox · ' + rows.length; - list.innerHTML = ''; + const list = el('ul'); const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(5, 19); for (const m of rows) { const li = el('li', m.in_reply_to != null ? { class: 'inbox-reply' } : {}); @@ -556,6 +623,18 @@ window.marked = marked; ); 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 // status block reads. Each status maps to a glyph + label + colour diff --git a/frontend/packages/agent/src/index.html b/frontend/packages/agent/src/index.html index dcc0d2b9..5874e191 100644 --- a/frontend/packages/agent/src/index.html +++ b/frontend/packages/agent/src/index.html @@ -6,43 +6,79 @@ - - -
+ + + +
-

◆ … ◆

-
- +
+

◆ … ◆

+ +
-
-

loading…

-
+
+ + … booting + + + + + + +
-
- - … booting - - - - - - -
+ + + + - + +
+
+
+
connecting…
+
+
- - -
-
connecting…
+ +
+
+ + +