Compare commits
4 changed files with 26 additions and 236 deletions
|
|
@ -152,33 +152,6 @@
|
||||||
pointer-events: auto;
|
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. */
|
|
||||||
.load-more-pill {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--muted);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 0.8em;
|
|
||||||
font-weight: bold;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
border: 0;
|
|
||||||
border-bottom: 1px dashed var(--purple-dim);
|
|
||||||
padding: 0.4em 1em;
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
transition: color 120ms ease, background 120ms ease;
|
|
||||||
}
|
|
||||||
.load-more-pill:hover:not(:disabled) {
|
|
||||||
color: var(--fg);
|
|
||||||
background: var(--border);
|
|
||||||
}
|
|
||||||
.load-more-pill:disabled {
|
|
||||||
cursor: default;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
/* Expandable rows reuse the flat-row prefix metrics (padding-left +
|
/* Expandable rows reuse the flat-row prefix metrics (padding-left +
|
||||||
negative text-indent) so the disclosure glyph (`▸ / ▾`) lands in
|
negative text-indent) so the disclosure glyph (`▸ / ▾`) lands in
|
||||||
exactly the same column as flat-row prefix glyphs (`→ ← · ◆ ✓ ✗`).
|
exactly the same column as flat-row prefix glyphs (`→ ← · ◆ ✓ ✗`).
|
||||||
|
|
|
||||||
|
|
@ -50,10 +50,6 @@
|
||||||
// count=0); pages use it to set state flags from the replayed history.
|
// count=0); pages use it to set state flags from the replayed history.
|
||||||
|
|
||||||
const NEAR_BOTTOM_PX = 48;
|
const NEAR_BOTTOM_PX = 48;
|
||||||
// Scroll distance from the top of the log that triggers an automatic
|
|
||||||
// "load older" fetch — fires via the scroll event handler so the operator
|
|
||||||
// never has to click the pill; the pill stays as a visual indicator.
|
|
||||||
const LOAD_MORE_SCROLL_PX = 80;
|
|
||||||
// Snap-to-bottom animation duration. See docs/web-ui.md::Shared
|
// Snap-to-bottom animation duration. See docs/web-ui.md::Shared
|
||||||
// terminal pane (Sticky-bottom + snap animation) for the 140ms-vs-
|
// terminal pane (Sticky-bottom + snap animation) for the 140ms-vs-
|
||||||
// 500ms-browser-default + 24px short-circuit rationale.
|
// 500ms-browser-default + 24px short-circuit rationale.
|
||||||
|
|
@ -61,12 +57,8 @@ const SCROLL_ANIM_MS = 140;
|
||||||
const SCROLL_SNAP_PX = 24;
|
const SCROLL_SNAP_PX = 24;
|
||||||
|
|
||||||
export function create(opts) {
|
export function create(opts) {
|
||||||
// `log` is `let` not `const` so loadMore() can temporarily redirect
|
const log = opts.logEl;
|
||||||
// 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 renderers = opts.renderers || {};
|
||||||
const defaultRender = renderers._default
|
const defaultRender = renderers._default
|
||||||
|| ((ev, api) => api.row('note', JSON.stringify(ev)));
|
|| ((ev, api) => api.row('note', JSON.stringify(ev)));
|
||||||
|
|
@ -76,11 +68,6 @@ export function create(opts) {
|
||||||
let pill = null;
|
let pill = null;
|
||||||
let unseen = 0;
|
let unseen = 0;
|
||||||
let currentNoAnim = false;
|
let currentNoAnim = false;
|
||||||
// Pagination state for the "load older" feature.
|
|
||||||
let histMinId = null;
|
|
||||||
let histHasMore = false;
|
|
||||||
let histLoading = false;
|
|
||||||
let loadMoreBtn = null;
|
|
||||||
// Sticky-bottom intent. True means "keep snapping to bottom on
|
// Sticky-bottom intent. True means "keep snapping to bottom on
|
||||||
// any mutation"; false means "the operator scrolled up — leave
|
// any mutation"; false means "the operator scrolled up — leave
|
||||||
// them alone". Updated synchronously from the scroll event
|
// them alone". Updated synchronously from the scroll event
|
||||||
|
|
@ -164,11 +151,6 @@ export function create(opts) {
|
||||||
if (Date.now() < smoothScrollingUntil) return;
|
if (Date.now() < smoothScrollingUntil) return;
|
||||||
stickToBottom = isNearBottom();
|
stickToBottom = isNearBottom();
|
||||||
if (stickToBottom) { unseen = 0; updatePill(); }
|
if (stickToBottom) { unseen = 0; updatePill(); }
|
||||||
// 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) {
|
|
||||||
loadMore();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
// Post-append mutation snap — catches renderer mutations that land
|
// Post-append mutation snap — catches renderer mutations that land
|
||||||
// after `api.row` returns (badges, multi-line bodies, tool
|
// after `api.row` returns (badges, multi-line bodies, tool
|
||||||
|
|
@ -275,80 +257,6 @@ export function create(opts) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Load-older machinery ───────────────────────────────────────
|
|
||||||
//
|
|
||||||
// When the initial backfill response includes `has_more: true`, a
|
|
||||||
// "↑ load older" button appears at the top of the log. Clicking it
|
|
||||||
// fetches the next page (`?before=<min_id>`) and prepends the events
|
|
||||||
// while holding the viewport steady so the operator's reading position
|
|
||||||
// doesn't jump.
|
|
||||||
|
|
||||||
function updateLoadMoreBtn() {
|
|
||||||
if (!histHasMore || !opts.historyUrl) {
|
|
||||||
if (loadMoreBtn && loadMoreBtn.parentElement) {
|
|
||||||
loadMoreBtn.parentElement.removeChild(loadMoreBtn);
|
|
||||||
}
|
|
||||||
loadMoreBtn = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!loadMoreBtn) {
|
|
||||||
loadMoreBtn = document.createElement('button');
|
|
||||||
loadMoreBtn.type = 'button';
|
|
||||||
loadMoreBtn.className = 'load-more-pill';
|
|
||||||
loadMoreBtn.addEventListener('click', loadMore);
|
|
||||||
rootLog.prepend(loadMoreBtn);
|
|
||||||
}
|
|
||||||
loadMoreBtn.textContent = '↑ load older';
|
|
||||||
loadMoreBtn.disabled = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadMore() {
|
|
||||||
if (!histHasMore || histLoading || !opts.historyUrl || histMinId === null) return;
|
|
||||||
histLoading = true;
|
|
||||||
if (loadMoreBtn) { loadMoreBtn.textContent = '↑ loading…'; loadMoreBtn.disabled = true; }
|
|
||||||
try {
|
|
||||||
const sep = opts.historyUrl.includes('?') ? '&' : '?';
|
|
||||||
const url = opts.historyUrl + sep + 'before=' + histMinId;
|
|
||||||
const resp = await fetch(url);
|
|
||||||
if (!resp.ok) { updateLoadMoreBtn(); return; }
|
|
||||||
const body = await resp.json();
|
|
||||||
const events = Array.isArray(body) ? body : (body.events || []);
|
|
||||||
histHasMore = body.has_more || false;
|
|
||||||
if (typeof body.min_id === 'number') histMinId = body.min_id;
|
|
||||||
|
|
||||||
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');
|
|
||||||
log = tempEl;
|
|
||||||
currentNoAnim = true;
|
|
||||||
for (const ev of events) dispatch(ev, true);
|
|
||||||
currentNoAnim = false;
|
|
||||||
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 ───';
|
|
||||||
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 beforeH = rootLog.scrollHeight;
|
|
||||||
while (tempEl.firstChild) rootLog.insertBefore(tempEl.firstChild, anchor);
|
|
||||||
// Compensate scroll so the viewport stays on the same content.
|
|
||||||
rootLog.scrollTop += rootLog.scrollHeight - beforeH;
|
|
||||||
}
|
|
||||||
updateLoadMoreBtn();
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('loadMore failed', err);
|
|
||||||
updateLoadMoreBtn();
|
|
||||||
} finally {
|
|
||||||
histLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscribe → buffer → fetch history → dedupe → apply.
|
// Subscribe → buffer → fetch history → dedupe → apply.
|
||||||
//
|
//
|
||||||
// Race the SSE subscription opens before the history fetch starts.
|
// Race the SSE subscription opens before the history fetch starts.
|
||||||
|
|
@ -451,16 +359,11 @@ export function create(opts) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const body = await resp.json();
|
const body = await resp.json();
|
||||||
// Accept the envelope `{ seq, events, min_id?, has_more? }`.
|
// Accept the envelope `{ seq, events }`. A bare array means
|
||||||
// A bare array means the server hasn't been updated — treat it
|
// the server hasn't been updated to include seq yet — treat
|
||||||
// as "no dedupe possible, no pagination."
|
// it as "no dedupe possible."
|
||||||
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);
|
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;
|
|
||||||
}
|
|
||||||
// Kinds present in the history replay — the only kinds that
|
// Kinds present in the history replay — the only kinds that
|
||||||
// can double and therefore the only ones to seq-dedupe.
|
// can double and therefore the only ones to seq-dedupe.
|
||||||
const historyKinds = new Set(events.map((ev) => ev.kind));
|
const historyKinds = new Set(events.map((ev) => ev.kind));
|
||||||
|
|
@ -470,8 +373,6 @@ export function create(opts) {
|
||||||
if (events.length) row('note', '─── live (older above) ───');
|
if (events.length) row('note', '─── live (older above) ───');
|
||||||
else placeholder('(connected — waiting for events)');
|
else placeholder('(connected — waiting for events)');
|
||||||
flushBuffered(boundarySeq, historyKinds);
|
flushBuffered(boundarySeq, historyKinds);
|
||||||
// Show load-older button if the server reports more history.
|
|
||||||
updateLoadMoreBtn();
|
|
||||||
if (opts.onBackfillDone) opts.onBackfillDone(events.length);
|
if (opts.onBackfillDone) opts.onBackfillDone(events.length);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('history backfill failed', err);
|
console.warn('history backfill failed', err);
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ use tokio::sync::broadcast;
|
||||||
const CHANNEL_CAPACITY: usize = 256;
|
const CHANNEL_CAPACITY: usize = 256;
|
||||||
/// Max `LiveEvent`s the `Bus` returns from `history()` and keeps in
|
/// Max `LiveEvent`s the `Bus` returns from `history()` and keeps in
|
||||||
/// sqlite. Older rows are vacuumed on a periodic sweep.
|
/// sqlite. Older rows are vacuumed on a periodic sweep.
|
||||||
pub const HISTORY_CAPACITY: usize = 2000;
|
const HISTORY_CAPACITY: usize = 2000;
|
||||||
/// Path to the persisted event db. Overridable via `HYPERHIVE_EVENTS_DB`
|
/// Path to the persisted event db. Overridable via `HYPERHIVE_EVENTS_DB`
|
||||||
/// for dev / tests; otherwise derived from the agent's harness dir.
|
/// for dev / tests; otherwise derived from the agent's harness dir.
|
||||||
fn events_db_path() -> PathBuf {
|
fn events_db_path() -> PathBuf {
|
||||||
|
|
@ -249,61 +249,20 @@ impl EventStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn recent(&self, limit: usize) -> rusqlite::Result<Vec<LiveEvent>> {
|
fn recent(&self, limit: usize) -> rusqlite::Result<Vec<LiveEvent>> {
|
||||||
let (events, _, _) = self.page(None, limit)?;
|
|
||||||
Ok(events)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetch up to `limit` events with id < `before_id` (or the most recent
|
|
||||||
/// `limit` events when `before_id` is `None`). Returns
|
|
||||||
/// `(events_oldest_first, min_row_id, has_more)`.
|
|
||||||
fn page(
|
|
||||||
&self,
|
|
||||||
before_id: Option<i64>,
|
|
||||||
limit: usize,
|
|
||||||
) -> rusqlite::Result<(Vec<LiveEvent>, Option<i64>, bool)> {
|
|
||||||
let limit_i = i64::try_from(limit).unwrap_or(i64::MAX);
|
let limit_i = i64::try_from(limit).unwrap_or(i64::MAX);
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
// Fetch one extra row so we can tell whether more exist.
|
|
||||||
let fetch = limit_i.saturating_add(1);
|
|
||||||
let rows: Vec<(i64, LiveEvent)> = match before_id {
|
|
||||||
Some(bid) => {
|
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, payload_json FROM events
|
"SELECT payload_json FROM events
|
||||||
WHERE id < ?1
|
|
||||||
ORDER BY id DESC
|
|
||||||
LIMIT ?2",
|
|
||||||
)?;
|
|
||||||
stmt.query_map(params![bid, fetch], |row| {
|
|
||||||
let id: i64 = row.get(0)?;
|
|
||||||
let s: String = row.get(1)?;
|
|
||||||
Ok(serde_json::from_str::<LiveEvent>(&s).ok().map(|e| (id, e)))
|
|
||||||
})?
|
|
||||||
.flatten()
|
|
||||||
.flatten()
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
let mut stmt = conn.prepare(
|
|
||||||
"SELECT id, payload_json FROM events
|
|
||||||
ORDER BY id DESC
|
ORDER BY id DESC
|
||||||
LIMIT ?1",
|
LIMIT ?1",
|
||||||
)?;
|
)?;
|
||||||
stmt.query_map(params![fetch], |row| {
|
let rows = stmt.query_map(params![limit_i], |row| {
|
||||||
let id: i64 = row.get(0)?;
|
let s: String = row.get(0)?;
|
||||||
let s: String = row.get(1)?;
|
Ok(serde_json::from_str::<LiveEvent>(&s).ok())
|
||||||
Ok(serde_json::from_str::<LiveEvent>(&s).ok().map(|e| (id, e)))
|
})?;
|
||||||
})?
|
let mut out: Vec<LiveEvent> = rows.flatten().flatten().collect();
|
||||||
.flatten()
|
out.reverse();
|
||||||
.flatten()
|
Ok(out)
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let has_more = rows.len() > limit;
|
|
||||||
let mut rows: Vec<(i64, LiveEvent)> = rows.into_iter().take(limit).collect();
|
|
||||||
rows.reverse(); // oldest first
|
|
||||||
let min_id = rows.first().map(|(id, _)| *id);
|
|
||||||
let events = rows.into_iter().map(|(_, e)| e).collect();
|
|
||||||
Ok((events, min_id, has_more))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -875,23 +834,6 @@ impl Bus {
|
||||||
};
|
};
|
||||||
store.recent(HISTORY_CAPACITY).unwrap_or_default()
|
store.recent(HISTORY_CAPACITY).unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Paginated history: up to `limit` events before `before_id`
|
|
||||||
/// (or the most recent `limit` when `before_id` is `None`).
|
|
||||||
/// Returns `(events_oldest_first, min_row_id, has_more)`.
|
|
||||||
/// `min_row_id` is the cursor for the next page; pass it as
|
|
||||||
/// `before_id` on the next call.
|
|
||||||
#[must_use]
|
|
||||||
pub fn history_page(
|
|
||||||
&self,
|
|
||||||
before_id: Option<i64>,
|
|
||||||
limit: usize,
|
|
||||||
) -> (Vec<LiveEvent>, Option<i64>, bool) {
|
|
||||||
let Some(store) = &self.store else {
|
|
||||||
return (Vec::new(), None, false);
|
|
||||||
};
|
|
||||||
store.page(before_id, limit).unwrap_or_default()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Bus {
|
impl Default for Bus {
|
||||||
|
|
|
||||||
|
|
@ -717,41 +717,15 @@ async fn post_send(State(state): State<AppState>, Form(form): Form<SendForm>) ->
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Query params for the paginated history endpoint.
|
async fn events_history(State(state): State<AppState>) -> axum::Json<serde_json::Value> {
|
||||||
#[derive(Debug, Deserialize)]
|
// Capture seq *before* the read so dedupe is "drop buffered events
|
||||||
struct HistoryParams {
|
// you've already seen in history", never "lose an event that fired
|
||||||
/// Cursor: only return events with sqlite row id < `before`.
|
// between the read and the timestamp." Historical rows have no
|
||||||
/// Omit for the initial (most-recent) page.
|
// per-row seq; only the high-water mark matters for the dedupe
|
||||||
before: Option<i64>,
|
// window.
|
||||||
/// Page size (default 100, capped at HISTORY_CAPACITY).
|
let seq = state.bus.current_seq();
|
||||||
limit: Option<usize>,
|
let events = state.bus.history();
|
||||||
}
|
axum::Json(serde_json::json!({ "seq": seq, "events": events }))
|
||||||
|
|
||||||
async fn events_history(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
axum::extract::Query(params): axum::extract::Query<HistoryParams>,
|
|
||||||
) -> axum::Json<serde_json::Value> {
|
|
||||||
use crate::events::HISTORY_CAPACITY;
|
|
||||||
let limit = params.limit.unwrap_or(100).min(HISTORY_CAPACITY);
|
|
||||||
let before = params.before;
|
|
||||||
let is_initial = before.is_none();
|
|
||||||
|
|
||||||
// Capture seq *before* the read on initial loads so the SSE dedupe
|
|
||||||
// window is "drop buffered events you've already seen in history",
|
|
||||||
// never "lose an event that fired between the read and the seq."
|
|
||||||
// On paginated loads (`before` is set) seq is not needed.
|
|
||||||
let seq = if is_initial { Some(state.bus.current_seq()) } else { None };
|
|
||||||
|
|
||||||
let (events, min_id, has_more) = state.bus.history_page(before, limit);
|
|
||||||
let mut resp = serde_json::json!({
|
|
||||||
"events": events,
|
|
||||||
"min_id": min_id,
|
|
||||||
"has_more": has_more,
|
|
||||||
});
|
|
||||||
if let Some(s) = seq {
|
|
||||||
resp["seq"] = serde_json::json!(s);
|
|
||||||
}
|
|
||||||
axum::Json(resp)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn events_stream(
|
async fn events_stream(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue