Compare commits

...
Author SHA1 Message Date
damocles
aca39072d5 fix(#3072): stop waking every agent on a re-applied m.space.child state event 2026-08-19 17:12:01 +02:00
iris
1207a0e282 swarm-ui: create-agent layout fills the page width, centered
mara: the two cards were neither centered nor filling the space in
two-column mode. Dropped the page's own max-width entirely — Panel
already has no width opinion, and once both cards are equal-width
flex children there's no reason for an extra cap between them and
.shell-body's own 60em/centered column. Removed the now-pointless
wrapper div along with it.

Checked centered + filling behaviour at both a normal (1200px) and an
ultra-wide (1600px) viewport, and narrow-viewport stacking still
works.
2026-08-19 17:11:59 +02:00
iris
3b339b2232 swarm-ui: equal-width create-agent cards, full-width inputs
mara's follow-up: both cards should be the same size (flex: 1 1 0
instead of the form keeping its own narrower basis), and the form's
inputs should fill the card rather than stopping at their old 16em
cap. Dropped that cap from the shared form kit (.ui-form-control /
.ui-form-field) entirely rather than overriding it per-page — the kit
itself has no width opinion now, same as Panel already has none; a
page that wants a narrower field caps its layout, not the kit.

Checked ComponentsPage's standalone samples (no regression, just
wider) and narrow-viewport stacking (still moves the explanation
under the form).
2026-08-19 17:11:59 +02:00
iris
f30897b44e swarm-ui: create-agent info panel reads as the finished flow
mara's review: the info-panel copy should describe the intended end
state (a running agent on the chosen hive), not narrate today's
partial implementation (deploy isn't wired up server-side yet — noted
in this file's existing top comment for maintainers, kept separate
from the user-facing copy).
2026-08-19 17:11:59 +02:00
iris
fc65811d9f swarm-ui: fill create-agent's empty column with an explanatory panel
mara's follow-up on the field-alignment fix: narrowing the form panel
left a lot of bare space next to it, and asked for something that
fills it while helping a new user understand what the page does.

Adds a second panel beside the form (stacks under it on a narrow
viewport) explaining the job chain '/api/agents' actually queues:
an authelia identity, then a forge config repo — no container exists
yet after this page, and deploying one onto the chosen hive is a
separate step that isn't wired up server-side.
2026-08-19 17:11:59 +02:00
iris
f14fc154cc swarm-ui: fix create-agent form field alignment and panel width
TextField and SelectField's shared FormField wrapper had no width of
its own, so inside the form's shrink-to-fit flex column each field's
input/select resolved its 'width: 100%' against its own shrunk
wrapper rather than a shared column width — two fields with
differently-long labels ended up with differently-wide controls.
FormField now caps its own width the same way the control already
does, so every field in a form lines up regardless of label length.

Also wrapped the page in a max-width container: Panel has no width
opinion of its own, so it filled the full page column, leaving a lot
of bare panel to the right of the ~16em-wide form.
2026-08-19 17:11:59 +02:00
4 changed files with 217 additions and 72 deletions

View file

