From f14fc154cc71e072c0c966dfb67f3d330f027c9c Mon Sep 17 00:00:00 2001 From: iris Date: Wed, 19 Aug 2026 14:13:02 +0200 Subject: [PATCH 1/6] swarm-ui: fix create-agent form field alignment and panel width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../swarm-ui/src/pages/CreateAgentPage.css | 14 +++- .../swarm-ui/src/pages/CreateAgentPage.tsx | 80 ++++++++++--------- .../swarm-ui/src/ui/form-field/FormField.css | 15 +++- 3 files changed, 69 insertions(+), 40 deletions(-) diff --git a/frontend/packages/swarm-ui/src/pages/CreateAgentPage.css b/frontend/packages/swarm-ui/src/pages/CreateAgentPage.css index 394ad05c..b551351c 100644 --- a/frontend/packages/swarm-ui/src/pages/CreateAgentPage.css +++ b/frontend/packages/swarm-ui/src/pages/CreateAgentPage.css @@ -4,7 +4,19 @@ 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. */ + every other component draws from. + + `.create-agent-page` caps the panel's own width: `Panel` has no width + opinion of its own, so left unconstrained it filled `.shell-body`'s + full 60em column — a lot of bare panel to the right of a ~16em-wide + form, the "weird empty space" mara flagged alongside the field + misalignment. 24em roughly matches the fields' own 16em cap + (../ui/form-field/FormField.css) plus the panel's 1em body padding on + each side and a little breathing room, so the panel reads as sized to + its content instead of to the page column. */ +.create-agent-page { + max-width: 24em; +} .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..fb2dd464 100644 --- a/frontend/packages/swarm-ui/src/pages/CreateAgentPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/CreateAgentPage.tsx @@ -125,44 +125,48 @@ 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 +

+ +

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

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

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

+ )} + {result.status === 'error' && ( + + )} + +
); } 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..a2365e9a 100644 --- a/frontend/packages/swarm-ui/src/ui/form-field/FormField.css +++ b/frontend/packages/swarm-ui/src/ui/form-field/FormField.css @@ -8,11 +8,24 @@ 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. */ + (../../theme.css), never literal. + + The field wrapper repeats `.ui-form-control`'s own `width: 100%; + max-width: 16em` 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 intended 16em cap, 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 fixed cap regardless of its label's length or its siblings'. */ .ui-form-field { display: flex; flex-direction: column; gap: 0.3em; + width: 100%; + max-width: 16em; } .ui-form-field-label { font-size: 0.85em; From fc65811d9fa0106f7178c06d89041b1ceca0db1e Mon Sep 17 00:00:00 2001 From: iris Date: Wed, 19 Aug 2026 14:28:38 +0200 Subject: [PATCH 2/6] swarm-ui: fill create-agent's empty column with an explanatory panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../swarm-ui/src/pages/CreateAgentPage.css | 47 +++++--- .../swarm-ui/src/pages/CreateAgentPage.tsx | 106 +++++++++++------- 2 files changed, 99 insertions(+), 54 deletions(-) diff --git a/frontend/packages/swarm-ui/src/pages/CreateAgentPage.css b/frontend/packages/swarm-ui/src/pages/CreateAgentPage.css index b551351c..7316702d 100644 --- a/frontend/packages/swarm-ui/src/pages/CreateAgentPage.css +++ b/frontend/packages/swarm-ui/src/pages/CreateAgentPage.css @@ -1,21 +1,42 @@ /* — 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. - `.create-agent-page` caps the panel's own width: `Panel` has no width - opinion of its own, so left unconstrained it filled `.shell-body`'s - full 60em column — a lot of bare panel to the right of a ~16em-wide - form, the "weird empty space" mara flagged alongside the field - misalignment. 24em roughly matches the fields' own 16em cap - (../ui/form-field/FormField.css) plus the panel's 1em body padding on - each side and a little breathing room, so the panel reads as sized to - its content instead of to the page column. */ + `.create-agent-page` caps the whole layout's width: `Panel` has no + width opinion of its own, so left unconstrained a single form panel + filled `.shell-body`'s full 60em column — a lot of bare panel to the + right of a ~16em-wide form, the "weird empty space" mara flagged + alongside the original field misalignment. The follow-up ask was to + fill that space with something that explains the page rather than + just narrowing it further — `.create-agent-layout` puts the form + beside a second, explanatory panel instead. `.create-agent-form-col` + keeps the same ~24em cap the single-panel version had (fields' own + 16em cap, ../ui/form-field/FormField.css, plus body padding and a + little breathing room); `.create-agent-info-col` takes the rest of + the row and wraps under the form on a narrow viewport (`flex-wrap`, + no separate media query needed). */ .create-agent-page { - max-width: 24em; + max-width: 44em; +} +.create-agent-layout { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 1.5em; +} +.create-agent-form-col { + flex: 0 1 24em; +} +.create-agent-info-col { + flex: 1 1 16em; +} +.create-agent-info-glyph { + margin: 0 0 0.5em; + font-size: 2em; } .create-agent-intro { margin: 0 0 1.5em; diff --git a/frontend/packages/swarm-ui/src/pages/CreateAgentPage.tsx b/frontend/packages/swarm-ui/src/pages/CreateAgentPage.tsx index fb2dd464..e870d3a2 100644 --- a/frontend/packages/swarm-ui/src/pages/CreateAgentPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/CreateAgentPage.tsx @@ -126,47 +126,71 @@ 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' && ( + + )} +
+
+
+ + +

