DesktopLocalCache stores users in LargeSoftCache, which holds each User
via a WeakReference. FindUsersTest populated the cache but kept no strong
reference to the created users, so a GC between consumeMetadata and
findUsersStartingWith could collect them — LargeSoftCache.forEach then
skips (and evicts) the cleared entries, making the search return fewer
results. multipleUsersWithMetadata allocates the most objects and tripped
this most often (AssertionError at FindUsersTest.kt:115).
Verified with a forced-GC probe: without a strong reference all three
users were evicted (found=0); holding a reference kept all three (found=3).
Pin each test's users in a strong-reference list that stays reachable
through the assertions, mirroring how Notes/Account/follow lists keep
authors alive in the real app.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X4BThbs8tz9egmFDYv2RPB
Break the newly added BOLT12, Buzz Agent Work board, Blossom, Cashu,
NWC, search-indexing, and geode bullets into short verb-first sentences,
removing em-dash run-ons to match the changelog house style.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017hmbpUJPxrsN4m85Kwemcw
LocalCache already has getOrCreateUser/getOrCreateNote/getOrCreateAddressableNote,
so make it implement Dao and default NewMessageTagger's `dao` to LocalCache
itself — no wrapper object, no interface-default methods. Dao drops the
`suspend` modifiers (LocalCache's lookups are synchronous) so the object
satisfies the interface; AccountViewModel's delegating overrides follow suit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RcWSCNMTVcvKMXo7xA2hue
Move the LocalCache calls off the Dao interface (back to a pure abstract
contract) and into the implementations: AccountViewModel keeps its own
overrides, and the default Dao used by callers without a ViewModel is an
anonymous implementation on NewMessageTagger's `dao` parameter.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RcWSCNMTVcvKMXo7xA2hue
Replace the standalone LocalCacheDao object with default method
implementations on the Dao interface itself, backed by LocalCache. The
NewMessageTagger `dao` parameter now defaults to a bare `object : Dao {}`,
so callers with no AccountViewModel (model-layer sends, the notification
receiver) just omit it, while UI callers keep passing their AccountViewModel
(which resolves to the same LocalCache calls).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RcWSCNMTVcvKMXo7xA2hue
The lightweight reply paths built their events from raw text, so a person
cited with `@name`/`nostr:` was neither resolved into a `nostr:` reference
nor tagged with `p`. Run NewMessageTagger on these paths too, backed by a
new LocalCache-based Dao so they work without an AccountViewModel:
- New `LocalCacheDao`: a `Dao` delegating to `LocalCache`, for mention
resolution outside a ViewModel (model-layer sends, background receivers).
- `Account.sendMinichatReply` (kinds 9 + 1111): resolve mentions and emit
`p` tags for cited users across the public-chat, Buzz, and NIP-29 group
branches (parent author excluded to avoid a duplicate).
- `NotificationReplyReceiver.sendPublicReply` (kinds 1 + 1111): same
resolution for the system notification quick-reply.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RcWSCNMTVcvKMXo7xA2hue
Extends the mention p-tag fix to the shared chat/thread composers, which
resolved `@`/`nostr:` mentions via NewMessageTagger but then dropped the
cited users from the signed event:
- kind 9 (ChatEvent, NIP-29 group chat): the reply path tagged only the
parent author and the plain build path tagged no one. Emit `p` tags for
every cited body user (reply path keeps the parent's relay-hinted tag and
excludes it from the extra set to avoid a duplicate).
- kind 1111 (CommentEvent) minichat reply: replyBuilder auto-tags the
parent/root author but body mentions were dropped. Notify the other
cited users (parent excluded to avoid a duplicate).
- kind 11 (ThreadEvent, NIP-29 group thread): the ShortNote thread branch
added no `p` tags at all; emit them from the cited body users, matching
the sibling Poll/ZapPoll branches.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RcWSCNMTVcvKMXo7xA2hue
Adds NegentropyMultiRelayLiveTest (gated NEG_MULTI=1): runs negentropySyncOrFetch
against 30 reachable public relays and asserts none hangs. Live run: 0 hangs,
0 errors — 10 reconciled via native negentropy, 20 fell over to paging, across
9+ relay softwares (strfry, ditto, purplepag.es, nostr.wine, nostr-rs-relay,
NFDB, rockstr, wot-relay, nostrcheck).
Confirms both fallback paths: the NOTICE fast-path (~1-4s) for relays whose
refusal names negentropy / unknown-envelope, and the idle-watchdog backstop
(~20s) for the rest (e.g. damus silently ignores NEG-OPEN, snort answers
"Unknown message type: NEG-OPEN") — now reliable because NOTICE/CLOSED no longer
reset the watchdog. Matrix recorded in the plan doc.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6ZVTixuc1ef8eGB6MQHRn
Audit follow-ups on the NIP-77 client, found while reviewing the notice-rejection fix:
- isOverflow was too broad. A bare "too many"/"too large" match meant a
NON-shrinking error ("too many requests", "too many concurrent subscriptions")
was read as a set-too-large overflow. Such an error doesn't shrink with the
window, so every created_at split re-triggers it and reconcileWindows walks
toward 1-second leaves, queueing up to ~2^31 Filters (OOM + relay hammering).
Tightened to result-set-qualified phrases (too many records / too many query
results / result set too large / max_sync_events); rate/quota errors now fail
over to paging.
- Added a MAX_WINDOWS (100k) backstop in reconcileWindows: a wording-independent
guard that bails to paging if a split ever fails to converge, so no novel
overflow-looking-but-non-shrinking error can storm.
- Window split dropped future-dated events. On overflow the upper child was
copy(until = hi) with hi = until ?: now(), so once any split happened, events
with created_at > now() (clock skew) were excluded though the un-split path
included them. The upper child now keeps the window's original until (may be
null = unbounded); the split math still uses now() so it converges.
- Hardened the NOTICE rejection matcher. isNegentropyRejectionNotice matched
bare "envelope"/"NEG-OPEN"/"NEG-MSG"; since a NOTICE has no subId and every
connection listener sees it, an unrelated notice on a shared connection could
abort a healthy reconcile mid-handshake. Narrowed to "negentropy"/"unknown
envelope"; the now-un-defeated idle watchdog is the wording-independent backstop.
- NegentropyStoreSync up-direction memory: haveBatches was an UNLIMITED channel
drained by a single network-bound uploader, so a first push of a large store
buffered O(local-set) ids. Bounded it like needBatches to back-pressure the
reconcile.
- Docs: flagged negentropySyncOrFetch's O(delivered) cross-phase dedup memory
(steer bulk mirrors to negentropySync/negentropyReconcile); corrected the
stale onEvent "reader thread" note (it runs on the delivery consumer).
Tests: NegentropyErrorClassificationTest pins both wording classifiers;
NegentropyRejectionFallbackTest adds a rate-limit NEG-ERR case asserting paging
after exactly one NEG-OPEN per phase (no split storm).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6ZVTixuc1ef8eGB6MQHRn
The Buzz composers dropped `p` mention tags for people named in a
message body, so a member cited with `@name` was neither notified nor
linkable and the relay couldn't resolve the `nostr:` reference:
- Stream chat (kind 40002): only the reply-parent author got a `p` tag;
body mentions from the tagger were ignored. Emit `p` tags for every
cited user (dedup covers the reply author).
- Stream edit (kind 40003): carried no mentions at all — a mention added
by an edit lost its `p` tag. Emit them from the edited text.
- Forum reply (kind 45003): never ran NewMessageTagger, so `@`-mentions
stayed literal and only the reply-parent author was tagged. Run the
tagger to resolve mentions and emit their `p` tags.
- Forum post (kind 45001): the root composer built the event from raw
text with no mentions. Run the tagger there too.
Mirrors the mention p-tag handling every other chat kind already does.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RcWSCNMTVcvKMXo7xA2hue
Relays that advertise NIP-77 in NIP-11 but refuse it at runtime answer a
NEG-OPEN with a connection-level NOTICE (which carries no subId) instead of a
subId-addressed NEG-ERR:
- strfry with negentropy disabled: "ERROR: bad msg: negentropy disabled"
- purplepag.es (no NEG envelope): "failed to parse envelope: unknown envelope label"
reconcileStreaming only routed NegMsg/NegErr for its exact subId into the driver
channel, so the NOTICE was dropped and the driver blocked in receiveWithinIdle
with no terminating frame. Worse, the connection-level idle watchdog was bumped
by every relay message, so unrelated refusal chatter (a rejected keep-alive REQ
being re-CLOSED on re-sync) reset it forever and it never fired. Net effect:
negentropySync/negentropyReconcile hung against relay.primal.net and
purplepag.es, and negentropySyncOrFetch never reached its paging fallback.
Fix, in reconcileStreaming's connection listener:
- route a CLOSED for our NEG subId into the driver as a terminal failure;
- treat a negentropy-refusal NOTICE as terminal, bound to this session by
phase (before the first valid NEG frame) + wording (isNegentropyRejectionNotice),
so an unrelated NOTICE on a healthy relay mid-reconcile cannot abort a
progressing sync;
- stop bumping the idle clock on NOTICE/CLOSED so refusal chatter can no longer
keep a dead sync alive; it now advances only on real progress (this session's
NEG frames and the download REQs' events/EOSEs).
The refusal now surfaces as NegentropySyncException(UNAVAILABLE); negentropySync
throws promptly and negentropySyncOrFetch pages the same filter (both relays
answer ordinary REQs fine). Verified live: primal 0-event hang -> pages 11.7k
events in 6.4s; purplepag.es 0-event hang -> pages continuously.
Tests:
- NegentropyRejectionFallbackTest: offline, deterministic; a scripted fake
relay answers NEG-OPEN with each observed NOTICE and asserts the sync throws
UNAVAILABLE fast and negentropySyncOrFetch sets pagedFallback.
- NegentropyStallRepro: gated live repro against the three real relays.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6ZVTixuc1ef8eGB6MQHRn
The previous two commits refetched a group's 39000-39003 whenever the relay
narrated a change, on the premise that Buzz could not stream them at all: it
signs them with `d`/`p` tags and no `h`, so an `#h` filter looked unable to match
and a filter without `#h` registers as a global subscription, which never
receives a channel-scoped event.
The first half of that was wrong. `filter_match_one` has an explicit fallback:
for an `#h` filter, when the event carries no `h` tag at all, it matches against
the stored `channel_id` — and these are stored channel-scoped. So an `#h` filter
both indexes the subscription under the channel (which is what makes it eligible
for the channel fan-out) and matches the events when they arrive.
So subscribe, like the rest of the app does. The per-channel `#h` subscription
that already keeps each joined group's chat live now carries its state kinds too,
and every screen updates through the flows it already observes. The fetch, the
event-bundle hook that triggered it and the cache lookup it needed are all gone.
Buzz-only: on a relay29-family relay these events are addressable with no
`channel_id` behind them, so an `#h` filter matches nothing there and the `#d`
directory filters keep serving them.
Verified on emulator-5554 with no fetch code left in the tree: promoting shows
the `admin` badge on the open members screen within seconds, and removing the
role clears it again.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit hung the refresh off the members screen, so only that screen
recovered from a stale roster: a rename, a visibility flip or a join seen from the
chat, the channel list or a Messages row stayed wrong until the next cold start.
The rest of the app is reactive — relay to LocalCache to screen — and this should
be too.
Move it into AccountViewModel's always-on event collector: any kind-40099 that
lands names its channel, so re-read that group's 39000-39003 into LocalCache and
every screen observing the group updates through the flows it already has. One
rule, no screen has to know about it.
Two things this had to work around:
- The event names its channel but not its host, and a note's relay list can still
be empty when the bundle fires. LocalCache.relayGroupChannelsWithId resolves the
host from the channels already in cache.
- Invalidating the standing state subscription does nothing: its filters are
unchanged, so no new REQ goes out and no events come back. Measured — the badge
did not move. It takes an explicit fetch, which is what
Account.refreshRelayGroupState now does by group id.
Verified on emulator-5554: removing a role updates the badge on the open members
screen with no restart and no screen-local refresh code left in it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ContactCardEvent.indexableContent() already indexed the public summary
and topics; add the public petName() tag alongside them so a card can be
found by the nickname its author gave the target user. petName()/summary()
read the public tag array, so any petname kept in the NIP-44 encrypted
content stays out of the index (privacy preserved).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHSTACrFHaRX1o6C5cEk6N
Collapses the operator setup from an 8-flag command + two hand-written scripts to
essentially two commands, without removing any of the safety.
- `buzz workflow run` gains `--accept-from-channel` (parity with the job
scheduler): scope intake to the channel's kind-39002 roster instead of pasting
every teammate key. `--worktree` now defaults to the current directory.
- Ship the gated reference wrappers (tools/buzz-agent/workflow-agent.sh →
agent+commit; workflow-ship.sh → push+PR after the gate), split around the
approval gate the way agent-exec.sh is the one-shot ungated version.
- `buzz agent up RELAY --repo DIR --approver NPUB` — one command: resolves the
channel (the relay's only one, or --channel), defaults worktree/intake, extracts
the bundled wrappers to ~/.amy/buzz-agent, and delegates to `workflow run`. The
only thing it can't default is the human approver.
- `buzz agent doctor [--repo DIR]` — preflight that turns the security checklist
into a green/red report: gh authenticated, token can write to the repo, default
branch protected against force-push, worktree clean. Exits non-zero if not.
- cli build: set duplicatesStrategy on processResources (the explicit
resources.srcDir re-adds the default root, which now doubles the bundled scripts).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
**The roster went stale.** Promoting somebody changed nothing on screen until the
next cold start. Buzz signs its 39000-39003 with `d`/`p` tags and **no `h`**, yet
stores and fans them out channel-scoped — so a filter carrying `#h` does not match
their tags, and one without `#h` is a global subscription, which by design receives
no channel-scoped event. Neither shape can be live; measured it directly, a role
change delivers only the kind-40099 that narrates it.
So use that as the cue: the members screen re-reads the group's state whenever a
new system message lands in it, keyed on the message id so it fires once per
change rather than polling. Account.refreshRelayGroupState does the fetch.
**Unread badges never cleared.** loadAndMarkAsRead lived inside NormalChatNote —
the `else` of the render switch — so a row drawn by any specialised path (Buzz
system lines and activity rows, diffs, forum votes, NIP-28 admin lines, zaps)
never advanced the room's last-read marker. On a Buzz relay that is most rows:
joins, adds and role changes are all system messages, so a channel whose newest
events were those kept its badge no matter how often it was opened. Hoisted the
call to cover every row type; NormalChatNote's now-dead routeForLastRead
parameter is gone.
Verified on emulator-5554 against nosfabrica.communities.buzz.xyz: promoting a
member now shows the `admin` badge without restarting the app, and opening
`general` cleared its badge while the channels left untouched kept theirs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Massive review of every Event subclass in Quartz that carries
human-authored plaintext at rest but was not participating in the NIP-50
full-text index (SearchableEvent).
Add SearchableEvent to 15 kinds whose content is plaintext searchable
text a user would look for:
- 9737 Bolt12 zap intent (comment) — mirrors 9734/9736
- 5302 / 5303 NIP-90 content / people search request (search query)
- 31871 / 31872 attestation / attestation request — sibling family was
already searchable
- 1315 roadstr road-event report (free-text comment)
- 45001 / 45003 Buzz forum post / comment (body)
- 48106 Buzz huddle guidelines
- 30176 / 30175 / 30177 / 10100 Buzz team / persona / managed-agent /
agent-profile (name, description, system prompt)
- 30620 Buzz workflow definition (name + YAML)
- 3302 Concord chat edit (replacement message text) — mirrors kind-9 chat
Widen indexableContent() on 8 kinds that already implemented
SearchableEvent but dropped natural-language text carried in tags:
- 30020 auction — category hashtags were written by build() but never
indexed
- 12473 Birdex — species names
- 30382 contact card — public topics
- 9002 NIP-29 group-metadata edit — hashtags
- 30054 Podcasting-2.0 episode — topics
- 38192 PS1 save — region name
- 1111 comment / 1311 live-activity chat — hashtags
Encrypted-at-rest (NIP-04, giftwraps, MLS/marmot, NWC, cashu), purely
structural (relay/follow lists, reactions, deletions, moderation/presence
signaling), and ephemeral events were reviewed and deliberately left out.
The NIP-31 alt tag was also left out: it is frequently kind-level client
boilerplate and would dilute relevance.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHSTACrFHaRX1o6C5cEk6N
**Adding people.** The Members screen hid its search behind a FAB, so adding a
handful of people was "open dialog, search, pick, dialog closes, reopen" per
person. The field now lives at the bottom of the screen with its results rising
above it, like a chat composer: each pick lands in the roster above and clears
the query while the keyboard stays up. It clears the gesture bar and rides above
the IME, so the field you type into is not the part that gets covered.
**Promotions did nothing.** "Make moderator" published a kind-9000 and changed
nothing, on either client. Two reasons:
- NIP-29 carries roles inside the `p` tag; Buzz reads a top-level `role` tag
(`extract_tag_value(event, "role")`) and defaults to `member` without it. So
every promotion re-added the target as a plain member. PutUserEvent can now
carry that tag and Account maps our role onto Buzz's vocabulary before sending.
- That vocabulary is `owner`/`admin`/`member`/`guest`/`bot` — there is **no
moderator**, and a role the relay cannot parse fails the whole put-user. So the
action is hidden on Buzz rather than offered and silently dropped.
**The owner could not promote anyone.** membershipOf only mapped the literal
`admin` to ADMIN, but a Buzz channel's creator carries `owner` — leaving the one
person with full authority ranked below it, so "Make admin" never appeared. Both
role strings now mean ADMIN.
**The 3-dot button moved when tapped.** An expanded DropdownMenu still emits a
node into its parent, and it sat as a direct child of a `spacedBy(12.dp)` Row —
so opening the menu added a second gap and shoved the button sideways. Button and
menu now share a Box. ConcordMembersScreen had the identical bug and is fixed
too; GitBrowseUi looks like a third instance and is left alone as unrelated
territory.
Verified on emulator-5554 against nosfabrica.communities.buzz.xyz: promoting the
added member published the 9000, the relay narrated it, and after the roster
refreshed the member carries an `admin` badge. The 3-dot sits at the same pixel
column whether the menu is open or closed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adding someone to a workspace or channel offered a square button and a dialog
whose own hint told you to paste a hex key. Both are now what the rest of the
app does.
The dialog searched **only** LocalCache, so anyone this device had never seen
simply had no result — pasting a raw key was the only way through, which is why
the field said "Add someone (npub or hex)". It now runs on UserSuggestionState,
the same engine behind the @-mention typeahead: local cache, relay search
(NIP-50) and NIP-05 resolution, plus a pasted npub/nprofile. The hint asks for a
name; results show avatar, display name and a verified NIP-05 address.
The members-screen button was the app's only FloatingActionButton without
`shape = CircleShape`, so it rendered as Material3's rounded square next to
circular FABs everywhere else.
Two shared components needed to bend for this, both additive:
- SlimListItem took a `colors` parameter and then painted its container with a
hardcoded `MaterialTheme.colorScheme.background` regardless — so a row asked
to be transparent still drew an opaque block. It honours `containerColor` now,
defaulting to the same `background` it always painted, so every existing
caller is byte-identical.
- ShowUserSuggestionList's row colours, dividers and top padding are parameters.
Their defaults are the dropdown's existing look — opaque rows and a divider
each, which is what separates it from a composer it floats over. Inside a
dialog that chrome reads as a black box with a gap above it, so this one caller
passes transparent rows, no dividers and no padding.
Verified on emulator-5554 against nosfabrica.communities.buzz.xyz: searching
"cloudfodder" returns relay results with verified NIP-05s; pasting an npub
resolves to the person; adding them to a channel published the kind-9000, the
relay narrated "Vitor was added by Vitor Pamplona", the member count went 1 → 2,
and that member then posted from Buzz and the message rendered here. The mention
dropdown is unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The community screen's FAB opened the NIP-29 create-group flow, and on a Buzz
relay it published two events and produced nothing: no channel in the list, no
channel anywhere. Two independent reasons, both silent.
**The create was rejected.** NIP-29 puts the id on the kind-9007 and leaves the
metadata to a following 9002. Buzz's ingest.rs validates the 9007 *before
storage* and rejects it with "invalid: channel name is required" unless the
create event itself carries a `name`; it also reads `about`, `visibility` and
`channel_type` off that same event. Our 9007 had only the `h` tag, so it never
stored, and the 9002 behind it addressed a channel that was never made. Send the
metadata on both events: relay29 ignores the extra tags and takes the 9002, Buzz
takes the 9007.
**The id was not a UUID.** Buzz keys channels by UUID and parses the `h` tag with
`val.parse::<Uuid>()`. Our 8-random-bytes hex id doesn't parse, so the relay
discarded it and created the channel under an id of its own — the app then opened
the id *it* had picked, which is why the one channel that did get created showed
a hex title over an empty feed. Generate a v4 UUID when the host speaks Buzz;
NIP-29 ids are opaque strings, so nothing else changes.
The screen matched NIP-29 rather than Buzz, too. It offered a photo, hashtags, a
geohash and four permission flags — of which Buzz's 9002 handler honours exactly
one (`visibility`, two-valued). Those controls looked like they configured the
channel and were dropped on the floor. On a Buzz relay it is now: name,
description, "Private channel" (Buzz's open/private in Buzz's words), and
"Forum channel" for `channel_type` — offered only on create, since Buzz has no
`channel_type` key on edit. Titled "New channel", because Buzz calls them
channels, and the FAB says so. Plain NIP-29 relays are untouched.
Also stops the NIP-11 gate blocking creation on Buzz relays, which advertise no
NIP-29 support yet implement 9007/9002, and makes RelayGroupMetadataViewModel's
`relay` snapshot state — it is assigned after first composition, so a plain var
left the screen stuck rendering its NIP-29 shape.
Verified against nosfabrica.communities.buzz.xyz: creating "amethyst-create-test2"
lands a real channel — right name in the title, Moderator badge, member count 1,
the relay's own "Vitor Pamplona created this channel" system line, and a row in
the channel list. BuzzChannelCreateTest covers both wire-level fixes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Collapses the two near-identical agent backlogs (jobs 43xxx, workflow runs
46xxx) into a single board that speaks one vocabulary, so users stop having to
tell two protocols apart. The human-approval gate is no longer its own screen —
it's the top-sorted, glowing card state (NEEDS_APPROVAL).
- AgentWork.kt: AgentWorkState {NEEDS_APPROVAL,WORKING,QUEUED,SHIPPED,CLOSED} +
AgentWorkItem, with mappers from WorkflowRun and JobView and a merge() that
sorts needs-approval first, then working, then the upvote-ranked queue, then
shipped/closed.
- AgentWorkBoardViewModel: runs both subscriptions (jobs+upvotes, and workflow
base + by-author decisions) off subscribeAsFlow and folds them via the two
existing aggregators into one List<AgentWorkItem>.
- AgentWorkBoardScreen: one list; inline approve/deny on the gate card, View PR
on shipped, upvote/cancel on jobs, and a "New task" sheet whose single
"Require approval before it ships" toggle picks the protocol — on → a gated
workflow run, off → a direct job (no workflow definition required).
- Nav: the channel top bar's two board glyphs (Backlog + Workflow runs) collapse
to one "Agent work" entry (Route.BuzzAgentWork). The standalone JobBoard and
WorkflowRunBoard screens/routes stay as reference for now.
Prototype scope: strings inline pending adoption; the standalone board's
confirm-before-approve dialog and full localization are the carry-overs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
Watches, Strava, and auto-pause often split one long effort — a 5-hour run
with breaks — into several back-to-back exercise sessions. The New Workout
carousel offered each fragment as its own suggestion.
Add WorkoutMerger, which walks the detected sessions in start-time order and
folds any run of same-type sessions less than an hour apart into a single
DetectedWorkout: distance, calories, steps, elevation and duration are summed,
heart rate is duration-weighted, max heart rate is the max, and sessionCount
records how many were combined. Gaps are tracked per exercise type so a brief
cross-training block mid-run doesn't split the two run segments. The carousel
shows the combined count so the user sees it's one thing.
HealthConnectManager.readNewWorkouts now applies the merge before returning, so
the composer offers the whole effort as one post.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018UHCr6tW2xAXPJUVZjZPvA
Resolve all `:quartz:compileTestKotlin*` warnings without changing any
test's behavior:
- Drop redundant bare `Secp256k1Instance` "force crypto lib load"
statements (and their now-unused imports). JVM object initialization is
thread-safe and lazy, and every one of these tests exercises crypto, so
the lib loads on first use regardless — the eager reference was a no-op
the K2 compiler flags as an unused expression.
- PairingEventTest: use `assertIs` instead of `assertTrue(x is T)` + cast.
- MergeQueryCorrectnessTest: drop `!!` that smart-cast already made moot.
- PodcastCommentScopeTest / MintExceptionTest: widen the declared type so
the `is` checks are genuine runtime checks rather than always-true.
- FetchAllIdleTimeoutTest / NostrConnectSignerServiceTest: opt in to
`ExperimentalCoroutinesApi` at the class level.
- GiantReqStreamTest: name the `WebSocketListener` overrides' parameters
to match the supertype.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyXakbYay4zNJ4cwoF4j7N
The two new boards were added as top-bar icons, which put a Buzz channel back to
four (Canvas, Backlog, Workflow runs, ⋮) and truncated the title row the bar is
there to show — "nosfabrica.commun…" instead of the full relay. That is the
crowding #3729 had just removed; this branch predates it and the merge stacked
them.
Canvas keeps the only icon: it is the channel's shared document, i.e. content.
Backlog and Workflow runs are two more views of the channel, reached
occasionally, so they join Threads and Share in the overflow — and pick up the
`!isDm` gate the icons never had.
Also gives the job board a string resource; the branch's i18n pass left
"Backlog" hardcoded in JobBoardScreen and in the icon's contentDescription.
Adds cli/tests/buzz/agent-exec.sh, which covers what the two loop harnesses
stub out. job-loop.sh proves the scheduler drives *an* --exec program; nothing
committed exercised the real one — the wrapper that turns a job into a PR. With
a stubbed `gh` and agent (no network, credentials, or Claude Code) it asserts
the happy path end to end (task on stdin → agent → commit → push the job branch
→ PR url on stdout) and, as importantly, the paths that must fail: an agent that
changed nothing becomes a job error, an empty task is rejected before the agent
runs, missing scheduler env is a hard error rather than a silent no-op, an agent
that committed for itself is not double-committed, and the default-branch guard
holds — asserting `main` on the remote is left untouched. 19/19.
Verified on emulator-5554: the bar is Canvas + ⋮ again with the full relay name
visible, the menu reads Threads / Backlog / Workflow runs / Share / Members /
Leave, and Backlog still opens from it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>