Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aca39072d5 | ||
|
|
1207a0e282 | ||
|
|
3b339b2232 | ||
|
|
f30897b44e | ||
|
|
fc65811d9f | ||
|
|
f14fc154cc |
4 changed files with 217 additions and 72 deletions
|
|
@ -1,10 +1,45 @@
|
||||||
/* <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: with a second field (hive) added, a row layout put fields of
|
not row inside the form itself: with a second field (hive) added, a
|
||||||
different natural widths on one baseline and looked misaligned —
|
row layout put fields of different natural widths on one baseline and
|
||||||
mara: "make it a col". Reuses the same base16 slots (../theme.css)
|
looked misaligned — mara: "make it a col". Reuses the same base16
|
||||||
every other component draws from. */
|
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 {
|
.create-agent-intro {
|
||||||
margin: 0 0 1.5em;
|
margin: 0 0 1.5em;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
|
|
||||||
|
|
@ -125,44 +125,74 @@ export function CreateAgentPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel title="create agent">
|
<div class="create-agent-layout">
|
||||||
<p class="create-agent-intro">
|
<div class="create-agent-form-col">
|
||||||
Create a new agent's swarm-level identity. This only queues the job — check{' '}
|
<Panel title="create agent">
|
||||||
<Link href="/jobs">jobs</Link> to watch it settle.
|
<p class="create-agent-intro">
|
||||||
</p>
|
Create a new agent's swarm-level identity. This only queues the job — check{' '}
|
||||||
{hivesError && <ApiErrorPanel context="failed to load the hive list" problem={hivesError} />}
|
<Link href="/jobs">jobs</Link> to watch it settle.
|
||||||
<form class="create-agent-form" onSubmit={submit}>
|
</p>
|
||||||
<TextField
|
{hivesError && (
|
||||||
id="agent-name"
|
<ApiErrorPanel context="failed to load the hive list" problem={hivesError} />
|
||||||
label="agent name"
|
)}
|
||||||
value={name}
|
<form class="create-agent-form" onSubmit={submit}>
|
||||||
pattern={NAME_PATTERN}
|
<TextField
|
||||||
title="1-63 chars: lowercase letters, digits, hyphens"
|
id="agent-name"
|
||||||
required
|
label="agent name"
|
||||||
onInput={setName}
|
value={name}
|
||||||
/>
|
pattern={NAME_PATTERN}
|
||||||
<SelectField
|
title="1-63 chars: lowercase letters, digits, hyphens"
|
||||||
id="agent-hive"
|
required
|
||||||
label="hive"
|
onInput={setName}
|
||||||
value={hive}
|
/>
|
||||||
onChange={setHive}
|
<SelectField
|
||||||
options={hiveOptions}
|
id="agent-hive"
|
||||||
required
|
label="hive"
|
||||||
disabled={!hivesReady}
|
value={hive}
|
||||||
placeholder={!hives ? 'loading…' : hives.length === 0 ? 'no hives configured' : 'select a hive'}
|
onChange={setHive}
|
||||||
/>
|
options={hiveOptions}
|
||||||
<Button variant="primary" type="submit" disabled={result.status === 'submitting' || !hivesReady}>
|
required
|
||||||
{result.status === 'submitting' ? 'creating…' : 'create'}
|
disabled={!hivesReady}
|
||||||
</Button>
|
placeholder={
|
||||||
</form>
|
!hives ? 'loading…' : hives.length === 0 ? 'no hives configured' : 'select a hive'
|
||||||
{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>
|
<Button
|
||||||
</p>
|
variant="primary"
|
||||||
)}
|
type="submit"
|
||||||
{result.status === 'error' && (
|
disabled={result.status === 'submitting' || !hivesReady}
|
||||||
<ApiErrorPanel context="failed to queue" problem={result.problem} />
|
>
|
||||||
)}
|
{result.status === 'submitting' ? 'creating…' : 'create'}
|
||||||
</Panel>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,34 @@
|
||||||
/* <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). `max-width` + `width: 100%`
|
from (one class, so the two never drift). `width: 100%` fills
|
||||||
rather than a fixed `width`: caps the control on a wide desktop
|
whichever container the caller gives it — the kit itself has no
|
||||||
viewport without forcing an overflow on a narrow/touch one.
|
opinion on a maximum width; a page that wants one narrower than its
|
||||||
`min-height` is a touch-target floor (44px at the default 16px root
|
own layout caps it at the layout level (`CreateAgentPage.css`'s
|
||||||
font — WCAG 2.5.5's minimum), not a visual choice — it's the same on
|
`.create-agent-form-col` is the existing example), same reasoning
|
||||||
every control in the kit whether or not it's ever used on a touch
|
`Panel` has no width opinion of its own either. `min-height` is a
|
||||||
device, since the alternative is a component that behaves differently
|
touch-target floor (44px at the default 16px root font — WCAG
|
||||||
per input method. Colours are the shared base16-derived vars
|
2.5.5's minimum), not a visual choice — it's the same on every
|
||||||
(../../theme.css), never literal. */
|
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 {
|
.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;
|
||||||
|
|
@ -26,7 +42,6 @@
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
/// Timeline events to scan backward, per room, when hunting for the
|
||||||
/// (redactions/state/reactions included — identity is what
|
/// latest event that carries actor intent (see [`is_intentional`])
|
||||||
/// `room_unread_state` needs, not content). `None` for a genuinely
|
/// 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.
|
/// empty room or on any request failure.
|
||||||
async fn latest_event(
|
async fn recent_events(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
room: &matrix_sdk::Room,
|
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::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(1u32);
|
req.limit = matrix_sdk::ruma::UInt::from(UNREAD_LOOKBACK);
|
||||||
let resp = client.send(req).await.ok()?;
|
let Ok(resp) = client.send(req).await else {
|
||||||
let raw = resp.chunk.first()?;
|
return Vec::new();
|
||||||
let ev = raw.deserialize().ok()?;
|
};
|
||||||
let body = extract_body(&ev);
|
resp.chunk
|
||||||
Some((ev.event_id().to_owned(), ev.sender().to_owned(), body))
|
.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.
|
/// 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
|
/// 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 = room
|
let read_event_id = room
|
||||||
.load_user_receipt(
|
.load_user_receipt(
|
||||||
LocalReceiptType::Read,
|
LocalReceiptType::Read,
|
||||||
ReceiptThread::Unthreaded,
|
ReceiptThread::Unthreaded,
|
||||||
|
|
@ -254,11 +283,47 @@ async fn room_unread_state(
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
.flatten();
|
.flatten()
|
||||||
if read.is_some_and(|(id, _)| id == event_id) {
|
.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;
|
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 {
|
pub async fn send_message(client: &Client, room_ref: &str, body: &str) -> DaemonResponse {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue