Compare commits

..
4 changed files with 72 additions and 217 deletions

View file

@ -1,45 +1,10 @@
/* <CreateAgentPage> the create-agent form. Input/button chrome now /* <CreateAgentPage> the create-agent form. Input/button chrome now
comes from the shared `ui/` form kit (`TextField`/`SelectField`/ comes from the shared `ui/` form kit (`TextField`/`SelectField`/
`Button`); this file only owns the page's own layout + copy. Column, `Button`); this file only owns the page's own layout + copy. Column,
not row inside the form itself: with a second field (hive) added, a not row: with a second field (hive) added, a row layout put fields of
row layout put fields of different natural widths on one baseline and different natural widths on one baseline and looked misaligned
looked misaligned mara: "make it a col". Reuses the same base16 mara: "make it a col". Reuses the same base16 slots (../theme.css)
slots (../theme.css) every other component draws from. 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 { .create-agent-intro {
margin: 0 0 1.5em; margin: 0 0 1.5em;
color: var(--muted); color: var(--muted);

View file

@ -125,74 +125,44 @@ export function CreateAgentPage() {
} }
return ( return (
<div class="create-agent-layout"> <Panel title="create agent">
<div class="create-agent-form-col"> <p class="create-agent-intro">
<Panel title="create agent"> Create a new agent's swarm-level identity. This only queues the job — check{' '}
<p class="create-agent-intro"> <Link href="/jobs">jobs</Link> to watch it settle.
Create a new agent's swarm-level identity. This only queues the job — check{' '} </p>
<Link href="/jobs">jobs</Link> to watch it settle. {hivesError && <ApiErrorPanel context="failed to load the hive list" problem={hivesError} />}
</p> <form class="create-agent-form" onSubmit={submit}>
{hivesError && ( <TextField
<ApiErrorPanel context="failed to load the hive list" problem={hivesError} /> id="agent-name"
)} label="agent name"
<form class="create-agent-form" onSubmit={submit}> value={name}
<TextField pattern={NAME_PATTERN}
id="agent-name" title="1-63 chars: lowercase letters, digits, hyphens"
label="agent name" required
value={name} onInput={setName}
pattern={NAME_PATTERN} />
title="1-63 chars: lowercase letters, digits, hyphens" <SelectField
required id="agent-hive"
onInput={setName} label="hive"
/> value={hive}
<SelectField onChange={setHive}
id="agent-hive" options={hiveOptions}
label="hive" required
value={hive} disabled={!hivesReady}
onChange={setHive} placeholder={!hives ? 'loading…' : hives.length === 0 ? 'no hives configured' : 'select a hive'}
options={hiveOptions} />
required <Button variant="primary" type="submit" disabled={result.status === 'submitting' || !hivesReady}>
disabled={!hivesReady} {result.status === 'submitting' ? 'creating…' : 'create'}
placeholder={ </Button>
!hives ? 'loading…' : hives.length === 0 ? 'no hives configured' : 'select a hive' </form>
} {result.status === 'done' && (
/> <p class="create-agent-result create-agent-result-ok">
<Button queued as job node #{result.nodeId} <Link href="/jobs">watch it in jobs</Link>
variant="primary" </p>
type="submit" )}
disabled={result.status === 'submitting' || !hivesReady} {result.status === 'error' && (
> <ApiErrorPanel context="failed to queue" problem={result.problem} />
{result.status === 'submitting' ? 'creating…' : 'create'} )}
</Button> </Panel>
</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,34 +1,18 @@
/* <FormField> label stacked above its control, plus the shared /* <FormField> label stacked above its control, plus the shared
`.ui-form-control` chrome every text/select input in the kit draws `.ui-form-control` chrome every text/select input in the kit draws
from (one class, so the two never drift). `width: 100%` fills from (one class, so the two never drift). `max-width` + `width: 100%`
whichever container the caller gives it the kit itself has no rather than a fixed `width`: caps the control on a wide desktop
opinion on a maximum width; a page that wants one narrower than its viewport without forcing an overflow on a narrow/touch one.
own layout caps it at the layout level (`CreateAgentPage.css`'s `min-height` is a touch-target floor (44px at the default 16px root
`.create-agent-form-col` is the existing example), same reasoning font WCAG 2.5.5's minimum), not a visual choice — it's the same on
`Panel` has no width opinion of its own either. `min-height` is a every control in the kit whether or not it's ever used on a touch
touch-target floor (44px at the default 16px root font WCAG device, since the alternative is a component that behaves differently
2.5.5's minimum), not a visual choice — it's the same on every per input method. Colours are the shared base16-derived vars
control in the kit whether or not it's ever used on a touch device, (../../theme.css), never literal. */
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 { .ui-form-field {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.3em; gap: 0.3em;
width: 100%;
} }
.ui-form-field-label { .ui-form-field-label {
font-size: 0.85em; font-size: 0.85em;
@ -42,6 +26,7 @@
padding: 0.4em 0.6em; padding: 0.4em 0.6em;
font: inherit; font: inherit;
width: 100%; width: 100%;
max-width: 16em;
box-sizing: border-box; box-sizing: border-box;
min-height: 2.75em; min-height: 2.75em;
} }

