diff --git a/frontend/packages/swarm-ui/src/pages/CreateAgentPage.css b/frontend/packages/swarm-ui/src/pages/CreateAgentPage.css index 394ad05c..b1fe91a2 100644 --- a/frontend/packages/swarm-ui/src/pages/CreateAgentPage.css +++ b/frontend/packages/swarm-ui/src/pages/CreateAgentPage.css @@ -1,10 +1,45 @@ /* — the create-agent form. Input/button chrome now comes from the shared `ui/` form kit (`TextField`/`SelectField`/ `Button`); this file only owns the page's own layout + copy. Column, - not row: with a second field (hive) added, a row layout put fields of - different natural widths on one baseline and looked misaligned — - mara: "make it a col". Reuses the same base16 slots (../theme.css) - every other component draws from. */ + not row inside the form itself: with a second field (hive) added, a + row layout put fields of different natural widths on one baseline and + looked misaligned — mara: "make it a col". Reuses the same base16 + slots (../theme.css) every other component draws from. + + This layout dropped an explicit page `max-width` after a few rounds: + first added so bare `Panel` (no width opinion of its own) wouldn't + fill `.shell-body`'s full 60em column behind a narrow form (the + original "weird empty space" complaint), then widened for two panels + side by side — but once both were equal-width flex children that cap + itself became "doesn't fill the space" (mara: "the cards are neither + centered nor fill the space tho"). Removing it lets the row fill + `.shell-body`'s own width like most pages — nothing sits off-center + once nothing is narrower than the row. + + `.create-agent-form-col`/`.create-agent-info-col` are equal-sized + (`flex: 1 1 0` — zero basis so the 1.5em `gap` splits the row evenly, + not each column keeping its natural content width) per mara's "both + cards should be equal sized". `min-width` is the wrap threshold below + which the row goes single-column instead (info card moving under the + form) — no separate media query needed. Inputs fill their whole + column now too ("inputs should fill full card width") — the kit's + `.ui-form-control`/`.ui-form-field` dropped their old 16em cap for + exactly this (../ui/form-field/FormField.css). */ +.create-agent-layout { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 1.5em; +} +.create-agent-form-col, +.create-agent-info-col { + flex: 1 1 0; + min-width: 16em; +} +.create-agent-info-glyph { + margin: 0 0 0.5em; + font-size: 2em; +} .create-agent-intro { margin: 0 0 1.5em; color: var(--muted); diff --git a/frontend/packages/swarm-ui/src/pages/CreateAgentPage.tsx b/frontend/packages/swarm-ui/src/pages/CreateAgentPage.tsx index 7bb605b2..eaa22152 100644 --- a/frontend/packages/swarm-ui/src/pages/CreateAgentPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/CreateAgentPage.tsx @@ -125,44 +125,74 @@ export function CreateAgentPage() { } return ( - -

- Create a new agent's swarm-level identity. This only queues the job — check{' '} - jobs to watch it settle. -

- {hivesError && } -
- - - - - {result.status === 'done' && ( -

- queued as job node #{result.nodeId} — watch it in jobs -

- )} - {result.status === 'error' && ( - - )} -
+
+
+ +

+ Create a new agent's swarm-level identity. This only queues the job — check{' '} + jobs to watch it settle. +

+ {hivesError && ( + + )} +
+ + + + + {result.status === 'done' && ( +

+ queued as job node #{result.nodeId} — watch it in jobs +

+ )} + {result.status === 'error' && ( + + )} +
+
+
+ {/* Describes the intended end state (a running agent), not today's + literal behaviour (deploying the container onto the hive isn't + wired up server-side yet — see this file's top comment) — + mara's explicit call on this copy: read as finished, not as a + running commentary on partial implementation. */} + + +

+ Submitting this queues everything a new agent needs: a swarm-level identity, a config + repo on the forge with the operator added as a collaborator, and a container running + the agent on the hive you pick above. Watch it all settle on{' '} + jobs. +