@ -1,10 +1,45 @@
/* <CreateAgentPage> 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);

View file

@ -125,44 +125,74 @@ export function CreateAgentPage() {
}
return (
<Panel title="create agent">
<p class="create-agent-intro">
Create a new agent's swarm-level identity. This only queues the job — check{' '}
<Link href="/jobs">jobs</Link> to watch it settle.
</p>
{hivesError && <ApiErrorPanel context="failed to load the hive list" problem={hivesError} />}
<form class="create-agent-form" onSubmit={submit}>
<TextField
id="agent-name"
label="agent name"
value={name}
pattern={NAME_PATTERN}
title="1-63 chars: lowercase letters, digits, hyphens"
required
onInput={setName}
/>
<SelectField
id="agent-hive"
label="hive"
value={hive}
onChange={setHive}
options={hiveOptions}
required
disabled={!hivesReady}
placeholder={!hives ? 'loading…' : hives.length === 0 ? 'no hives configured' : 'select a hive'}
/>
<Button variant="primary" type="submit" disabled={result.status === 'submitting' || !hivesReady}>
{result.status === 'submitting' ? 'creating…' : 'create'}
</Button>
</form>
{result.status === 'done' && (
<p class="create-agent-result create-agent-result-ok">
queued as job node #{result.nodeId} <Link href="/jobs">watch it in jobs</Link>
</p>
)}
{result.status === 'error' && (
<ApiErrorPanel context="failed to queue" problem={result.problem} />
)}
</Panel>
<div class="create-agent-layout">
<div class="create-agent-form-col">
<Panel title="create agent">
<p class="create-agent-intro">
Create a new agent's swarm-level identity. This only queues the job — check{' '}
<Link href="/jobs">jobs</Link> to watch it settle.
</p>
{hivesError && (
<ApiErrorPanel context="failed to load the hive list" problem={hivesError} />
)}
<form class="create-agent-form" onSubmit={submit}>
<TextField
id="agent-name"
label="agent name"
value={name}
pattern={NAME_PATTERN}
title="1-63 chars: lowercase letters, digits, hyphens"
required
onInput={setName}
/>
<SelectField
id="agent-hive"
label="hive"
value={hive}
onChange={setHive}
options={hiveOptions}
required
disabled={!hivesReady}
placeholder={
!hives ? 'loading…' : hives.length === 0 ? 'no hives configured' : 'select a hive'
}
/>
<Button
variant="primary"
type="submit"
disabled={result.status === 'submitting' || !hivesReady}
>
{result.status === 'submitting' ? 'creating…' : 'create'}
</Button>
</form>
{result.status === 'done' && (
<p class="create-agent-result create-agent-result-ok">
queued as job node #{result.nodeId} <Link href="/jobs">watch it in jobs</Link>
</p>
)}
{result.status === 'error' && (
<ApiErrorPanel context="failed to queue" problem={result.problem} />
)}
</Panel>
</div>
<div class="create-agent-info-col">
{/* 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. */}
<Panel title="what this creates">
<p class="create-agent-info-glyph" aria-hidden="true">
🪪
</p>
<p>
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{' '}
<Link href="/jobs">jobs</Link>.
</p>
</Panel>
</div>
</div>
);
}

View file

@ -1,18 +1,34 @@
/* <FormField> 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;
}

View file

@ -193,24 +193,52 @@ async fn unread_guard(client: &Client, room: &matrix_sdk::Room) -> Option<Daemon
)))
}
/// Fetch the single most recent timeline event in `room`, of any type
/// (redactions/state/reactions included — identity is what
/// `room_unread_state` needs, not content). `None` for a genuinely
/// Timeline events to scan backward, per room, when hunting for the
/// latest event that carries actor intent (see [`is_intentional`])
/// before giving up and answering conservatively. Bounds the walk a
/// chatty periodic state re-emit (the prior `m.space.child` PUT-always-
/// appends-an-event fix, since resolved on the forge tracker: `m.space.child`
/// PUT'd unconditionally on an unchanged value, still appending a timeline
/// event) would otherwise force — see [`room_unread_state`] for how the
/// bound is used and why it doesn't need to be exact.
const UNREAD_LOOKBACK: u32 = 50;
/// Fetch up to [`UNREAD_LOOKBACK`] timeline events in `room`, newest
/// first, of any type (redactions/state/reactions included —
/// `room_unread_state` filters by type itself). Empty for a genuinely
/// empty room or on any request failure.
async fn latest_event(
async fn recent_events(
client: &Client,
room: &matrix_sdk::Room,
) -> Option<(OwnedEventId, OwnedUserId, String)> {
) -> Vec<matrix_sdk::ruma::events::AnyTimelineEvent> {
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 {