View file

@ -193,52 +193,24 @@ async fn unread_guard(client: &Client, room: &matrix_sdk::Room) -> Option<Daemon
))) )))
} }
/// Timeline events to scan backward, per room, when hunting for the /// Fetch the single most recent timeline event in `room`, of any type
/// latest event that carries actor intent (see [`is_intentional`]) /// (redactions/state/reactions included — identity is what
/// before giving up and answering conservatively. Bounds the walk a /// `room_unread_state` needs, not content). `None` for a genuinely
/// 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. /// empty room or on any request failure.
async fn recent_events( async fn latest_event(
client: &Client, client: &Client,
room: &matrix_sdk::Room, room: &matrix_sdk::Room,
) -> Vec<matrix_sdk::ruma::events::AnyTimelineEvent> { ) -> Option<(OwnedEventId, OwnedUserId, String)> {
use matrix_sdk::ruma::api::Direction; use matrix_sdk::ruma::api::Direction;
use matrix_sdk::ruma::api::client::message::get_message_events; use matrix_sdk::ruma::api::client::message::get_message_events;
let mut req = let mut req =
get_message_events::v3::Request::new(room.room_id().to_owned(), Direction::Backward); get_message_events::v3::Request::new(room.room_id().to_owned(), Direction::Backward);
req.limit = matrix_sdk::ruma::UInt::from(UNREAD_LOOKBACK); req.limit = matrix_sdk::ruma::UInt::from(1u32);
let Ok(resp) = client.send(req).await else { let resp = client.send(req).await.ok()?;
return Vec::new(); let raw = resp.chunk.first()?;
}; let ev = raw.deserialize().ok()?;
resp.chunk let body = extract_body(&ev);
.iter() Some((ev.event_id().to_owned(), ev.sender().to_owned(), body))
.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. /// Whether `room` carries content the agent hasn't caught up on.
@ -260,22 +232,21 @@ fn is_intentional(event: &matrix_sdk::ruma::events::AnyTimelineEvent) -> bool {
/// Self-authored latest events are never "unread" — after a daemon /// Self-authored latest events are never "unread" — after a daemon
/// rebuild the read receipt can lag behind the agent's own just-sent /// rebuild the read receipt can lag behind the agent's own just-sent
/// message, which must not self-wake it. /// 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( async fn room_unread_state(
client: &Client, client: &Client,
room: &matrix_sdk::Room, room: &matrix_sdk::Room,
) -> Option<(OwnedEventId, OwnedUserId, String)> { ) -> Option<(OwnedEventId, OwnedUserId, String)> {
let own_user_id = client.user_id()?; 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` // `load_user_receipt` takes `ruma::events::receipt::ReceiptType`
// (imported here as `LocalReceiptType`), a distinct type from the // (imported here as `LocalReceiptType`), a distinct type from the
// `ReceiptType` this module already imports for `send_single_receipt` // `ReceiptType` this module already imports for `send_single_receipt`
// (`ruma::api::client::receipt::create_receipt::v3::ReceiptType`) — two // (`ruma::api::client::receipt::create_receipt::v3::ReceiptType`) — two
// same-named enums from different ruma crates, not interchangeable. // same-named enums from different ruma crates, not interchangeable.
let read_event_id = room let read = room
.load_user_receipt( .load_user_receipt(
LocalReceiptType::Read, LocalReceiptType::Read,
ReceiptThread::Unthreaded, ReceiptThread::Unthreaded,
@ -283,47 +254,11 @@ async fn room_unread_state(
) )
.await .await
.ok() .ok()
.flatten() .flatten();
.map(|(id, _)| id); if read.is_some_and(|(id, _)| id == event_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; return None;
} }
Some(( Some((event_id, sender, body))
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 { pub async fn send_message(client: &Client, room_ref: &str, body: &str) -> DaemonResponse {