+ This queues a small chain of jobs, not a running agent: first an authelia identity + at the swarm level, then a config repo on the forge with the operator added as a + collaborator. It reserves everything an agent needs before any container exists — + actually deploying one onto the hive you pick above is a separate step that isn't + wired up yet. +

+
+
+
); } From f30897b44ecf9dc6df8da289698df95be132c029 Mon Sep 17 00:00:00 2001 From: iris Date: Wed, 19 Aug 2026 14:40:51 +0200 Subject: [PATCH 3/6] swarm-ui: create-agent info panel reads as the finished flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../swarm-ui/src/pages/CreateAgentPage.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/frontend/packages/swarm-ui/src/pages/CreateAgentPage.tsx b/frontend/packages/swarm-ui/src/pages/CreateAgentPage.tsx index e870d3a2..64e750cb 100644 --- a/frontend/packages/swarm-ui/src/pages/CreateAgentPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/CreateAgentPage.tsx @@ -177,16 +177,20 @@ export function CreateAgentPage() {
+ {/* 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. */}

- This queues a small chain of jobs, not a running agent: first an authelia identity - at the swarm level, then a config repo on the forge with the operator added as a - collaborator. It reserves everything an agent needs before any container exists — - actually deploying one onto the hive you pick above is a separate step that isn't - wired up yet. + 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.

From 3b339b22320ef3e0e287aac18058340ff310d932 Mon Sep 17 00:00:00 2001 From: iris Date: Wed, 19 Aug 2026 14:48:00 +0200 Subject: [PATCH 4/6] swarm-ui: equal-width create-agent cards, full-width inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../swarm-ui/src/pages/CreateAgentPage.css | 27 +++++++----- .../swarm-ui/src/ui/form-field/FormField.css | 42 ++++++++++--------- 2 files changed, 38 insertions(+), 31 deletions(-) diff --git a/frontend/packages/swarm-ui/src/pages/CreateAgentPage.css b/frontend/packages/swarm-ui/src/pages/CreateAgentPage.css index 7316702d..0623fd6c 100644 --- a/frontend/packages/swarm-ui/src/pages/CreateAgentPage.css +++ b/frontend/packages/swarm-ui/src/pages/CreateAgentPage.css @@ -9,16 +9,22 @@ `.create-agent-page` caps the whole layout's width: `Panel` has no width opinion of its own, so left unconstrained a single form panel filled `.shell-body`'s full 60em column — a lot of bare panel to the - right of a ~16em-wide form, the "weird empty space" mara flagged + right of a narrow form, the "weird empty space" mara flagged alongside the original field misalignment. The follow-up ask was to fill that space with something that explains the page rather than just narrowing it further — `.create-agent-layout` puts the form - beside a second, explanatory panel instead. `.create-agent-form-col` - keeps the same ~24em cap the single-panel version had (fields' own - 16em cap, ../ui/form-field/FormField.css, plus body padding and a - little breathing room); `.create-agent-info-col` takes the rest of - the row and wraps under the form on a narrow viewport (`flex-wrap`, - no separate media query needed). */ + beside a second, explanatory panel instead, `.create-agent-form-col` + and `.create-agent-info-col` sized equally (`flex: 1 1 0` — an equal + *basis* of zero so the 1.5em `gap` splits the remaining row width + evenly between them, rather than each keeping its own natural + content width) per mara's follow-up ("both cards should be equal + sized"). `min-width` on both is the wrap threshold: below it a card + would get uncomfortably narrow, so the row wraps to a single column + instead (`.create-agent-info-col` moving under the form) — no + separate media query needed. The form's own inputs now fill that + whole column width too (mara: "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-page { max-width: 44em; } @@ -28,11 +34,10 @@ align-items: flex-start; gap: 1.5em; } -.create-agent-form-col { - flex: 0 1 24em; -} +.create-agent-form-col, .create-agent-info-col { - flex: 1 1 16em; + flex: 1 1 0; + min-width: 16em; } .create-agent-info-glyph { margin: 0 0 0.5em; 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 a2365e9a..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,31 +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 + 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%; - max-width: 16em` 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 intended 16em cap, 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 fixed cap regardless of its label's length or its siblings'. */ + 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%; - max-width: 16em; } .ui-form-field-label { font-size: 0.85em; @@ -39,7 +42,6 @@ padding: 0.4em 0.6em; font: inherit; width: 100%; - max-width: 16em; box-sizing: border-box; min-height: 2.75em; } From 1207a0e282c851f5e5d54074aae2220554935de3 Mon Sep 17 00:00:00 2001 From: iris Date: Wed, 19 Aug 2026 14:55:44 +0200 Subject: [PATCH 5/6] swarm-ui: create-agent layout fills the page width, centered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../swarm-ui/src/pages/CreateAgentPage.css | 41 +++--- .../swarm-ui/src/pages/CreateAgentPage.tsx | 134 +++++++++--------- 2 files changed, 85 insertions(+), 90 deletions(-) diff --git a/frontend/packages/swarm-ui/src/pages/CreateAgentPage.css b/frontend/packages/swarm-ui/src/pages/CreateAgentPage.css index 0623fd6c..b1fe91a2 100644 --- a/frontend/packages/swarm-ui/src/pages/CreateAgentPage.css +++ b/frontend/packages/swarm-ui/src/pages/CreateAgentPage.css @@ -6,28 +6,25 @@ looked misaligned — mara: "make it a col". Reuses the same base16 slots (../theme.css) every other component draws from. - `.create-agent-page` caps the whole layout's width: `Panel` has no - width opinion of its own, so left unconstrained a single form panel - filled `.shell-body`'s full 60em column — a lot of bare panel to the - right of a narrow form, the "weird empty space" mara flagged - alongside the original field misalignment. The follow-up ask was to - fill that space with something that explains the page rather than - just narrowing it further — `.create-agent-layout` puts the form - beside a second, explanatory panel instead, `.create-agent-form-col` - and `.create-agent-info-col` sized equally (`flex: 1 1 0` — an equal - *basis* of zero so the 1.5em `gap` splits the remaining row width - evenly between them, rather than each keeping its own natural - content width) per mara's follow-up ("both cards should be equal - sized"). `min-width` on both is the wrap threshold: below it a card - would get uncomfortably narrow, so the row wraps to a single column - instead (`.create-agent-info-col` moving under the form) — no - separate media query needed. The form's own inputs now fill that - whole column width too (mara: "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-page { - max-width: 44em; -} + 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; diff --git a/frontend/packages/swarm-ui/src/pages/CreateAgentPage.tsx b/frontend/packages/swarm-ui/src/pages/CreateAgentPage.tsx index 64e750cb..eaa22152 100644 --- a/frontend/packages/swarm-ui/src/pages/CreateAgentPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/CreateAgentPage.tsx @@ -125,75 +125,73 @@ export function CreateAgentPage() { } return ( -
-
-
- -

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

+
+ +

+ 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

- {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. -

-
-
+ )} + {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. +

+
); From aca39072d5f91f6dd62de13f85e627ebbc7bff5b Mon Sep 17 00:00:00 2001 From: damocles Date: Wed, 19 Aug 2026 14:07:24 +0200 Subject: [PATCH 6/6] fix(#3072): stop waking every agent on a re-applied m.space.child state event --- hive-matrix-mcp/src/handlers.rs | 103 ++++++++++++++++++++++++++------ 1 file changed, 84 insertions(+), 19 deletions(-) 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 {