+
+
+
); } diff --git a/frontend/packages/swarm-ui/src/ui/form-field/FormField.css b/frontend/packages/swarm-ui/src/ui/form-field/FormField.css index 29257bdd..ed4f1393 100644 --- a/frontend/packages/swarm-ui/src/ui/form-field/FormField.css +++ b/frontend/packages/swarm-ui/src/ui/form-field/FormField.css @@ -1,18 +1,34 @@ /* — label stacked above its control, plus the shared `.ui-form-control` chrome every text/select input in the kit draws - from (one class, so the two never drift). `max-width` + `width: 100%` - rather than a fixed `width`: caps the control on a wide desktop - viewport without forcing an overflow on a narrow/touch one. - `min-height` is a touch-target floor (44px at the default 16px root - font — WCAG 2.5.5's minimum), not a visual choice — it's the same on - every control in the kit whether or not it's ever used on a touch - device, since the alternative is a component that behaves differently - per input method. Colours are the shared base16-derived vars - (../../theme.css), never literal. */ + from (one class, so the two never drift). `width: 100%` fills + whichever container the caller gives it — the kit itself has no + opinion on a maximum width; a page that wants one narrower than its + own layout caps it at the layout level (`CreateAgentPage.css`'s + `.create-agent-form-col` is the existing example), same reasoning + `Panel` has no width opinion of its own either. `min-height` is a + touch-target floor (44px at the default 16px root font — WCAG + 2.5.5's minimum), not a visual choice — it's the same on every + control in the kit whether or not it's ever used on a touch device, + since the alternative is a component that behaves differently per + input method. Colours are the shared base16-derived vars + (../../theme.css), never literal. + + The field wrapper repeats `.ui-form-control`'s own `width: 100%` + rather than leaving the wrapper unconstrained: inside a shrink-to-fit + flex column (`CreateAgentPage`'s form is one), an unconstrained + wrapper sizes to its own content — and a `width: 100%` *control* + inside an auto-width wrapper resolves against that shrunk width, not + the container the page actually gave it, so two fields with + differently-long labels ("agent name" vs "hive") ended up with + differently-wide inputs — the misalignment mara reported on the + create-agent page. Matching the two declarations here means every + field's control width is driven by the same container width + regardless of its label's length or its siblings'. */ .ui-form-field { display: flex; flex-direction: column; gap: 0.3em; + width: 100%; } .ui-form-field-label { font-size: 0.85em; @@ -26,7 +42,6 @@ padding: 0.4em 0.6em; font: inherit; width: 100%; - max-width: 16em; box-sizing: border-box; min-height: 2.75em; } diff --git a/hive-matrix-mcp/src/handlers.rs b/hive-matrix-mcp/src/handlers.rs index 1dc1e724..7ec2059f 100644 --- a/hive-matrix-mcp/src/handlers.rs +++ b/hive-matrix-mcp/src/handlers.rs @@ -193,24 +193,52 @@ async fn unread_guard(client: &Client, room: &matrix_sdk::Room) -> Option Option<(OwnedEventId, OwnedUserId, String)> { +) -> Vec { use matrix_sdk::ruma::api::Direction; use matrix_sdk::ruma::api::client::message::get_message_events; let mut req = get_message_events::v3::Request::new(room.room_id().to_owned(), Direction::Backward); - req.limit = matrix_sdk::ruma::UInt::from(1u32); - let resp = client.send(req).await.ok()?; - let raw = resp.chunk.first()?; - let ev = raw.deserialize().ok()?; - let body = extract_body(&ev); - Some((ev.event_id().to_owned(), ev.sender().to_owned(), body)) + req.limit = matrix_sdk::ruma::UInt::from(UNREAD_LOOKBACK); + let Ok(resp) = client.send(req).await else { + return Vec::new(); + }; + resp.chunk + .iter() + .filter_map(|raw| raw.deserialize().ok()) + .collect() +} + +/// Whether `event` carries actor intent, as opposed to noise nobody +/// "did" anything to produce. Everything counts by default — messages +/// (of every msgtype, not just `m.room.message`'s common ones), +/// reactions, state changes (join/leave/topic/name/etc. are all real +/// activity an agent should see, mara: don't lump them in with +/// housekeeping noise). The one deliberate exception is `m.space.child`: +/// a periodic re-apply of an *unchanged* value still appends a timeline +/// event (see [`room_unread_state`]'s doc comment), and that specific +/// redundant re-emit is the one thing this function exists to filter — +/// an earlier version of this filter over-corrected by excluding whole +/// event categories instead of just that one type; narrower is right. +fn is_intentional(event: &matrix_sdk::ruma::events::AnyTimelineEvent) -> bool { + use matrix_sdk::ruma::events::{AnyStateEvent, AnyTimelineEvent}; + !matches!(event, AnyTimelineEvent::State(AnyStateEvent::SpaceChild(_))) } /// Whether `room` carries content the agent hasn't caught up on. @@ -232,21 +260,22 @@ async fn latest_event( /// Self-authored latest events are never "unread" — after a daemon /// rebuild the read receipt can lag behind the agent's own just-sent /// message, which must not self-wake it. +/// +/// Walks backward through up to [`UNREAD_LOOKBACK`] events (not just the +/// newest one) looking for the first [`is_intentional`] hit, skipping a +/// chatty state re-emit nobody "posted" instead of treating it as gospel. +/// See the three branches below for what each outcome means. async fn room_unread_state( client: &Client, room: &matrix_sdk::Room, ) -> Option<(OwnedEventId, OwnedUserId, String)> { let own_user_id = client.user_id()?; - let (event_id, sender, body) = latest_event(client, room).await?; - if sender.as_str() == own_user_id.as_str() { - return None; - } // `load_user_receipt` takes `ruma::events::receipt::ReceiptType` // (imported here as `LocalReceiptType`), a distinct type from the // `ReceiptType` this module already imports for `send_single_receipt` // (`ruma::api::client::receipt::create_receipt::v3::ReceiptType`) — two // same-named enums from different ruma crates, not interchangeable. - let read = room + let read_event_id = room .load_user_receipt( LocalReceiptType::Read, ReceiptThread::Unthreaded, @@ -254,11 +283,47 @@ async fn room_unread_state( ) .await .ok() - .flatten(); - if read.is_some_and(|(id, _)| id == event_id) { + .flatten() + .map(|(id, _)| id); + + let events = recent_events(client, room).await; + for event in &events { + if read_event_id.as_deref() == Some(event.event_id()) { + // Scanned back to the agent's own read receipt without + // hitting anything intentional first — proven read, not + // guessed: there cannot be an unread intentional event in a + // range we've fully walked. + return None; + } + if !is_intentional(event) { + continue; + } + if event.sender().as_str() == own_user_id.as_str() { + return None; + } + return Some(( + event.event_id().to_owned(), + event.sender().to_owned(), + extract_body(event), + )); + } + // Exhausted the lookback window without hitting the receipt or an + // intentional event — genuinely unknown (the receipt is further + // back than we scanned). Falls back to the pre-fix behaviour + // (surface the newest event, whatever its type) rather than + // guessing "read": a false "unread" just costs a wasted wake, a + // false "read" risks silently swallowing a real message. Expected + // to be rare now that the state-churn source this fix targets is + // itself bounded. + let newest = events.first()?; + if newest.sender().as_str() == own_user_id.as_str() { return None; } - Some((event_id, sender, body)) + Some(( + newest.event_id().to_owned(), + newest.sender().to_owned(), + extract_body(newest), + )) } pub async fn send_message(client: &Client, room_ref: &str, body: &str) -> DaemonResponse {