From 13cf21beeb7e9dc912cc00573298093829a0dc9d Mon Sep 17 00:00:00 2001
From: pic-worker-aa352576c7
<6838876f3610dc213d19edeb1cdf5a8d0a0b568dc9b40db674ade16725074cce@buzz.block.builderlab.xyz>
Date: Fri, 11 Sep 2026 21:59:00 -0700
Subject: [PATCH 1/7] Add shared receive-only channel and thread typing
Signed-off-by: pic-worker-aa352576c7 <6838876f3610dc213d19edeb1cdf5a8d0a0b568dc9b40db674ade16725074cce@buzz.block.builderlab.xyz>
---
docs/relay-queries.md | 52 ++++++
src/features/messages/MessageComposer.tsx | 13 ++
src/features/messages/Messages.module.css | 8 +
src/features/messages/TypingIndicator.tsx | 40 +++++
src/features/relay/live.test.ts | 30 ++++
src/features/relay/live.ts | 13 +-
src/features/relay/session.ts | 26 ++-
src/features/relay/typing.integration.test.ts | 106 +++++++++++
src/features/relay/typing.test.ts | 156 ++++++++++++++++
src/features/relay/typing.ts | 170 ++++++++++++++++++
tests/browser/fixture.mjs | 21 +++
tests/browser/typing.spec.mjs | 54 ++++++
12 files changed, 687 insertions(+), 2 deletions(-)
create mode 100644 src/features/messages/TypingIndicator.tsx
create mode 100644 src/features/relay/typing.integration.test.ts
create mode 100644 src/features/relay/typing.test.ts
create mode 100644 src/features/relay/typing.ts
create mode 100644 tests/browser/typing.spec.mjs
diff --git a/docs/relay-queries.md b/docs/relay-queries.md
index 552d85e0..21f0529f 100644
--- a/docs/relay-queries.md
+++ b/docs/relay-queries.md
@@ -453,3 +453,55 @@ Exhausted attempts, unsupported pauses, other route/connection failures and
unfinished finite roster/head failures still show a warning and recovery action.
This presentation policy does not increase quotas or guarantee that another
client sharing the account cannot cause a refusal.
+
+## Receive-only typing
+
+`session.typing.snapshot()` / `.subscribe()` expose immutable active entries
+`{ channelId, threadRootId?, pubkey }`. The session owns one ephemeral projection;
+shared `features/messages/TypingIndicator` renders it inside channel/thread
+composers, including read-only connections. Page plugins consume the same UI and
+session. Mounting consumers starts no reads, profile enrichment or subscriptions.
+Names reuse the shared profile snapshot, with a public-key fragment fallback.
+This reports signed typing activity (including agents), **not** inferred agent
+execution, online presence, or a promise that an answer is coming.
+
+Kind 20002 joins the existing authenticated channel route, with the same signature
+verification and socket/generation fencing. Typing must match that exact route,
+name exactly one bounded `h`, belong to the current visible roster and pass the
+existing channel access policy. Identity is the event signer, matching message
+folding; arbitrary `p`/actor tags and profile display names do not confer identity.
+A channel pulse has no `e`; a thread pulse has one marked canonical `reply`, with
+at most one marked `root` for nested replies. Thread scope uses the existing
+canonical root convention; it is not an extra target lookup or an access grant.
+Malformed/ambiguous references are dropped rather than displayed at channel level.
+
+Only live pulses can activate typing. No typing payload enters finite views,
+recent history, channel caches, unread counts, the outbox or persistent storage.
+Expiry is eight seconds from the signed timestamp. Already-expired and future
+activity is rejected (no clock-skew allowance). Duplicates/older pulses cannot
+extend expiry. Signed content messages (9/40002) suppress their signer's same
+channel/thread activity; timestamp watermarks reject older pulses and a two-second
+post-message quiet period covers late activity. Old history does not clear newer
+activity or repeatedly extend suppression. This does not reinterpret edits as
+new messages or resolve relay-proxied author envelopes beyond current host rules.
+
+At most 1,024 active/suppression records and one timeout exist per session. At
+capacity new identities/scopes are dropped until expiry; suppression evidence is
+never evicted to admit a stale pulse. Access loss clears all typing conservatively,
+before batched access notifications. Disconnect, cache clear, session disposal and
+account/community replacement clear it too. Session disposal fences late callbacks;
+plugins unloading only remove their UI subscriptions. There is no typing publisher,
+new signer, transport, polling, durable cache, or plugin-owned ephemeral owner.
+
+Protocol reference: `block/buzz`'s
+`crates/buzz-acp/src/relay.rs::build_typing_event` and
+`desktop/src/features/messages/useChannelTyping.ts` (8-second TTL and 2-second
+post-message suppression). Regression owners are `typing*.test.ts`, `live.test.ts`
+and `tests/browser/typing.spec.mjs`; browser simulations use ephemeral fixture
+keys through the production authenticated broker, never the deployed relay.
+
+**Review-sensitive:** `session.ts` (FOUNDATION admission/lifecycle and synchronous
+subscriber reentrancy), `live.ts` (existing route filter/verification boundary),
+`typing.ts` (timestamp, scope, bounds and suppression), and shared composer placement.
+An existing development broker imports live routing at startup and needs one
+coordinated restart to receive kind 20002; frontend hot reload alone is insufficient.
diff --git a/src/features/messages/MessageComposer.tsx b/src/features/messages/MessageComposer.tsx
index 9639d419..a9aab60f 100644
--- a/src/features/messages/MessageComposer.tsx
+++ b/src/features/messages/MessageComposer.tsx
@@ -1,3 +1,4 @@
+import { TypingIndicator } from "./TypingIndicator";
import { ArrowUp, X } from "lucide-react";
import {
useEffect,
@@ -297,6 +298,11 @@ function Composer({
if (!outbox?.supports(9))
return (
);
@@ -311,6 +317,13 @@ function Composer({
send();
}}
>
+ {!disabled && (
+
+ )}
diff --git a/src/features/messages/Messages.module.css b/src/features/messages/Messages.module.css
index d8ac4cd1..df8bdd2a 100644
--- a/src/features/messages/Messages.module.css
+++ b/src/features/messages/Messages.module.css
@@ -661,3 +661,11 @@ button.avatar:focus-visible,
outline: 2px solid var(--focus);
outline-offset: 2px;
}
+
+.typing {
+ color: var(--text-muted);
+ font-size: calc(12px * var(--buzz-text-scale, 1));
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
diff --git a/src/features/messages/TypingIndicator.tsx b/src/features/messages/TypingIndicator.tsx
new file mode 100644
index 00000000..59d05f51
--- /dev/null
+++ b/src/features/messages/TypingIndicator.tsx
@@ -0,0 +1,40 @@
+import { useSyncExternalStore } from "react";
+import type { RelaySession } from "../relay/session";
+import styles from "./Messages.module.css";
+
+/** Shared presentation only. Mounting more consumers creates no relay work. */
+export function TypingIndicator({
+ session,
+ channelId,
+ threadRootId,
+}: {
+ session: RelaySession;
+ channelId: string;
+ threadRootId?: string | undefined;
+}) {
+ const entries = useSyncExternalStore(
+ session.typing.subscribe,
+ session.typing.snapshot,
+ );
+ const profiles = useSyncExternalStore(
+ session.profiles.subscribe,
+ session.profiles.snapshot,
+ );
+ const matching = entries.filter(
+ (entry) =>
+ entry.channelId === channelId && entry.threadRootId === threadRootId,
+ );
+ if (!matching.length) return null;
+ // Reuse already available names; optional typing must not trigger profile reads.
+ const names = matching
+ .slice(0, 3)
+ .map(({ pubkey }) => profiles.get(pubkey)?.name ?? pubkey.slice(0, 10));
+ const others = matching.length - names.length;
+ return (
+
+ {names.join(", ")}
+ {others > 0 ? ` and ${others} others` : ""}
+ {matching.length === 1 ? " is typing…" : " are typing…"}
+
+ );
+}
diff --git a/src/features/relay/live.test.ts b/src/features/relay/live.test.ts
index ff1520df..636d4b29 100644
--- a/src/features/relay/live.test.ts
+++ b/src/features/relay/live.test.ts
@@ -574,3 +574,33 @@ it("requests community emoji on the existing profile route and delivers verified
expect(h.callbacks.receive).toHaveBeenCalledWith([event]);
h.owner.dispose();
});
+
+it("admits signed typing only on its authenticated channel route, without extra subscriptions", async () => {
+ vi.useFakeTimers();
+ const h = setup();
+ await h.first.auth();
+ await vi.advanceTimersByTimeAsync(750);
+ const requests = h.first.requests();
+ expect(requests).toHaveLength(4);
+ const route = requests[2];
+ assert.exists(route);
+ expect(route[2].kinds).toContain(20002);
+ const event = signed(keypair(), {
+ kind: 20002,
+ content: "",
+ tags: [["h", "a"]],
+ });
+ await h.first.receive(["EVENT", requests[0]?.[1], event]);
+ await h.first.receive(["EVENT", requests[3]?.[1], event]);
+ expect(h.callbacks.receive).not.toHaveBeenCalled();
+ await h.first.receive(["EVENT", route[1], event]);
+ expect(h.callbacks.receive).toHaveBeenCalledExactlyOnceWith([event]);
+ await h.first.receive([
+ "EVENT",
+ route[1],
+ { ...event, sig: "0".repeat(128) },
+ ]);
+ expect(h.callbacks.receive).toHaveBeenCalledTimes(1);
+ h.owner.dispose();
+ expect(vi.getTimerCount()).toBe(0);
+});
diff --git a/src/features/relay/live.ts b/src/features/relay/live.ts
index e4a6c2ab..fa6d3630 100644
--- a/src/features/relay/live.ts
+++ b/src/features/relay/live.ts
@@ -78,7 +78,7 @@ type Route = {
quotaRetries: number;
deadline?: ReturnType;
};
-const CHANNEL_KINDS = [9, 40002, 40003, 5, 9005, 7, 39000, 39002, 39005];
+const CHANNEL_KINDS = [9, 40002, 40003, 5, 9005, 7, 39000, 39002, 39005, 20002];
/** One authenticated socket, independently established channel routes and two explicit globals.
* Recent replay is opportunistic: finite reads own catch-up and history bounds. */
export function subscribeRelayTraffic(
@@ -415,6 +415,17 @@ export function subscribeRelayTraffic(
fail(route, "Relay supplied invalid live traffic");
return;
}
+ // Ephemeral channel activity must arrive on that exact authenticated
+ // channel route; a global or another channel is not an access grant.
+ if (
+ incoming.kind === 20002 &&
+ (!route.channelId ||
+ incoming.tags.filter(([name]) => name === "h").length !== 1 ||
+ !incoming.tags.some(
+ ([name, value]) => name === "h" && value === route.channelId,
+ ))
+ )
+ return;
if (route.status === "pending") route.count++;
callbacks.receive([incoming]);
} else if (data[0] === "EOSE" && route.status === "pending") {
diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts
index abbac089..80fe4c44 100644
--- a/src/features/relay/session.ts
+++ b/src/features/relay/session.ts
@@ -15,6 +15,7 @@ import {
browserReadStateStorage,
type ReadStateStorage,
} from "./read-state-storage";
+import { createTyping } from "./typing";
import { createUnread } from "./unread";
import { readSidebarPreferences } from "./sidebar-preferences";
import { createSidebarPreferencesStore } from "./sidebar-preferences-store";
@@ -80,6 +81,14 @@ export function createRelaySession(
else listener();
};
let canAccess: (id: string) => boolean = () => true;
+ const typing = createTyping(
+ transport?.viewer ?? "",
+ (id) =>
+ !closed &&
+ canAccess(id) &&
+ channels.queries.list().channels.some((channel) => channel.id === id),
+ notify,
+ );
const recent = new ByteLru<{ event: RelayEvent; revision: number }>(
4096,
8 * 1024 * 1024,
@@ -154,6 +163,7 @@ export function createRelaySession(
revoking++;
try {
accessEpoch++;
+ typing.clear();
// Filters cannot tell us ownership of broad/ID/reference reads. Infrequent
// authoritative access loss cancels them all, not merely explicit #h reads.
requests.invalidate();
@@ -234,8 +244,13 @@ export function createRelaySession(
events.some((event) => [39000, 39002].includes(event.kind))
)
channels.acceptDiscovery(events);
- const visible = events.filter(visibility(events));
+ // Ephemeral typing never enters finite views, history, caches or persistence.
+ const visible = events
+ .filter((event) => event.kind !== 20002)
+ .filter(visibility(events));
const epoch = accessEpoch;
+ typing.accept(visible);
+ if (closed || epoch !== accessEpoch) return [];
profiling.measure(
"events.reconcile",
events[0]?.id ?? "empty",
@@ -567,6 +582,7 @@ export function createRelaySession(
notify,
);
const session = Object.freeze({
+ typing: typing.capability,
unread: unread.capability,
sidebarPreferences: sidebarPreferences.queries,
live,
@@ -882,9 +898,15 @@ export function createRelaySession(
)
refreshRoster();
accept(events);
+ if (liveSnapshot.status === "connected")
+ typing.accept(
+ events.filter((event) => event.kind === 20002),
+ true,
+ );
},
state(snapshot) {
if (closed) return;
+ if (snapshot.status !== "connected") typing.clear();
if (
snapshot.status !== "connected" &&
liveSnapshot.status === "connected"
@@ -947,6 +969,7 @@ export function createRelaySession(
async clearCache() {
accessEpoch++;
cacheClearEpoch++;
+ typing.clear();
sidebarPreferences.clear();
// New windows must not yield to or receive errors from retired owners.
catchups.clear();
@@ -964,6 +987,7 @@ export function createRelaySession(
},
dispose() {
closed = true;
+ typing.dispose();
lifetime.abort();
sidebarPreferences.dispose();
stopInterests();
diff --git a/src/features/relay/typing.integration.test.ts b/src/features/relay/typing.integration.test.ts
new file mode 100644
index 00000000..90eb0b8b
--- /dev/null
+++ b/src/features/relay/typing.integration.test.ts
@@ -0,0 +1,106 @@
+import { afterEach, expect, it, vi } from "vitest";
+import { createRelaySession } from "./session";
+import type { LiveCallbacks } from "./live";
+import { keypair, roster, signed, scriptedTransport } from "./testing";
+
+afterEach(() => vi.useRealTimers());
+it("owns one ephemeral projection across views; purges on access, disconnect, cache and disposal", async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(1_800_000_000_000);
+ const viewer = keypair(),
+ relay = keypair(),
+ agent = keypair();
+ const wire = scriptedTransport(viewer.pubkey, relay.pubkey);
+ let live!: LiveCallbacks;
+ const dispose = vi.fn(),
+ subscribe = vi.fn((callbacks: LiveCallbacks) => {
+ live = callbacks;
+ return { update() {}, retry() {}, dispose };
+ });
+ const owner = createRelaySession({ ...wire.transport, subscribe });
+ const pulse = signed(agent, {
+ kind: 20002,
+ content: "",
+ tags: [["h", "a"]],
+ created_at: 1_800_000_000,
+ });
+ const view = owner.session.observe([
+ { kinds: [20002], "#h": ["a"], limit: 10 },
+ ]);
+ const stops = [1, 2].map(() => owner.session.typing.subscribe(() => {}));
+ live.state({ status: "connected", routes: [] });
+ const snapshot = owner.session.typing.snapshot;
+ // No roster means no access, even for a signed event.
+ live.receive([pulse]);
+ expect(snapshot()).toEqual([]);
+ live.receive([roster(relay, "a", [viewer.pubkey])]);
+ live.receive([pulse]);
+ expect(snapshot()).toHaveLength(1);
+ expect(view.snapshot().events).toEqual([]);
+ expect(subscribe).toHaveBeenCalledTimes(1);
+ const callback = vi.fn(() => expect(snapshot()).toEqual([]));
+ const stop = owner.session.typing.subscribe(callback);
+ live.receive([roster(relay, "a", [], 1_800_000_001), pulse]);
+ expect(snapshot()).toEqual([]);
+ expect(callback).toHaveBeenCalled();
+ stop();
+ live.receive([roster(relay, "a", [viewer.pubkey], 1_800_000_002), pulse]);
+ expect(snapshot()).toHaveLength(1);
+ live.state({ status: "retrying", routes: [] });
+ expect(snapshot()).toEqual([]);
+ live.receive([pulse]);
+ expect(snapshot()).toEqual([]);
+ live.state({ status: "connected", routes: [] });
+ live.receive([pulse]);
+ expect(snapshot()).toHaveLength(1);
+ await owner.clearCache();
+ expect(snapshot()).toEqual([]);
+ live.receive([roster(relay, "a", [viewer.pubkey], 1_800_000_003), pulse]);
+ owner.dispose();
+ live.receive([pulse]);
+ expect(snapshot()).toEqual([]);
+ expect(dispose).toHaveBeenCalledTimes(1);
+ for (const stop of stops) stop();
+ view.dispose();
+ expect(vi.getTimerCount()).toBe(0);
+ const other = createRelaySession(null);
+ expect(other.session.typing.snapshot()).toEqual([]);
+ other.dispose();
+});
+
+it("a synchronous typing listener cannot reseed retained views after disposal", () => {
+ const viewer = keypair(),
+ relay = keypair(),
+ agent = keypair();
+ const wire = scriptedTransport(viewer.pubkey, relay.pubkey);
+ let live!: LiveCallbacks;
+ const owner = createRelaySession({
+ ...wire.transport,
+ subscribe(callbacks) {
+ live = callbacks;
+ return { update() {}, retry() {}, dispose() {} };
+ },
+ });
+ live.state({ status: "connected", routes: [] });
+ live.receive([roster(relay, "a", [viewer.pubkey])]);
+ const at = Math.floor(Date.now() / 1000);
+ const pulse = signed(agent, {
+ kind: 20002,
+ content: "",
+ created_at: at,
+ tags: [["h", "a"]],
+ });
+ live.receive([pulse]);
+ const view = owner.session.observe([{ kinds: [9], "#h": ["a"], limit: 10 }]);
+ owner.session.typing.subscribe(() => owner.dispose());
+ live.receive([
+ signed(agent, {
+ kind: 9,
+ content: "fixture",
+ created_at: at,
+ tags: [["h", "a"]],
+ }),
+ ]);
+ expect(view.snapshot().events).toEqual([]);
+ expect(owner.session.typing.snapshot()).toEqual([]);
+});
diff --git a/src/features/relay/typing.test.ts b/src/features/relay/typing.test.ts
new file mode 100644
index 00000000..76796501
--- /dev/null
+++ b/src/features/relay/typing.test.ts
@@ -0,0 +1,156 @@
+import { afterEach, expect, it, vi } from "vitest";
+import { createTyping } from "./typing";
+import { keypair, message, signed } from "./testing";
+
+const agent = keypair(),
+ viewer = keypair();
+const epoch = 1_800_000_000;
+function setup() {
+ vi.useFakeTimers();
+ vi.setSystemTime(epoch * 1000);
+ const owner = createTyping(
+ viewer.pubkey,
+ (id) => id === "a",
+ (fn) => fn(),
+ );
+ return { owner, snapshot: owner.capability.snapshot };
+}
+function pulse(tags = [["h", "a"]], at = epoch, key = agent) {
+ return signed(key, { kind: 20002, content: "", tags, created_at: at });
+}
+afterEach(() => vi.useRealTimers());
+it("expires at signed time, ignores duplicates/out-of-order pulses and uses one timer", () => {
+ const { owner, snapshot } = setup();
+ owner.accept([pulse()], true);
+ expect(snapshot()).toHaveLength(1);
+ expect(vi.getTimerCount()).toBe(1);
+ vi.advanceTimersByTime(3000);
+ owner.accept([pulse(), pulse(undefined, epoch - 1)], true);
+ vi.advanceTimersByTime(4999);
+ expect(snapshot()).toHaveLength(1);
+ vi.advanceTimersByTime(1);
+ expect(snapshot()).toEqual([]);
+ expect(vi.getTimerCount()).toBe(0);
+});
+it("rejects finite, expired, future, self, denied and malformed channel/thread scope", () => {
+ const { owner, snapshot } = setup();
+ owner.accept([pulse()]);
+ owner.accept(
+ [
+ pulse(undefined, epoch - 8),
+ pulse(undefined, epoch + 1),
+ pulse(undefined, epoch, viewer),
+ pulse([]),
+ pulse([["h", "denied"]]),
+ pulse([
+ ["h", "a"],
+ ["h", "a"],
+ ]),
+ pulse([
+ ["h", "a"],
+ ["e", "bad", "", "reply"],
+ ]),
+ pulse([
+ ["h", "a"],
+ ["e", "a".repeat(64), "", "root"],
+ ]),
+ pulse([
+ ["h", "a"],
+ ["e", "a".repeat(64)],
+ ]),
+ ],
+ true,
+ );
+ expect(snapshot()).toEqual([]);
+ expect(vi.getTimerCount()).toBe(0);
+});
+it("separates channel, canonical threads, nested roots and multiple signers", () => {
+ const { owner, snapshot } = setup();
+ const root = "a".repeat(64),
+ parent = "b".repeat(64);
+ owner.accept(
+ [
+ pulse(),
+ pulse([
+ ["h", "a"],
+ ["e", root, "", "reply"],
+ ]),
+ pulse(undefined, epoch, keypair()),
+ ],
+ true,
+ );
+ expect(snapshot()).toHaveLength(3);
+ owner.accept(
+ [
+ pulse([
+ ["h", "a"],
+ ["e", root, "", "root"],
+ ["e", parent, "", "reply"],
+ ]),
+ ],
+ true,
+ );
+ expect(snapshot()).toHaveLength(3);
+ expect(snapshot().filter((e) => e.threadRootId === root)).toHaveLength(1);
+ owner.dispose();
+ expect(snapshot()).toEqual([]);
+ expect(vi.getTimerCount()).toBe(0);
+});
+it("messages win a batch, suppress late pulses for two seconds and retain timestamp watermarks", () => {
+ const { owner, snapshot } = setup();
+ owner.accept([pulse(), message(agent, "a", "fixture", epoch)], true);
+ expect(snapshot()).toEqual([]);
+ vi.advanceTimersByTime(1000);
+ owner.accept([pulse(undefined, epoch + 1)], true);
+ expect(snapshot()).toEqual([]);
+ vi.advanceTimersByTime(1000);
+ owner.accept([pulse(), pulse(undefined, epoch + 2)], true);
+ expect(snapshot()).toHaveLength(1);
+ // Duplicate history cannot extend suppression or remove newer activity.
+ owner.accept([message(agent, "a", "old", epoch)]);
+ expect(snapshot()).toHaveLength(1);
+ vi.advanceTimersByTime(8000);
+ expect(snapshot()).toEqual([]);
+});
+it("message suppression is signer/thread scoped and clears only older activity", () => {
+ const { owner, snapshot } = setup();
+ const tags = [
+ ["h", "a"],
+ ["e", "a".repeat(64), "", "reply"],
+ ];
+ owner.accept([pulse(), pulse(tags)], true);
+ owner.accept([message(agent, "a", "channel", epoch)]);
+ expect(snapshot()).toHaveLength(1);
+ expect(snapshot()[0]?.threadRootId).toBe("a".repeat(64));
+ owner.accept([
+ signed(agent, { kind: 40002, tags, content: "{}", created_at: epoch }),
+ ]);
+ expect(snapshot()).toEqual([]);
+});
+it("bounds active and suppression records without eviction; teardown fences retained callbacks", () => {
+ const { owner, snapshot } = setup();
+ // Distinct roots avoid generating 1025 signing keys.
+ owner.accept(
+ Array.from({ length: 1025 }, (_, i) =>
+ pulse([
+ ["h", "a"],
+ ["e", i.toString(16).padStart(64, "0"), "", "reply"],
+ ]),
+ ),
+ true,
+ );
+ expect(snapshot()).toHaveLength(1024);
+ expect(vi.getTimerCount()).toBe(1);
+ const listener = vi.fn();
+ const stop = owner.capability.subscribe(listener);
+ owner.clear();
+ expect(snapshot()).toEqual([]);
+ expect(listener).toHaveBeenCalledTimes(1);
+ stop();
+ owner.accept([pulse()], true);
+ expect(listener).toHaveBeenCalledTimes(1);
+ owner.dispose();
+ owner.accept([pulse()], true);
+ expect(snapshot()).toEqual([]);
+ expect(vi.getTimerCount()).toBe(0);
+});
diff --git a/src/features/relay/typing.ts b/src/features/relay/typing.ts
new file mode 100644
index 00000000..415ca268
--- /dev/null
+++ b/src/features/relay/typing.ts
@@ -0,0 +1,170 @@
+import type { RelayEvent } from "./events";
+import { threadReference } from "./thread-reference";
+
+const TTL = 8_000;
+const SUPPRESS = 2_000;
+const CAPACITY = 1024;
+export type TypingEntry = Readonly<{
+ channelId: string;
+ threadRootId?: string;
+ pubkey: string;
+}>;
+type Record = {
+ entry: TypingEntry;
+ typingAt: number;
+ messageAt: number;
+ expires: number;
+ suppress: number;
+ retire: number;
+};
+
+/** Receive-only ephemeral projection. Input is verified by the existing host
+ * transport; identity is the signer, never a display name or an untrusted p tag.
+ * No retained event payloads, reads, persistence, or per-consumer timers. */
+export function createTyping(
+ viewer: string,
+ canAccess: (channelId: string) => boolean,
+ notify: (listener: () => void) => void,
+) {
+ let closed = false;
+ let timer: ReturnType | undefined;
+ const records = new Map();
+ const listeners = new Set<() => void>();
+ let snapshot: readonly TypingEntry[] = Object.freeze([]);
+ function publish() {
+ clearTimeout(timer);
+ timer = undefined;
+ const now = Date.now();
+ let nextWake = Infinity;
+ const next: TypingEntry[] = [];
+ for (const [key, record] of records) {
+ if (record.retire <= now) {
+ records.delete(key);
+ continue;
+ }
+ nextWake = Math.min(nextWake, record.retire);
+ if (record.expires > now) {
+ next.push(record.entry);
+ nextWake = Math.min(nextWake, record.expires);
+ }
+ }
+ if (!closed && nextWake < Infinity)
+ timer = setTimeout(publish, nextWake - now);
+ if (
+ next.length === snapshot.length &&
+ next.every((e, i) => e === snapshot[i])
+ )
+ return;
+ snapshot = Object.freeze(next);
+ for (const listener of listeners) notify(listener);
+ }
+ function accept(events: readonly RelayEvent[], live = false) {
+ if (closed) return;
+ const now = Date.now();
+ // Prune before admission; at capacity drop new keys, never evict suppression
+ // evidence to admit an older pulse. All records retire within a bounded TTL.
+ for (const [key, record] of records)
+ if (record.retire <= now) records.delete(key);
+ // Completion wins independent of batch ordering (including equal seconds).
+ for (const event of [...events].sort(
+ (a, b) => Number(a.kind === 20002) - Number(b.kind === 20002),
+ )) {
+ const typing = event.kind === 20002;
+ if ((!typing && ![9, 40002].includes(event.kind)) || (typing && !live))
+ continue;
+ if (event.pubkey === viewer || !/^[0-9a-f]{64}$/.test(event.pubkey))
+ continue;
+ const at = event.created_at * 1000;
+ if (
+ !Number.isSafeInteger(event.created_at) ||
+ at > now ||
+ at + TTL <= now
+ )
+ continue;
+ const channels = event.tags.filter(([name]) => name === "h");
+ const channelId = channels[0]?.[1];
+ if (
+ channels.length !== 1 ||
+ !channelId ||
+ !/^[a-zA-Z0-9_-]{1,128}$/.test(channelId) ||
+ !canAccess(channelId)
+ )
+ continue;
+ const refs = event.tags.filter(([name]) => name === "e");
+ // Canonical reply, optionally with one marked root for nested replies.
+ if (
+ refs.length &&
+ (refs.length > 2 ||
+ refs.some(
+ (tag) =>
+ !/^[0-9a-f]{64}$/i.test(tag[1] ?? "") ||
+ !["root", "reply"].includes(tag[3] ?? ""),
+ ) ||
+ refs.filter((tag) => tag[3] === "reply").length !== 1 ||
+ refs.filter((tag) => tag[3] === "root").length > 1)
+ )
+ continue;
+ const threadRootId = threadReference(event)?.rootId;
+ const key = `${channelId}:${threadRootId ?? ""}:${event.pubkey}`;
+ let record = records.get(key);
+ if (!record) {
+ if (records.size >= CAPACITY) continue;
+ record = {
+ entry: Object.freeze({
+ channelId,
+ ...(threadRootId ? { threadRootId } : {}),
+ pubkey: event.pubkey,
+ }),
+ typingAt: -1,
+ messageAt: -1,
+ expires: 0,
+ suppress: 0,
+ retire: 0,
+ };
+ records.set(key, record);
+ }
+ if (typing) {
+ if (
+ at <= record.typingAt ||
+ at <= record.messageAt ||
+ record.suppress > now
+ )
+ continue;
+ record.typingAt = at;
+ record.expires = at + TTL;
+ } else {
+ if (at <= record.messageAt) continue;
+ record.messageAt = at;
+ if (at >= record.typingAt) {
+ record.expires = 0;
+ record.suppress = now + SUPPRESS;
+ }
+ }
+ record.retire = Math.max(record.retire, at + TTL, record.suppress);
+ }
+ publish();
+ }
+ function clear() {
+ records.clear();
+ publish();
+ }
+ return {
+ capability: Object.freeze({
+ snapshot: () => snapshot,
+ subscribe(listener: () => void) {
+ if (closed) return () => {};
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+ }),
+ accept,
+ clear,
+ dispose() {
+ closed = true;
+ clear();
+ listeners.clear();
+ },
+ };
+}
diff --git a/tests/browser/fixture.mjs b/tests/browser/fixture.mjs
index 309ae0dc..064f27a2 100644
--- a/tests/browser/fixture.mjs
+++ b/tests/browser/fixture.mjs
@@ -59,6 +59,7 @@ export const test = base.extend({
testInfo,
) => {
const relayKey = generateSecretKey();
+ const typingKeys = [generateSecretKey(), generateSecretKey()];
const userKey = generateSecretKey();
const viewer = getPublicKey(userKey);
const peerKey = dmLabels || readState ? generateSecretKey() : undefined;
@@ -645,6 +646,26 @@ export const test = base.extend({
expect(rosterIds).toContain(id);
rosterIds.splice(rosterIds.indexOf(id), 1);
},
+ // Signed upstream-only simulations: never a browser publication or live relay.
+ activity({
+ channel = "alpha",
+ root,
+ author = 0,
+ kind = 20002,
+ age = 0,
+ } = {}) {
+ if (!relay)
+ throw new Error("Typing fixture requires production broker");
+ const event = sign(
+ kind,
+ [["h", channel], ...(root ? [["e", root, "", "reply"]] : [])],
+ kind === 20002 ? "" : "Fixture completion",
+ typingKeys[author],
+ Math.floor(Date.now() / 1000) - age,
+ );
+ relay.publish("primary", event);
+ return event;
+ },
edit(community, channel, target, content) {
const event = sign(
40003,
diff --git a/tests/browser/typing.spec.mjs b/tests/browser/typing.spec.mjs
new file mode 100644
index 00000000..4645ff5f
--- /dev/null
+++ b/tests/browser/typing.spec.mjs
@@ -0,0 +1,54 @@
+import { test, expect } from "./fixture.mjs";
+import { open } from "./timeline.mjs";
+
+test.use({ productionBroker: true, readState: true, threadUnread: true });
+test("Messages receives scoped typing through authenticated live traffic and expires it without publishing", async ({
+ page,
+ app,
+}, testInfo) => {
+ await open(page, app);
+ await expect
+ .poll(() =>
+ app.report.liveRequests.some((r) => r.filter?.["#h"]?.includes("alpha")),
+ )
+ .toBe(true);
+ const indicator = page.getByRole("status", { name: "Typing activity" });
+ app.activity({ age: 9 });
+ await expect(indicator).toHaveCount(0);
+ app.activity();
+ await expect(indicator).toContainText("is typing…");
+ app.activity({ author: 1 });
+ await expect(indicator).toContainText("are typing…");
+ await page.screenshot({ path: testInfo.outputPath("messages-typing.png") });
+ app.activity({ kind: 9 });
+ await expect(indicator).toContainText("is typing…");
+ app.activity({ kind: 9, author: 1 });
+ await expect(indicator).toHaveCount(0);
+ app.activity(); // same-second late pulse cannot resurrect completion
+ await expect(indicator).toHaveCount(0);
+ const root = app.histories
+ .get("primary/alpha")
+ .find((e) => e.content === "Thread root 0");
+ await page
+ .locator(`[data-channel-timeline] [data-message-id="${root.id}"]`)
+ .getByRole("button", { name: /^View thread:/ })
+ .click();
+ const thread = page.getByRole("complementary", {
+ name: "Thread",
+ exact: true,
+ });
+ await expect(
+ thread.getByRole("textbox", { name: "Reply to thread", exact: true }),
+ ).toBeVisible();
+ app.activity({ root: root.id });
+ await expect(
+ thread.getByRole("status", { name: "Typing activity" }),
+ ).toContainText("is typing…");
+ await expect(indicator).toHaveCount(1);
+ await page.screenshot({
+ path: testInfo.outputPath("messages-thread-typing.png"),
+ });
+ // Real browser timer, signed timestamp TTL, no polling transport or fixture cleanup.
+ await expect(indicator).toHaveCount(0, { timeout: 10000 });
+ expect(app.report.publications).toEqual([]);
+});
From e899641095d9732007fdedacfb19326caf4c09b3 Mon Sep 17 00:00:00 2001
From: pic-worker-aa352576c7
<6838876f3610dc213d19edeb1cdf5a8d0a0b568dc9b40db674ade16725074cce@buzz.block.builderlab.xyz>
Date: Fri, 11 Sep 2026 22:20:09 -0700
Subject: [PATCH 2/7] Fix typing completion scope and reentrant live admission
Signed-off-by: pic-worker-aa352576c7 <6838876f3610dc213d19edeb1cdf5a8d0a0b568dc9b40db674ade16725074cce@buzz.block.builderlab.xyz>
---
src/features/relay/session.ts | 11 +-
src/features/relay/typing.integration.test.ts | 103 ++++++++++++++++++
src/features/relay/typing.test.ts | 57 ++++++++++
src/features/relay/typing.ts | 4 +-
4 files changed, 173 insertions(+), 2 deletions(-)
diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts
index 80fe4c44..57c19e48 100644
--- a/src/features/relay/session.ts
+++ b/src/features/relay/session.ts
@@ -897,8 +897,17 @@ export function createRelaySession(
)
)
refreshRoster();
+ const epoch = accessEpoch;
+ const generation = liveGeneration;
accept(events);
- if (liveSnapshot.status === "connected")
+ // Completion subscribers can synchronously clear, revoke or retire this
+ // live delivery. Do not admit its remaining pulses into the new lifetime.
+ if (
+ !closed &&
+ epoch === accessEpoch &&
+ generation === liveGeneration &&
+ liveSnapshot.status === "connected"
+ )
typing.accept(
events.filter((event) => event.kind === 20002),
true,
diff --git a/src/features/relay/typing.integration.test.ts b/src/features/relay/typing.integration.test.ts
index 90eb0b8b..11be6144 100644
--- a/src/features/relay/typing.integration.test.ts
+++ b/src/features/relay/typing.integration.test.ts
@@ -104,3 +104,106 @@ it("a synchronous typing listener cannot reseed retained views after disposal",
expect(view.snapshot().events).toEqual([]);
expect(owner.session.typing.snapshot()).toEqual([]);
});
+
+for (const transition of ["cache", "dispose", "access", "reconnect"] as const) {
+ it(`fences the rest of a live callback batch after reentrant ${transition}`, async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(1_800_000_000_000);
+ const viewer = keypair(),
+ relay = keypair(),
+ agent = keypair();
+ const wire = scriptedTransport(viewer.pubkey, relay.pubkey);
+ let live!: LiveCallbacks;
+ const owner = createRelaySession({
+ ...wire.transport,
+ subscribe(callbacks) {
+ live = callbacks;
+ return { update() {}, retry() {}, dispose() {} };
+ },
+ });
+ live.state({ status: "connected", routes: [] });
+ live.receive([
+ roster(relay, "a", [viewer.pubkey]),
+ roster(relay, "b", [viewer.pubkey]),
+ ]);
+ const pulse = signed(agent, {
+ kind: 20002,
+ content: "",
+ created_at: 1_800_000_000,
+ tags: [["h", "a"]],
+ });
+ live.receive([pulse]);
+ expect(owner.session.typing.snapshot()).toHaveLength(1);
+ let clearing: Promise | undefined;
+ const listener = vi.fn(() => {
+ expect(owner.session.typing.snapshot()).toEqual([]);
+ if (transition === "cache") clearing = owner.clearCache();
+ else if (transition === "dispose") owner.dispose();
+ // Revoking another channel still invalidates the in-flight access epoch.
+ else if (transition === "access")
+ live.receive([roster(relay, "b", [], 1_800_000_001)]);
+ else {
+ live.state({ status: "retrying", routes: [] });
+ live.state({ status: "connected", routes: [] });
+ }
+ });
+ owner.session.typing.subscribe(listener);
+ // Supported LiveCallbacks batch boundary; WS/SSE currently deliver singletons.
+ live.receive([
+ signed(agent, {
+ kind: 9,
+ content: "fixture",
+ created_at: 1_800_000_000,
+ tags: [["h", "a"]],
+ }),
+ pulse,
+ ]);
+ await clearing;
+ expect(listener).toHaveBeenCalledTimes(1);
+ expect(owner.session.typing.snapshot()).toEqual([]);
+ owner.dispose();
+ expect(vi.getTimerCount()).toBe(0);
+ });
+}
+
+it("refreshing a finite kind-20002 view neither retains nor activates typing", async () => {
+ const viewer = keypair(),
+ relay = keypair(),
+ agent = keypair();
+ const wire = scriptedTransport(viewer.pubkey, relay.pubkey);
+ let live!: LiveCallbacks;
+ const owner = createRelaySession({
+ ...wire.transport,
+ subscribe(callbacks) {
+ live = callbacks;
+ return { update() {}, retry() {}, dispose() {} };
+ },
+ });
+ try {
+ live.state({ status: "connected", routes: [] });
+ live.receive([roster(relay, "a", [viewer.pubkey])]);
+ const filters = [{ kinds: [20002], "#h": ["a"], limit: 10 }];
+ const view = owner.session.observe(filters);
+ const pulse = signed(agent, {
+ kind: 20002,
+ content: "",
+ created_at: Math.floor(Date.now() / 1000),
+ tags: [["h", "a"]],
+ });
+ const refresh = view.refresh();
+ const read = wire.next();
+ expect(read.filters).toEqual(filters);
+ read.respond([pulse]);
+ await refresh;
+ expect(view.snapshot()).toMatchObject({ status: "ready", events: [] });
+ expect(owner.session.typing.snapshot()).toEqual([]);
+ // The same signed event is valid live, but still cannot enter finite views.
+ live.receive([pulse]);
+ expect(owner.session.typing.snapshot()).toHaveLength(1);
+ expect(view.snapshot().events).toEqual([]);
+ const later = owner.session.observe(filters);
+ expect(later.snapshot().events).toEqual([]);
+ } finally {
+ owner.dispose();
+ }
+});
diff --git a/src/features/relay/typing.test.ts b/src/features/relay/typing.test.ts
index 76796501..e63db962 100644
--- a/src/features/relay/typing.test.ts
+++ b/src/features/relay/typing.test.ts
@@ -58,6 +58,16 @@ it("rejects finite, expired, future, self, denied and malformed channel/thread s
["h", "a"],
["e", "a".repeat(64)],
]),
+ pulse([
+ ["h", "a"],
+ ["e", "a".repeat(64), "", "mention"],
+ ]),
+ pulse([
+ ["h", "a"],
+ ["e", "a".repeat(64), "", "root"],
+ ["e", "b".repeat(64), "", "reply"],
+ ["e", "c".repeat(64), "", "mention"],
+ ]),
],
true,
);
@@ -154,3 +164,50 @@ it("bounds active and suppression records without eviction; teardown fences reta
expect(snapshot()).toEqual([]);
expect(vi.getTimerCount()).toBe(0);
});
+
+for (const kind of [9, 40002]) {
+ for (const scope of ["mention", "quote", "reply", "nested"] as const) {
+ it(`kind ${kind} ${scope} content clears and suppresses its authoritative scope`, () => {
+ const { owner, snapshot } = setup();
+ const root = "a".repeat(64);
+ const threadTags = [
+ ["h", "a"],
+ ["e", root, "", "reply"],
+ ];
+ const references =
+ scope === "mention"
+ ? [["e", "b".repeat(64), "", "mention"]]
+ : scope === "quote"
+ ? [["e", "b".repeat(64)]]
+ : [
+ ...(scope === "nested" ? [["e", root, "", "root"]] : []),
+ ["e", scope === "nested" ? "c".repeat(64) : root, "", "reply"],
+ ["e", "b".repeat(64), "", "mention"],
+ ["e", "d".repeat(64)],
+ ];
+ const threaded = scope === "reply" || scope === "nested";
+ const target = pulse(threaded ? threadTags : undefined);
+ const other = pulse(threaded ? undefined : threadTags);
+ owner.accept([target, other], true);
+ owner.accept([
+ signed(agent, {
+ kind,
+ content: "fixture",
+ created_at: epoch,
+ tags: [["h", "a"], ...references],
+ }),
+ ]);
+ expect(snapshot()).toHaveLength(1);
+ expect(snapshot()[0]?.threadRootId).toBe(threaded ? undefined : root);
+ // Same-second replay and a newer pulse during the quiet period both lose.
+ owner.accept([target], true);
+ vi.advanceTimersByTime(1000);
+ owner.accept([pulse(threaded ? threadTags : undefined, epoch + 1)], true);
+ expect(snapshot()).toHaveLength(1);
+ vi.advanceTimersByTime(1000);
+ owner.accept([pulse(threaded ? threadTags : undefined, epoch + 2)], true);
+ expect(snapshot()).toHaveLength(2);
+ owner.dispose();
+ });
+ }
+}
diff --git a/src/features/relay/typing.ts b/src/features/relay/typing.ts
index 415ca268..dbec2792 100644
--- a/src/features/relay/typing.ts
+++ b/src/features/relay/typing.ts
@@ -91,8 +91,10 @@ export function createTyping(
)
continue;
const refs = event.tags.filter(([name]) => name === "e");
- // Canonical reply, optionally with one marked root for nested replies.
+ // Pulses require an unambiguous canonical scope. Content uses the same
+ // threadReference semantics as folding, including non-thread references.
if (
+ typing &&
refs.length &&
(refs.length > 2 ||
refs.some(
From fa065ddb560d9e82d57f5ac0fa4691d23858dee7 Mon Sep 17 00:00:00 2001
From: Fizz
<400e8babadcee6a7f420103f10a2849d84c4a9c71d5bd04f3948c814216648a3@buzz.block.builderlab.xyz>
Date: Thu, 10 Sep 2026 17:16:02 -0700
Subject: [PATCH 3/7] feat(pulse): checkpoint source page experiment
Signed-off-by: Fizz <400e8babadcee6a7f420103f10a2849d84c4a9c71d5bd04f3948c814216648a3@buzz.block.builderlab.xyz>
---
crates/plugin-manager/src/lib.rs | 2 +
crates/plugin-manager/tests/management.rs | 1 +
src/app/pages.integration.test.mjs | 20 +-
src/bundled/index.ts | 3 +
src/bundled/pulse/Pulse.module.css | 333 ++++++++++++++++++++
src/bundled/pulse/PulseConversation.tsx | 92 ++++++
src/bundled/pulse/PulsePage.tsx | 354 ++++++++++++++++++++++
src/bundled/pulse/README.md | 83 +++++
src/bundled/pulse/feed.test.ts | 116 +++++++
src/bundled/pulse/feed.ts | 83 +++++
src/bundled/pulse/index.tsx | 16 +
src/bundled/pulse/manifest.json | 1 +
src/bundled/pulse/usePulseFeed.ts | 105 +++++++
tests/browser/pulse-lifecycle.spec.mjs | 81 +++++
tests/browser/pulse.spec.mjs | 79 +++++
15 files changed, 1367 insertions(+), 2 deletions(-)
create mode 100644 src/bundled/pulse/Pulse.module.css
create mode 100644 src/bundled/pulse/PulseConversation.tsx
create mode 100644 src/bundled/pulse/PulsePage.tsx
create mode 100644 src/bundled/pulse/README.md
create mode 100644 src/bundled/pulse/feed.test.ts
create mode 100644 src/bundled/pulse/feed.ts
create mode 100644 src/bundled/pulse/index.tsx
create mode 100644 src/bundled/pulse/manifest.json
create mode 100644 src/bundled/pulse/usePulseFeed.ts
create mode 100644 tests/browser/pulse-lifecycle.spec.mjs
create mode 100644 tests/browser/pulse.spec.mjs
diff --git a/crates/plugin-manager/src/lib.rs b/crates/plugin-manager/src/lib.rs
index 4cc98d0a..c1cbb57a 100644
--- a/crates/plugin-manager/src/lib.rs
+++ b/crates/plugin-manager/src/lib.rs
@@ -65,6 +65,8 @@ pub fn bundled_manifests() -> Vec {
.expect("projects manifest"),
serde_json::from_str(include_str!("../../../src/bundled/agents/manifest.json"))
.expect("agents manifest"),
+ serde_json::from_str(include_str!("../../../src/bundled/pulse/manifest.json"))
+ .expect("pulse manifest"),
]
}
fn is_bundled(id: &str) -> bool {
diff --git a/crates/plugin-manager/tests/management.rs b/crates/plugin-manager/tests/management.rs
index 678b7f06..b1902388 100644
--- a/crates/plugin-manager/tests/management.rs
+++ b/crates/plugin-manager/tests/management.rs
@@ -278,6 +278,7 @@ fn bundled_plugins_have_independent_flags_and_all_ids_are_reserved() {
"buzz.agents",
"buzz.emoji",
"buzz.mentions",
+ "buzz.pulse",
] {
assert!(
manager
diff --git a/src/app/pages.integration.test.mjs b/src/app/pages.integration.test.mjs
index ed4fa6db..359cbaa4 100644
--- a/src/app/pages.integration.test.mjs
+++ b/src/app/pages.integration.test.mjs
@@ -29,7 +29,7 @@ test("the app runtime exposes ready bundled pages and removes them on disable",
services = createServices();
assert.deepEqual(services.pages.snapshot(), []);
await settle();
- assert.equal(services.pages.snapshot().length, 3);
+ assert.equal(services.pages.snapshot().length, 4);
await vi.waitFor(() =>
assert.equal(services.conversation.tools.snapshot().length, 2),
);
@@ -85,7 +85,7 @@ test("the app runtime exposes ready bundled pages and removes them on disable",
.some((panel) => panel.pluginId === "buzz.bestie"),
false,
);
- assert.equal(services.pages.snapshot().length, 3);
+ assert.equal(services.pages.snapshot().length, 4);
await services.plugins.change("enable", "buzz.bestie");
// Management completion is not activation completion; Cordis still owns import/disposal barriers.
await vi.waitFor(() =>
@@ -100,6 +100,22 @@ test("the app runtime exposes ready bundled pages and removes them on disable",
.find((panel) => panel.pluginId === "buzz.bestie");
assert.notEqual(secondBestie, firstBestie);
assert.equal(secondBestie.revision, firstBestie.revision);
+ const pulse = services.pages
+ .snapshot()
+ .find((page) => page.pluginId === "buzz.pulse");
+ assert.equal(pulse.title, "Pulse");
+ assert.equal(pulse.companion, true);
+ assert.match(
+ renderToStaticMarkup(createElement(pulse.component)),
+ /A little closer to what matters/,
+ );
+ const pulseSession = services.relay.snapshot().session;
+ await services.plugins.change("disable", "buzz.pulse");
+ assert.equal(
+ services.pages.snapshot().some((page) => page.pluginId === "buzz.pulse"),
+ false,
+ );
+ assert.equal(services.relay.snapshot().session, pulseSession);
const page = services.pages.snapshot()[0];
assert.match(
renderToStaticMarkup(createElement(page.component)),
diff --git a/src/bundled/index.ts b/src/bundled/index.ts
index 78cf15c0..18e9d76e 100644
--- a/src/bundled/index.ts
+++ b/src/bundled/index.ts
@@ -2,6 +2,8 @@ import terminalManifest from "./terminal/manifest.json";
import * as terminal from "./terminal";
import profilesManifest from "./profiles/manifest.json";
import * as profiles from "./profiles";
+import pulseManifest from "./pulse/manifest.json";
+import * as pulse from "./pulse";
import mentionsManifest from "./mentions/manifest.json";
import * as mentions from "./mentions";
import emojiManifest from "./emoji/manifest.json";
@@ -28,4 +30,5 @@ export const bundledPlugins: readonly BundledPlugin[] = [
{ manifest: { ...bestieManifest, apiVersion: 1 }, module: bestie },
{ manifest: { ...projectsManifest, apiVersion: 1 }, module: projects },
{ manifest: { ...agentsManifest, apiVersion: 1 }, module: agents },
+ { manifest: { ...pulseManifest, apiVersion: 1 }, module: pulse },
];
diff --git a/src/bundled/pulse/Pulse.module.css b/src/bundled/pulse/Pulse.module.css
new file mode 100644
index 00000000..78d177d2
--- /dev/null
+++ b/src/bundled/pulse/Pulse.module.css
@@ -0,0 +1,333 @@
+/* Page-owned selector trees are disjoint; no host or shared message overrides. */
+/* biome-ignore-all lint/style/noDescendingSpecificity: Selectors belong to disjoint Pulse regions. */
+.root {
+ height: 100%;
+ min-height: 0;
+}
+.canvas {
+ display: flex;
+ height: 100%;
+ min-height: 0;
+ max-width: 960px;
+ margin: 0 auto;
+ overflow: hidden;
+ border-radius: var(--radius-card);
+ background: var(--surface);
+ box-shadow: var(--elevation-card);
+ color: var(--text);
+}
+.sidebar {
+ width: 220px;
+ flex: 0 0 220px;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ padding: 24px 12px 12px;
+ border-right: 1px solid var(--border);
+}
+.brand {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 0 12px 24px;
+ font-size: 18px;
+}
+.sidebar nav button {
+ border: 0;
+ box-shadow: none;
+ width: 100%;
+ display: flex;
+ align-items: center;
+ text-align: left;
+ gap: 10px;
+ background: transparent;
+ padding: 10px 12px;
+ font-size: 13px;
+ border-radius: var(--radius-control);
+}
+.sidebar nav button:hover {
+ background: var(--surface-hover);
+}
+.sidebar nav button[aria-current="page"] {
+ background: var(--selected);
+ color: var(--on-selected);
+}
+.navIcon {
+ display: grid;
+ place-items: center;
+ border-radius: 50%;
+ width: 28px;
+ height: 28px;
+ background: var(--surface-control);
+ color: var(--text);
+}
+.sidebarLabel {
+ margin: 28px 12px 12px;
+ color: var(--text-muted);
+ font-size: 10px;
+ letter-spacing: 0.08em;
+ font-weight: 600;
+}
+.channelSearch {
+ min-width: 0;
+ width: 100%;
+ font-size: 12px;
+ margin-bottom: 8px;
+}
+.channelList {
+ min-height: 0;
+ flex: 1;
+ overflow: auto;
+}
+.channelList button span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.channelList svg {
+ flex-shrink: 0;
+}
+.sidebarFoot {
+ font-size: 11px;
+ line-height: 1.6;
+ color: var(--text-muted);
+ padding: 12px;
+ margin: 0;
+}
+.sidebarError {
+ font-size: 12px;
+ overflow-wrap: anywhere;
+}
+.main {
+ min-width: 0;
+ min-height: 0;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+}
+.feedHeading {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 36px 32px 8px;
+ gap: 12px;
+}
+.feedHeading button {
+ border: 0;
+ background: transparent;
+ padding: 8px;
+}
+.feedHeading h1 {
+ margin: 8px 0;
+ font-size: 30px;
+ font-weight: 500;
+ letter-spacing: -0.04em;
+}
+.eyebrow {
+ color: var(--text-muted);
+ font-size: 10px;
+ font-weight: 600;
+ letter-spacing: 0.08em;
+}
+.intro {
+ padding: 0 32px 24px;
+ color: var(--text-muted);
+ font-size: 13px;
+ line-height: 1.6;
+}
+.search {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin: 0 32px 16px;
+ background: var(--surface-input);
+ border: 1px solid var(--border-input);
+ border-radius: var(--radius-control);
+ padding: 0 12px;
+}
+.search input {
+ width: 100%;
+ min-width: 0;
+ border: 0;
+ background: transparent;
+}
+.feed {
+ flex: 1;
+ min-height: 0;
+ overflow: auto;
+ overflow-anchor: auto;
+}
+.card {
+ padding: 20px 16px 16px;
+ border-top: 1px solid var(--border);
+}
+.source {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ justify-content: space-between;
+ width: 100%;
+ text-align: left;
+ border: 0;
+ background: transparent;
+ color: var(--text-muted);
+ font-size: 11px;
+ padding: 0 12px 8px;
+}
+.source span:last-child {
+ font-size: 10px;
+}
+.reply {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ border: 0;
+ background: var(--surface-control);
+ color: var(--text-muted);
+ font-size: 11px;
+ border-radius: 24px;
+ margin: 12px 0 0 56px;
+ padding: 7px 12px;
+}
+.empty,
+.connect {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ padding: 48px 24px;
+ color: var(--text-muted);
+}
+.empty h2 {
+ font-size: 18px;
+ color: var(--text);
+ font-weight: 500;
+}
+.empty p,
+.connect p {
+ max-width: 360px;
+ font-size: 13px;
+ line-height: 1.6;
+}
+.connect {
+ height: 100%;
+ border-radius: var(--radius-card);
+ background: var(--surface);
+}
+.connect h1 {
+ font-size: 30px;
+ color: var(--text);
+ font-weight: 500;
+}
+.footer {
+ text-align: center;
+ padding: 28px;
+ font-size: 11px;
+ line-height: 1.8;
+ color: var(--text-muted);
+}
+.notice {
+ margin: 16px;
+ padding: 16px;
+ color: var(--danger);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-control);
+ font-size: 13px;
+}
+.detail {
+ min-height: 0;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+}
+.heading {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 16px;
+ border-bottom: 1px solid var(--border);
+}
+.heading strong {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 14px;
+}
+.heading button {
+ display: grid;
+ place-items: center;
+ border: 0;
+ background: transparent;
+}
+@media (max-width: 700px) {
+ .sidebar {
+ width: 160px;
+ flex-basis: 160px;
+ padding: 16px 8px 8px;
+ }
+ .feedHeading {
+ padding: 24px 20px 8px;
+ }
+ .intro {
+ padding: 0 20px 20px;
+ }
+ .search {
+ margin: 0 20px 16px;
+ }
+}
+@media (max-width: 520px) {
+ .canvas {
+ flex-direction: column;
+ }
+ .sidebar {
+ width: auto;
+ flex: 0 0 auto;
+ border-right: 0;
+ border-bottom: 1px solid var(--border);
+ padding: 8px;
+ }
+ .sidebar nav:first-of-type {
+ display: flex;
+ }
+ .sidebar nav:first-of-type button {
+ justify-content: center;
+ gap: 4px;
+ font-size: 12px;
+ padding: 6px;
+ }
+ .brand,
+ .sidebarLabel,
+ .sidebarFoot,
+ .channelSearch {
+ display: none;
+ }
+ .channelList {
+ display: flex;
+ max-height: 44px;
+ }
+ .channelList button {
+ flex: 0 0 auto;
+ max-width: 160px;
+ }
+ .reading .sidebar {
+ display: none;
+ }
+ .feedHeading {
+ padding-top: 20px;
+ }
+ .eyebrow {
+ font-size: 9px;
+ }
+ .card {
+ padding: 16px 8px;
+ }
+}
+
+.threadHost {
+ display: grid;
+ grid-template-rows: minmax(0, 1fr);
+ flex: 1;
+ min-height: 0;
+}
diff --git a/src/bundled/pulse/PulseConversation.tsx b/src/bundled/pulse/PulseConversation.tsx
new file mode 100644
index 00000000..4cc31c3e
--- /dev/null
+++ b/src/bundled/pulse/PulseConversation.tsx
@@ -0,0 +1,92 @@
+import { useState } from "react";
+import { ArrowLeft } from "lucide-react";
+import type { RelaySession } from "../../features/relay/session";
+import type { ConversationExtensions } from "../../features/conversation/contracts";
+import { useChannelWindow } from "../../features/relay/react";
+import { ChannelTimeline } from "../../features/messages/ChannelTimeline";
+import { MessageComposer } from "../../features/messages/MessageComposer";
+import { ThreadPanel } from "../../features/messages/ThreadPanel";
+import styles from "./Pulse.module.css";
+const openLink = () => false;
+export function PulseConversation({
+ session,
+ scope,
+ channelId,
+ name,
+ extensions,
+ back,
+ initialThread,
+}: {
+ session: RelaySession;
+ scope: string;
+ channelId: string;
+ name: string;
+ extensions?: ConversationExtensions | undefined;
+ back(): void;
+ initialThread?: string | undefined;
+}) {
+ const window = useChannelWindow(session.channels, channelId);
+ const [thread, setThread] = useState(initialThread);
+ const [sent, setSent] = useState();
+ return (
+
+
+ {thread ? (
+
+ setThread(undefined)}
+ onOpenLink={openLink}
+ />
+
+ ) : (
+ <>
+ {window.status === "error" && !window.rows.length ? (
+
+
{window.error}
+
+
+ ) : window.status !== "ready" && !window.rows.length ? (
+
+ Loading messages…
+
+ ) : (
+
+ )}
+
+ >
+ )}
+
+ );
+}
diff --git a/src/bundled/pulse/PulsePage.tsx b/src/bundled/pulse/PulsePage.tsx
new file mode 100644
index 00000000..8632fdbb
--- /dev/null
+++ b/src/bundled/pulse/PulsePage.tsx
@@ -0,0 +1,354 @@
+import { useMemo, useRef, useState, type ReactNode } from "react";
+import {
+ Hash,
+ Inbox,
+ MessageCircle,
+ Search,
+ Sparkles,
+ RefreshCw,
+} from "lucide-react";
+import type { RelayData } from "../../features/relay/service";
+import type { RelaySession } from "../../features/relay/session";
+import type { ConversationExtensions } from "../../features/conversation/contracts";
+import { useChannelList, useRelayConnection } from "../../features/relay/react";
+import { PanelFrame } from "../../features/panels/PanelFrame";
+import { MessageRow } from "../../features/messages/MessageRow";
+import { readView, writeView } from "../../shared/view-state";
+import { channelLabel, filterRows, visibleChannels } from "./feed";
+import { usePulseFeed } from "./usePulseFeed";
+import { PulseConversation } from "./PulseConversation";
+import styles from "./Pulse.module.css";
+
+type View = "for-you" | "all" | "search";
+type Selection = { channelId: string; thread?: string | undefined };
+const views = [
+ { id: "search", title: "Search", icon: Search },
+ { id: "for-you", title: "For you", icon: Sparkles },
+ { id: "all", title: "All messages", icon: MessageCircle },
+] as const;
+export function PulsePage({
+ relay,
+ extensions,
+ companion,
+}: {
+ relay: RelayData;
+ extensions?: ConversationExtensions | undefined;
+ companion?: ReactNode;
+}) {
+ const connection = useRelayConnection(relay);
+ return (
+
+
+ {connection.status === "ready" ? (
+
+ ) : (
+
+
+
A little closer to what matters.
+
+ {connection.status === "connecting"
+ ? "Connecting to your community…"
+ : "Connect to a community to see your conversations in Pulse."}
+
+ {connection.status === "error" && (
+ <>
+
{connection.error}
+
+ >
+ )}
+
+ )}
+
+
+ );
+}
+function PulseWorkspace({
+ session,
+ scope,
+ viewer,
+ extensions,
+}: {
+ session: RelaySession;
+ scope: string;
+ viewer?: string | undefined;
+ extensions?: ConversationExtensions | undefined;
+}) {
+ const list = useChannelList(session.channels);
+ const feed = usePulseFeed(session, list.channels, list.status === "ready");
+ const channels = visibleChannels(list.channels);
+ const [view, setView] = useState(() => {
+ const saved = readView(scope, "pulse-view", "all");
+ return saved === "for-you" || saved === "search" ? saved : "all";
+ });
+ const [selected, setSelected] = useState(() => {
+ const id = readView(scope, "pulse-channel", undefined);
+ return typeof id === "string" ? { channelId: id } : undefined;
+ });
+ const [search, setSearch] = useState("");
+ const [channelSearch, setChannelSearch] = useState("");
+ const trigger = useRef(null);
+ const current = channels.find(
+ (channel) => channel.id === selected?.channelId,
+ );
+ const open = (channelId: string, thread?: string) => {
+ trigger.current =
+ document.activeElement instanceof HTMLElement
+ ? document.activeElement
+ : null;
+ setSelected({ channelId, thread });
+ writeView(scope, "pulse-channel", channelId);
+ };
+ const back = () => {
+ setSelected(undefined);
+ writeView(scope, "pulse-channel", null);
+ requestAnimationFrame(() => {
+ if (trigger.current?.isConnected) trigger.current.focus();
+ });
+ };
+ const switchView = (next: View) => {
+ setView(next);
+ writeView(scope, "pulse-view", next);
+ back();
+ };
+ const rows = useMemo(
+ () =>
+ filterRows(feed.rows, list.channels, feed.profiles, view, search, viewer),
+ [feed.rows, list.channels, feed.profiles, view, search, viewer],
+ );
+ const title = views.find((item) => item.id === view)?.title;
+ const loading =
+ list.status === "loading" ||
+ (list.status === "ready" &&
+ channels.length > 0 &&
+ !feed.error &&
+ feed.status === "loading");
+ return (
+
+
+
+ {current ? (
+
+ ) : (
+ <>
+
+
+ {view === "for-you"
+ ? "Your direct conversations and messages that mention you. Original excerpts, not AI summaries."
+ : view === "search"
+ ? "Find something in the recent activity loaded here."
+ : "A quieter way to catch up. Open a conversation and pick up where it left off."}
+
+ {view === "search" && (
+
+ )}
+
+ {(feed.error || list.status === "error") && (
+
+
+ Some activity couldn’t be loaded. This feed may be out of
+ date.
+
+
+
+ )}
+ {loading && !rows.length ? (
+
+ Gathering recent conversations…
+
+ ) : rows.length ? (
+ rows.map((row) => {
+ const channel = channels.find(
+ (item) => item.id === row.channelId,
+ );
+ if (!channel) return null;
+ return (
+
+
+ false}
+ />
+
+
+ );
+ })
+ ) : (
+
+
+
A little quiet here
+
+ {search
+ ? "No recent conversations match that search."
+ : view === "for-you"
+ ? "No direct conversations or mentions in this recent window."
+ : "Recent conversations will appear here."}
+
+
+ )}
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/src/bundled/pulse/README.md b/src/bundled/pulse/README.md
new file mode 100644
index 00000000..bead257e
--- /dev/null
+++ b/src/bundled/pulse/README.md
@@ -0,0 +1,83 @@
+# Pulse experiment
+
+A bundled **source page plugin**, independently toggleable in Settings → Plugins.
+This is not a self-contained external API-v1 install artifact. Run this branch of
+buzz-app and choose **Pulse** in the top navigation. Messages is unchanged.
+
+## Design reference
+
+Inspired by [`block/buzz`'s `am-pulse-proto`](https://github.com/block/buzz/tree/2d620055e574f41f466ae13b88cfb0aa77ede068/desktop/src/features/pulse),
+inspected against merge base `cec5c8fd9280d30f56effac701e1e19d5cfe6fea` with main.
+The relevant additions are `UnifiedPulseView`, `PulseConversationSplitView`,
+`PulseBriefing`, and the feature README—not the branch's unrelated mobile,
+identity, agent-recovery, or broader shell changes.
+
+Carried over:
+- A centered 960px rounded canvas, quiet separators, compact 220px conversation
+ navigation, and Search / For you / All messages destinations.
+- Author-led activity with source-conversation links and in-place thread drill-in.
+- Conversation selection independent of feed order; shared, separately scoped
+ channel/thread drafts; responsive navigation and light/dark semantic tokens.
+
+Adapted to buzz-app:
+- The host keeps its own navigation, canvas, appearance and companion launcher.
+- A page owns its React tree and CSS module; no host or `FOUNDATION` file changes.
+- `relay` supplies one existing session. The page creates one bounded observed
+ activity view after the roster is available, disposed on unmount/roster change.
+ No new socket, cache, signer, outbox, polling loop, or model-provider connection.
+- Reuses `ChannelTimeline`, `MessageRow`, `MessageComposer`, `ThreadPanel` and
+ `PanelFrame`. Emoji/Mentions remain optional conversation contributions.
+ Shared message styling is retained rather than copied or overridden with
+ prototype bubble styles. Links keep ordinary external navigation in this pass;
+ only the host companion dock is embedded, not a second local object dock.
+- Session-owned state remounts by scope **and** generation. Navigation and drafts
+ persist under stable scope, not generation. The full roster's membership key
+ participates in missing DM-profile recovery; names never gate conversation reads.
+
+## Honest boundaries
+
+**For you is not an AI briefing.** It filters recent original top-level posts to
+DMs and exact `p`-tag mentions of the viewer. It does not infer requests from prose,
+agent runtime status or unread state. No provider receives messages. buzz-app does
+not currently expose a shared summarization or read-marker capability.
+
+The feed requests 200 recent events across the visible joined roster and shows at
+most 30 conversations, one newest matching root each. Host retained-view limits
+still apply. Busy channels may dominate the window; search covers retained activity,
+not full history. The channel rail remains roster-ordered, not a fabricated activity
+ranking. A partial roster is labeled. Hidden non-DM and archived channels are omitted.
+
+Author edits/deletes use the shared fold. Delivery uncertainty remains visible.
+Feed rows deliberately do not interpret relay thread summaries: the session does
+not expose the signing identity needed to validate those summaries in this page.
+Opening a thread uses the authoritative shared reader and its existing bounded,
+oldest-first history behavior. Selected channel state survives reload; thread
+selection and feed search do not become new URL routes.
+
+## Trying it
+
+From this worktree:
+
+```sh
+bin/just web
+```
+
+For actual community data, use the repository's existing opt-in live setup in
+[README](../../../../README.md#relay-channels), then `BUZZ_LIVE=1 bin/just web`.
+No identity configuration is added by this plugin. Packaged login and native
+acceptance remain the host's existing limitations.
+
+## Checks
+
+- `feed.test.ts`: visible sources, deterministic grouping, author edits/deletes,
+ failed-edit rollback, exact mentions, bounded search and DM names.
+- App composition test: contribution registration, disable and shared-session lifetime.
+- Native catalog test: independent enable/disable flag and reserved bundled identity.
+- `tests/browser/pulse*.spec.mjs`: Chromium/WebKit search, conversation opening,
+ separate retained drafts, thread reader, live source edits, access revocation,
+ plugin unload/re-enable, companion placement, theme and narrow viewport.
+
+Browser fixtures use synthetic signed data; they do not publish to a real relay.
+The lifecycle fixture exercises reply draft composition, not successful publication.
+A full `just scan`, attended live use, final design approval and packaged native
+acceptance are required before calling this ready for integration.
diff --git a/src/bundled/pulse/feed.test.ts b/src/bundled/pulse/feed.test.ts
new file mode 100644
index 00000000..57609e4f
--- /dev/null
+++ b/src/bundled/pulse/feed.test.ts
@@ -0,0 +1,116 @@
+import { expect, it } from "vitest";
+import { keypair, message, signed } from "../../features/relay/testing";
+import { channelLabel, filterRows, pulseRows, visibleChannels } from "./feed";
+import type { ChannelSummary } from "../../features/relay/contracts";
+const user = keypair();
+const channels: ChannelSummary[] = [
+ { id: "a", name: "Design" },
+ { id: "b", name: "B" },
+ {
+ id: "dm",
+ name: "DM",
+ channelType: "dm",
+ hidden: true,
+ participants: [user.pubkey],
+ },
+ { id: "hidden", name: "Hidden", hidden: true },
+ { id: "archived", name: "Archived", archived: true },
+];
+it("uses only visible joined channels and folded roots, with stable chronological ordering", () => {
+ const a = message(user, "a", "First", 1);
+ const b = message(user, "b", "Second", 2);
+ const events = [
+ a,
+ b,
+ message(user, "a", "Reply", 3, [["e", a.id, "", "reply"]]),
+ message(user, "hidden", "Secret", 4),
+ message(user, "archived", "Old", 5),
+ message(user, "unknown", "Unknown", 6),
+ ];
+ expect(visibleChannels(channels).map((c) => c.id)).toEqual(["a", "b", "dm"]);
+ expect(pulseRows(events, channels).map((row) => row.id)).toEqual([
+ b.id,
+ a.id,
+ ]);
+ expect(pulseRows([...events].reverse(), channels)).toEqual(
+ pulseRows(events, channels),
+ );
+});
+it("uses shared edit/delete semantics and preserves delivery uncertainty", () => {
+ const root = message(user, "a", "Before", 1);
+ const edit = signed(user, {
+ kind: 40003,
+ content: "After",
+ created_at: 2,
+ tags: [
+ ["h", "a"],
+ ["e", root.id],
+ ],
+ });
+ expect(pulseRows([root, edit], channels)[0]?.content).toBe("After");
+ expect(
+ pulseRows([root, { ...edit, delivery: "failed" }], channels)[0]?.content,
+ ).toBe("Before");
+ expect(
+ pulseRows(
+ [{ ...root, delivery: "unknown", error: "Timed out" }],
+ channels,
+ )[0],
+ ).toMatchObject({ delivery: "unknown", deliveryError: "Timed out" });
+ const deletion = signed(user, {
+ kind: 5,
+ content: "",
+ tags: [
+ ["h", "a"],
+ ["e", root.id],
+ ],
+ });
+ expect(pulseRows([root, edit, deletion], channels)).toEqual([]);
+ expect(
+ pulseRows(
+ [root],
+ channels.filter((c) => c.id !== "a"),
+ ),
+ ).toEqual([]);
+});
+it("For you uses exact notification identity or DM membership, not prose or invented unread status", () => {
+ const prose = message(user, "a", "@Reader please review", 3);
+ const mention = message(user, "b", "A request", 2, [["p", user.pubkey]]);
+ const dm = message(user, "dm", "Hello", 1);
+ const rows = pulseRows([prose, mention, dm], channels);
+ expect(
+ filterRows(rows, channels, new Map(), "for-you", "", user.pubkey).map(
+ (row) => row.id,
+ ),
+ ).toEqual([mention.id, dm.id]);
+ expect(
+ filterRows(rows, channels, new Map(), "search", "design").map(
+ (row) => row.id,
+ ),
+ ).toEqual([prose.id]);
+});
+it("groups one newest matching source per channel and searches current profile labels", () => {
+ const events = [
+ message(user, "a", "one", 1),
+ message(user, "a", "two", 2),
+ message(user, "dm", "hello", 3),
+ ];
+ const profiles = new Map([[user.pubkey, { name: "Alice" }]]);
+ const rows = pulseRows(events, channels);
+ expect(filterRows(rows, channels, profiles, "all", "")).toHaveLength(2);
+ expect(
+ filterRows(rows, channels, profiles, "search", "one")[0]?.content,
+ ).toBe("one");
+ expect(
+ channelLabel(
+ { id: "dm", name: "DM", channelType: "dm", participants: [user.pubkey] },
+ profiles,
+ ),
+ ).toBe("Alice");
+ expect(
+ channelLabel(
+ { id: "self", name: "Self", channelType: "dm", participants: [] },
+ profiles,
+ ),
+ ).toBe("Notes to self");
+});
diff --git a/src/bundled/pulse/feed.ts b/src/bundled/pulse/feed.ts
new file mode 100644
index 00000000..9c2aa7da
--- /dev/null
+++ b/src/bundled/pulse/feed.ts
@@ -0,0 +1,83 @@
+import type {
+ ChannelSummary,
+ ChannelMessage,
+ Profile,
+} from "../../features/relay/contracts";
+import type { VisibleEvent } from "../../features/relay/projection";
+import { foldMessages } from "../../features/relay/fold";
+
+export function visibleChannels(channels: readonly ChannelSummary[]) {
+ return channels.filter(
+ (channel) =>
+ !channel.archived && (!channel.hidden || channel.channelType === "dm"),
+ );
+}
+export function channelLabel(
+ channel: ChannelSummary,
+ profiles: ReadonlyMap,
+) {
+ if (channel.channelType !== "dm" || !channel.participants)
+ return channel.name;
+ return channel.participants.length
+ ? channel.participants
+ .map((id) => profiles.get(id)?.name ?? id.slice(0, 10))
+ .join(", ")
+ : "Notes to self";
+}
+/** Presentation over an owned host view. No independent retained cache or authority. */
+export function pulseRows(
+ events: readonly VisibleEvent[],
+ channels: readonly ChannelSummary[],
+) {
+ const usable = events.filter(
+ (event) =>
+ !([40003, 7].includes(event.kind) && event.delivery === "failed"),
+ );
+ const byId = new Map(events.map((event) => [event.id, event]));
+ return visibleChannels(channels)
+ .flatMap((channel) => {
+ // The session does not expose the relay signing identity. Do not infer it or
+ // trust an arbitrary 39005 author; the shared thread reader resolves replies.
+ const rows = foldMessages(channel.id, "", usable);
+ return rows.map((row) => ({
+ ...row,
+ delivery: byId.get(row.id)?.delivery,
+ deliveryError: byId.get(row.id)?.error,
+ }));
+ })
+ .sort((a, b) => b.createdAt - a.createdAt || a.id.localeCompare(b.id));
+}
+export function filterRows(
+ rows: readonly ChannelMessage[],
+ channels: readonly ChannelSummary[],
+ profiles: ReadonlyMap,
+ view: string,
+ search: string,
+ viewer?: string,
+) {
+ const byId = new Map(channels.map((channel) => [channel.id, channel]));
+ const seen = new Set();
+ return rows
+ .filter((row) => {
+ const channel = byId.get(row.channelId);
+ if (!channel) return false;
+ if (
+ view === "for-you" &&
+ !(
+ channel.channelType === "dm" ||
+ (viewer && row.mentions.includes(viewer))
+ )
+ )
+ return false;
+ const text = `${row.content} ${channelLabel(channel, profiles)} ${profiles.get(row.authorId)?.name ?? ""}`;
+ if (
+ view === "search" &&
+ !text.toLowerCase().includes(search.trim().toLowerCase())
+ )
+ return false;
+ if (seen.has(row.channelId)) return false;
+ seen.add(row.channelId);
+ return true;
+ })
+ .slice(0, 30);
+}
diff --git a/src/bundled/pulse/index.tsx b/src/bundled/pulse/index.tsx
new file mode 100644
index 00000000..19f20d50
--- /dev/null
+++ b/src/bundled/pulse/index.tsx
@@ -0,0 +1,16 @@
+import type { PluginModule } from "../../plugins/api";
+import { PulsePage } from "./PulsePage";
+export const inject = ["pages", "relay", "conversation"];
+export const apply: PluginModule["apply"] = (ctx) => {
+ const relay = ctx.relay;
+ const extensions = ctx.conversation;
+ ctx.pages.register({
+ id: "pulse",
+ title: "Pulse",
+ layout: "workspace",
+ companion: true,
+ component: ({ companion }) => (
+
+ ),
+ });
+};
diff --git a/src/bundled/pulse/manifest.json b/src/bundled/pulse/manifest.json
new file mode 100644
index 00000000..3ec7b5d0
--- /dev/null
+++ b/src/bundled/pulse/manifest.json
@@ -0,0 +1 @@
+{ "id": "buzz.pulse", "name": "Pulse", "apiVersion": 1 }
diff --git a/src/bundled/pulse/usePulseFeed.ts b/src/bundled/pulse/usePulseFeed.ts
new file mode 100644
index 00000000..35fe9d0d
--- /dev/null
+++ b/src/bundled/pulse/usePulseFeed.ts
@@ -0,0 +1,105 @@
+import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
+import type {
+ RelaySession,
+ EventViewSnapshot,
+} from "../../features/relay/session";
+import type { ChannelSummary } from "../../features/relay/contracts";
+import { selectProfiles } from "../../features/relay/profile-selection";
+import { pulseRows, visibleChannels } from "./feed";
+const empty: EventViewSnapshot = { status: "idle", events: [] };
+const noop = () => () => {};
+
+export function usePulseFeed(
+ session: RelaySession,
+ roster: readonly ChannelSummary[],
+ ready: boolean,
+) {
+ const key = visibleChannels(roster)
+ .map((channel) => channel.id)
+ .sort()
+ .join("\n");
+ const [owned, setOwned] = useState<{
+ key: string;
+ view: ReturnType;
+ }>();
+ const [error, setError] = useState();
+ const [attempt, setAttempt] = useState(0);
+ // Allocate only after roster authority and in an effect: StrictMode/unload cannot leak handles.
+ // biome-ignore lint/correctness/useExhaustiveDependencies: attempt retries allocation failures.
+ useEffect(() => {
+ if (!ready || !key) return;
+ try {
+ const view = session.observe([
+ {
+ kinds: [9, 40002, 40003, 5, 9005, 7],
+ "#h": key.split("\n"),
+ limit: 200,
+ top_level: true,
+ include_aux: true,
+ },
+ ]);
+ setError(undefined);
+ setOwned({ key, view });
+ void view.refresh();
+ return () => view.dispose();
+ } catch (cause) {
+ setError(String(cause));
+ }
+ }, [session, key, ready, attempt]);
+ const view = ready && owned?.key === key ? owned.view : undefined;
+ const snapshot = useSyncExternalStore(
+ view?.subscribe ?? noop,
+ view?.snapshot ?? (() => empty),
+ view?.snapshot ?? (() => empty),
+ );
+ const rows = useMemo(
+ () => pulseRows(snapshot.events, roster),
+ [snapshot.events, roster],
+ );
+ const profileKey = [
+ ...new Set([
+ ...rows.slice(0, 200).map((row) => row.authorId),
+ ...visibleChannels(roster).flatMap(
+ (channel) => channel.participants ?? [],
+ ),
+ ]),
+ ]
+ .sort()
+ .slice(0, 1024)
+ .join(":");
+ const selection = useMemo(
+ () =>
+ selectProfiles(session.profiles, profileKey ? profileKey.split(":") : []),
+ [session, profileKey],
+ );
+ const profiles = useSyncExternalStore(
+ selection.subscribe,
+ selection.snapshot,
+ selection.snapshot,
+ );
+ const missing = profileKey
+ .split(":")
+ .filter((id) => id && !profiles.has(id))
+ .join(":");
+ // Include the whole roster identity: even hidden-channel revocation can purge shared profiles.
+ const membership = roster
+ .map((channel) => channel.id)
+ .sort()
+ .join("\n");
+ useEffect(() => {
+ if (membership && missing)
+ void session.profiles
+ .ensure(missing.split(":"), "background")
+ .catch(() => {});
+ }, [session, missing, membership]);
+ return {
+ rows,
+ profiles,
+ status: snapshot.status,
+ error: error ?? snapshot.error,
+ refresh: () => {
+ if (view) void view.refresh();
+ else setAttempt((value) => value + 1);
+ },
+ };
+}
diff --git a/tests/browser/pulse-lifecycle.spec.mjs b/tests/browser/pulse-lifecycle.spec.mjs
new file mode 100644
index 00000000..3a694458
--- /dev/null
+++ b/tests/browser/pulse-lifecycle.spec.mjs
@@ -0,0 +1,81 @@
+import { test, expect } from "./fixture.mjs";
+const pulse = (page) =>
+ page
+ .getByRole("navigation", { name: "Pages", exact: true })
+ .getByRole("button", { name: "Pulse", exact: true });
+
+test("Pulse opens shared threads, follows edits, purges revoked sources and unloads independently", async ({
+ page,
+ app,
+}, testInfo) => {
+ // Only model the upstream root/traversal response; the thread owner, composer,
+ // verification and application remain real. Publication is not exercised here.
+ await page.route("**/api/relay/primary/query", async (route) => {
+ const filters = route.request().postDataJSON();
+ if (
+ filters.some((filter) => filter.ids || filter.depth_limit !== undefined)
+ ) {
+ const root = app.histories.get("primary/alpha").at(-1);
+ const events = filters.some((filter) => filter.ids?.includes(root.id))
+ ? [root]
+ : [];
+ return route.fulfill({ json: events });
+ }
+ return route.continue();
+ });
+ await page.goto(app.origin);
+ await pulse(page).click();
+ await expect(
+ page.getByRole("article", { name: "Activity in Alpha" }),
+ ).toBeVisible();
+ await page.getByRole("button", { name: "Open thread / reply" }).click();
+ const thread = page.getByRole("complementary", {
+ name: "Thread",
+ exact: true,
+ });
+ await expect(thread).toBeVisible();
+ const reply = thread.getByRole("textbox", {
+ name: "Reply to thread",
+ exact: true,
+ });
+ await reply.fill("A separate reply draft");
+ await page.screenshot({ path: testInfo.outputPath("pulse-thread.png") });
+ await page.getByRole("button", { name: "Close thread", exact: true }).click();
+ await expect(
+ page.getByRole("textbox", { name: "Message #Alpha" }),
+ ).toHaveValue("");
+ await page.getByRole("button", { name: "Back to Pulse" }).click();
+ const root = app.histories.get("primary/alpha").at(-1);
+ app.edit("primary", "alpha", root, "An updated Pulse source");
+ await expect(
+ page.getByRole("article", { name: "Activity in Alpha" }),
+ ).toContainText("An updated Pulse source");
+ await page.getByRole("button", { name: "Open thread / reply" }).click();
+ await expect(reply).toHaveValue("A separate reply draft");
+ await page.getByRole("button", { name: "Back to Pulse" }).click();
+ app.omitChannel("alpha");
+ await page.getByRole("button", { name: "Refresh Pulse" }).click();
+ await expect(
+ page
+ .getByRole("navigation", { name: "Pulse conversations" })
+ .getByRole("button", { name: "Alpha" }),
+ ).toHaveCount(0);
+ await expect(
+ page.getByRole("article", { name: "Activity in Alpha" }),
+ ).toHaveCount(0);
+ await page.getByRole("button", { name: "Your profile" }).click();
+ await page.getByRole("button", { name: "Settings", exact: true }).click();
+ await page.getByRole("button", { name: "Plugins", exact: true }).click();
+ await page.getByRole("switch", { name: "Enable Pulse", exact: true }).click();
+ await expect(pulse(page)).toHaveCount(0);
+ await page.getByRole("switch", { name: "Enable Pulse", exact: true }).click();
+ await pulse(page).click();
+ await expect(
+ page.getByRole("article", { name: "Activity in Alpha" }),
+ ).toHaveCount(0);
+ await expect(
+ page
+ .getByRole("navigation", { name: "Pulse conversations" })
+ .getByRole("button", { name: "Beta" }),
+ ).toBeVisible();
+});
diff --git a/tests/browser/pulse.spec.mjs b/tests/browser/pulse.spec.mjs
new file mode 100644
index 00000000..3f104ce5
--- /dev/null
+++ b/tests/browser/pulse.spec.mjs
@@ -0,0 +1,79 @@
+import { test, expect } from "./fixture.mjs";
+const pulse = (page) =>
+ page
+ .getByRole("navigation", { name: "Pages", exact: true })
+ .getByRole("button", { name: "Pulse", exact: true });
+const view = (page, name) =>
+ page
+ .getByRole("navigation", { name: "Pulse views" })
+ .getByRole("button", { name, exact: true });
+
+test("Pulse reads shared activity, searches, opens a conversation and retains scoped drafts", async ({
+ page,
+ app,
+}, testInfo) => {
+ await page.goto(app.origin);
+ await pulse(page).click();
+ await expect(
+ page.getByRole("article", { name: "Activity in Alpha" }),
+ ).toBeVisible();
+ await page.screenshot({ path: testInfo.outputPath("pulse-light.png") });
+ await view(page, "Search").click();
+ const search = page.getByRole("searchbox", {
+ name: "Search recent Pulse activity",
+ });
+ await search.fill("nothing-matches-this");
+ await expect(
+ page.getByText("No recent conversations match that search."),
+ ).toBeVisible();
+ await search.fill("message 639");
+ await expect(page.getByRole("article")).toHaveCount(1);
+ const before = performance.now();
+ await page.getByRole("button", { name: /Open conversation ↗/ }).click();
+ await expect(
+ page.getByRole("region", { name: "Channel message history" }),
+ ).toBeVisible();
+ app.report.measurements.push({
+ name: "pulse-cold-open",
+ durationMs: performance.now() - before,
+ });
+ const draft = page.getByRole("textbox", { name: "Message #Alpha" });
+ await draft.fill("Keep this Pulse draft");
+ await page.getByRole("button", { name: "Back to Pulse" }).click();
+ const warm = performance.now();
+ await page.getByRole("button", { name: /Open conversation ↗/ }).click();
+ await expect(draft).toHaveValue("Keep this Pulse draft");
+ app.report.measurements.push({
+ name: "pulse-warm-open",
+ durationMs: performance.now() - warm,
+ });
+ await page.screenshot({
+ path: testInfo.outputPath("pulse-conversation.png"),
+ });
+ await page.getByRole("button", { name: "Back to Pulse" }).click();
+ await view(page, "All messages").click();
+ await page.getByRole("button", { name: "Bestie", exact: true }).click();
+ await expect(
+ page.getByRole("complementary", { name: "Bestie", exact: true }),
+ ).toBeVisible();
+ await page.getByRole("button", { name: "Close Bestie panel" }).click();
+ await page.getByRole("button", { name: "Your profile" }).click();
+ await page.getByRole("button", { name: "Settings", exact: true }).click();
+ await page.getByRole("button", { name: "Appearance", exact: true }).click();
+ await page.getByRole("radio", { name: "Dark", exact: true }).check();
+ await pulse(page).click();
+ await page.screenshot({ path: testInfo.outputPath("pulse-dark.png") });
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.screenshot({ path: testInfo.outputPath("pulse-mobile.png") });
+ expect(
+ await page.evaluate(
+ () => document.documentElement.scrollWidth <= innerWidth,
+ ),
+ ).toBe(true);
+ await view(page, "For you").click();
+ await expect(
+ page.getByText(
+ "No direct conversations or mentions in this recent window.",
+ ),
+ ).toBeVisible();
+});
From 2498ec8de8700a6f1d2663cc8234cbe3de3dd399 Mon Sep 17 00:00:00 2001
From: pic-worker-aa352576c7
<6838876f3610dc213d19edeb1cdf5a8d0a0b568dc9b40db674ade16725074cce@buzz.block.builderlab.xyz>
Date: Fri, 11 Sep 2026 18:27:36 -0700
Subject: [PATCH 4/7] Refine Pulse presentation and preserve current shared
messaging APIs
Recover the existing design pass, retain the single-channel relay window contract, and combine bubble presentation with main Markdown, profiles, and reading behavior.
Co-authored-by: Fizz <400e8babadcee6a7f420103f10a2849d84c4a9c71d5bd04f3948c814216648a3@buzz.block.builderlab.xyz>
Signed-off-by: pic-worker-aa352576c7 <6838876f3610dc213d19edeb1cdf5a8d0a0b568dc9b40db674ade16725074cce@buzz.block.builderlab.xyz>
---
src/bundled/pulse/Pulse.module.css | 191 ++++-----
src/bundled/pulse/PulseConversation.tsx | 127 +++---
src/bundled/pulse/PulsePage.tsx | 388 ++++++++++--------
src/bundled/pulse/README.md | 34 +-
src/bundled/pulse/usePulseFeed.ts | 11 +-
src/features/messages/ChannelTimeline.tsx | 13 +-
.../messages/MessageRow.presentation.test.tsx | 56 +++
src/features/messages/MessageRow.tsx | 96 +++--
src/features/messages/Messages.module.css | 71 ++++
src/features/messages/ThreadPanel.tsx | 18 +-
tests/browser/layout.spec.mjs | 22 +-
tests/browser/pulse-design.spec.mjs | 141 +++++++
tests/browser/pulse.spec.mjs | 16 +
13 files changed, 808 insertions(+), 376 deletions(-)
create mode 100644 src/features/messages/MessageRow.presentation.test.tsx
create mode 100644 tests/browser/pulse-design.spec.mjs
diff --git a/src/bundled/pulse/Pulse.module.css b/src/bundled/pulse/Pulse.module.css
index 78d177d2..2e428a42 100644
--- a/src/bundled/pulse/Pulse.module.css
+++ b/src/bundled/pulse/Pulse.module.css
@@ -1,4 +1,4 @@
-/* Page-owned selector trees are disjoint; no host or shared message overrides. */
+/* Pulse owns layout only; bubble rendering belongs to the shared MessageRow. */
/* biome-ignore-all lint/style/noDescendingSpecificity: Selectors belong to disjoint Pulse regions. */
.root {
height: 100%;
@@ -13,7 +13,6 @@
overflow: hidden;
border-radius: var(--radius-card);
background: var(--surface);
- box-shadow: var(--elevation-card);
color: var(--text);
}
.sidebar {
@@ -22,16 +21,9 @@
display: flex;
flex-direction: column;
min-height: 0;
- padding: 24px 12px 12px;
+ padding: 8px;
border-right: 1px solid var(--border);
}
-.brand {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 0 12px 24px;
- font-size: 18px;
-}
.sidebar nav button {
border: 0;
box-shadow: none;
@@ -39,18 +31,19 @@
display: flex;
align-items: center;
text-align: left;
- gap: 10px;
+ gap: 12px;
background: transparent;
- padding: 10px 12px;
- font-size: 13px;
+ padding: 12px;
+ margin-bottom: 4px;
+ font-size: 14px;
border-radius: var(--radius-control);
}
.sidebar nav button:hover {
background: var(--surface-hover);
}
.sidebar nav button[aria-current="page"] {
- background: var(--selected);
- color: var(--on-selected);
+ background: var(--surface-control);
+ font-weight: 600;
}
.navIcon {
display: grid;
@@ -58,58 +51,60 @@
border-radius: 50%;
width: 28px;
height: 28px;
+ flex-shrink: 0;
background: var(--surface-control);
- color: var(--text);
-}
-.sidebarLabel {
- margin: 28px 12px 12px;
color: var(--text-muted);
font-size: 10px;
- letter-spacing: 0.08em;
- font-weight: 600;
+}
+.sidebarDivider {
+ margin: 8px 0;
+ border-top: 1px solid var(--border);
}
.channelSearch {
min-width: 0;
width: 100%;
font-size: 12px;
margin-bottom: 8px;
+ padding: 8px 12px;
+ border: 0;
+ background: var(--surface-input);
}
.channelList {
min-height: 0;
flex: 1;
overflow: auto;
+ scrollbar-width: thin;
}
-.channelList button span {
+.channelList button > span:last-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
-.channelList svg {
- flex-shrink: 0;
-}
-.sidebarFoot {
- font-size: 11px;
- line-height: 1.6;
+.sidebarFoot,
+.sidebarError {
+ font-size: 12px;
+ line-height: 1.5;
color: var(--text-muted);
padding: 12px;
margin: 0;
-}
-.sidebarError {
- font-size: 12px;
overflow-wrap: anywhere;
}
-.main {
+.main,
+.feedContent {
min-width: 0;
min-height: 0;
flex: 1;
display: flex;
flex-direction: column;
}
+.feedContent[hidden] {
+ display: none;
+}
.feedHeading {
display: flex;
justify-content: space-between;
align-items: center;
- padding: 36px 32px 8px;
+ padding: 8px 28px;
gap: 12px;
}
.feedHeading button {
@@ -118,30 +113,23 @@
padding: 8px;
}
.feedHeading h1 {
- margin: 8px 0;
- font-size: 30px;
+ margin: 0;
+ font-size: 14px;
font-weight: 500;
- letter-spacing: -0.04em;
-}
-.eyebrow {
- color: var(--text-muted);
- font-size: 10px;
- font-weight: 600;
- letter-spacing: 0.08em;
}
.intro {
- padding: 0 32px 24px;
+ padding: 0 28px 16px;
color: var(--text-muted);
- font-size: 13px;
- line-height: 1.6;
+ font-size: 12px;
+ line-height: 1.5;
+ border-bottom: 1px solid var(--border);
}
.search {
display: flex;
align-items: center;
gap: 8px;
- margin: 0 32px 16px;
- background: var(--surface-input);
- border: 1px solid var(--border-input);
+ margin: 20px 28px 8px;
+ background: var(--surface-control);
border-radius: var(--radius-control);
padding: 0 12px;
}
@@ -156,38 +144,58 @@
min-height: 0;
overflow: auto;
overflow-anchor: auto;
+ scrollbar-width: thin;
+}
+.latest {
+ position: sticky;
+ top: 8px;
+ display: block;
+ margin: 8px auto;
+ border-radius: 24px;
+ font-size: 12px;
+ background: var(--surface-elevated);
+ box-shadow: var(--elevation-card);
+ z-index: 1;
}
.card {
- padding: 20px 16px 16px;
- border-top: 1px solid var(--border);
+ padding: 0 28px;
+ border-bottom: 1px solid var(--border);
}
.source {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- justify-content: space-between;
- width: 100%;
- text-align: left;
+ max-width: 200px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
border: 0;
background: transparent;
color: var(--text-muted);
- font-size: 11px;
- padding: 0 12px 8px;
+ font-size: 12px;
+ padding: 0;
}
-.source span:last-child {
- font-size: 10px;
+.cardActions {
+ display: flex;
+ justify-content: space-between;
+ gap: 20px;
+ margin-top: 8px;
}
.reply {
- display: flex;
+ display: inline-flex;
gap: 8px;
align-items: center;
+ justify-content: center;
border: 0;
- background: var(--surface-control);
+ background: transparent;
color: var(--text-muted);
- font-size: 11px;
+ font-size: 12px;
border-radius: 24px;
- margin: 12px 0 0 56px;
- padding: 7px 12px;
+ min-width: 32px;
+ min-height: 32px;
+ padding: 4px 8px;
+}
+.reply:hover,
+.source:hover {
+ background: var(--surface-hover);
+ color: var(--text);
}
.empty,
.connect {
@@ -200,7 +208,7 @@
color: var(--text-muted);
}
.empty h2 {
- font-size: 18px;
+ font-size: 16px;
color: var(--text);
font-weight: 500;
}
@@ -216,7 +224,7 @@
background: var(--surface);
}
.connect h1 {
- font-size: 30px;
+ font-size: 24px;
color: var(--text);
font-weight: 500;
}
@@ -254,6 +262,7 @@
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
+ font-weight: 500;
}
.heading button {
display: grid;
@@ -261,20 +270,28 @@
border: 0;
background: transparent;
}
+.threadHost {
+ display: grid;
+ grid-template-rows: minmax(0, 1fr);
+ flex: 1;
+ min-height: 0;
+}
@media (max-width: 700px) {
.sidebar {
- width: 160px;
- flex-basis: 160px;
- padding: 16px 8px 8px;
+ width: 180px;
+ flex-basis: 180px;
}
.feedHeading {
- padding: 24px 20px 8px;
+ padding: 16px 20px 0;
}
.intro {
- padding: 0 20px 20px;
+ padding: 0 20px 16px;
}
.search {
- margin: 0 20px 16px;
+ margin: 16px 20px 8px;
+ }
+ .card {
+ padding: 0 20px;
}
}
@media (max-width: 520px) {
@@ -293,41 +310,27 @@
}
.sidebar nav:first-of-type button {
justify-content: center;
- gap: 4px;
+ gap: 6px;
font-size: 12px;
- padding: 6px;
+ padding: 8px 4px;
}
- .brand,
- .sidebarLabel,
- .sidebarFoot,
- .channelSearch {
+ .sidebarDivider,
+ .sidebarFoot {
display: none;
}
.channelList {
display: flex;
- max-height: 44px;
+ max-height: 48px;
}
.channelList button {
flex: 0 0 auto;
max-width: 160px;
+ padding: 8px 12px;
}
.reading .sidebar {
display: none;
}
- .feedHeading {
- padding-top: 20px;
- }
- .eyebrow {
- font-size: 9px;
- }
.card {
- padding: 16px 8px;
+ padding: 0 16px;
}
}
-
-.threadHost {
- display: grid;
- grid-template-rows: minmax(0, 1fr);
- flex: 1;
- min-height: 0;
-}
diff --git a/src/bundled/pulse/PulseConversation.tsx b/src/bundled/pulse/PulseConversation.tsx
index 4cc31c3e..09b78e95 100644
--- a/src/bundled/pulse/PulseConversation.tsx
+++ b/src/bundled/pulse/PulseConversation.tsx
@@ -8,26 +8,28 @@ import { MessageComposer } from "../../features/messages/MessageComposer";
import { ThreadPanel } from "../../features/messages/ThreadPanel";
import styles from "./Pulse.module.css";
const openLink = () => false;
+type ConversationProps = {
+ session: RelaySession;
+ viewer?: string | undefined;
+ scope: string;
+ channelId: string;
+ name: string;
+ extensions?: ConversationExtensions | undefined;
+ back(): void;
+ initialThread?: string | undefined;
+};
+
export function PulseConversation({
session,
+ viewer,
scope,
channelId,
name,
extensions,
back,
initialThread,
-}: {
- session: RelaySession;
- scope: string;
- channelId: string;
- name: string;
- extensions?: ConversationExtensions | undefined;
- back(): void;
- initialThread?: string | undefined;
-}) {
- const window = useChannelWindow(session.channels, channelId);
+}: ConversationProps) {
const [thread, setThread] = useState(initialThread);
- const [sent, setSent] = useState();
return (
@@ -39,6 +41,8 @@ export function PulseConversation({
{thread ? (
) : (
- <>
- {window.status === "error" && !window.rows.length ? (
-
-
{window.error}
-
-
- ) : window.status !== "ready" && !window.rows.length ? (
-
- Loading messages…
-
- ) : (
-
- )}
-
- >
+
)}
);
}
+
+// Direct thread entry owns only the thread reader; do not also open a channel window.
+function PulseChannelMessages({
+ session,
+ viewer,
+ scope,
+ channelId,
+ name,
+ extensions,
+ onOpenThread,
+}: Omit & {
+ onOpenThread(id: string): void;
+}) {
+ const window = useChannelWindow(session.channels, channelId);
+ const [sent, setSent] = useState();
+ return (
+ <>
+ {window.status === "error" && !window.rows.length ? (
+
+
{window.error}
+
+
+ ) : window.status !== "ready" && !window.rows.length ? (
+
+ Loading messages…
+
+ ) : (
+
+ )}
+
+ >
+ );
+}
diff --git a/src/bundled/pulse/PulsePage.tsx b/src/bundled/pulse/PulsePage.tsx
index 8632fdbb..26698369 100644
--- a/src/bundled/pulse/PulsePage.tsx
+++ b/src/bundled/pulse/PulsePage.tsx
@@ -1,4 +1,4 @@
-import { useMemo, useRef, useState, type ReactNode } from "react";
+import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import {
Hash,
Inbox,
@@ -6,6 +6,8 @@ import {
Search,
Sparkles,
RefreshCw,
+ ArrowUpRight,
+ Layers,
} from "lucide-react";
import type { RelayData } from "../../features/relay/service";
import type { RelaySession } from "../../features/relay/session";
@@ -23,8 +25,8 @@ type View = "for-you" | "all" | "search";
type Selection = { channelId: string; thread?: string | undefined };
const views = [
{ id: "search", title: "Search", icon: Search },
- { id: "for-you", title: "For you", icon: Sparkles },
- { id: "all", title: "All messages", icon: MessageCircle },
+ { id: "for-you", title: "For you", icon: Inbox },
+ { id: "all", title: "All messages", icon: Layers },
] as const;
export function PulsePage({
relay,
@@ -98,11 +100,12 @@ function PulseWorkspace({
const current = channels.find(
(channel) => channel.id === selected?.channelId,
);
- const open = (channelId: string, thread?: string) => {
+ const open = (channelId: string, thread?: string, source?: HTMLElement) => {
trigger.current =
- document.activeElement instanceof HTMLElement
+ source ??
+ (document.activeElement instanceof HTMLElement
? document.activeElement
- : null;
+ : null);
setSelected({ channelId, thread });
writeView(scope, "pulse-channel", channelId);
};
@@ -116,13 +119,37 @@ function PulseWorkspace({
const switchView = (next: View) => {
setView(next);
writeView(scope, "pulse-view", next);
- back();
+ setSelected(undefined);
+ writeView(scope, "pulse-channel", null);
};
const rows = useMemo(
() =>
filterRows(feed.rows, list.channels, feed.profiles, view, search, viewer),
[feed.rows, list.channels, feed.profiles, view, search, viewer],
);
+ const scroll = useRef(null);
+ // Freeze channel order while reading; content edits and revoked sources stay live.
+ const [order, setOrder] = useState([]);
+ const rowChannels = rows.map((row) => row.channelId).join("\n");
+ useEffect(() => {
+ if (!scroll.current || scroll.current.scrollTop <= 24)
+ setOrder(rowChannels ? rowChannels.split("\n") : []);
+ }, [rowChannels]);
+ const ranked = new Map(order.map((id, index) => [id, index]));
+ const orderedRows = [...rows].sort(
+ (a, b) =>
+ (ranked.get(a.channelId) ?? 999) - (ranked.get(b.channelId) ?? 999),
+ );
+ const pendingOrder = order.join("\n") !== rowChannels;
+ const acceptLatest = () => {
+ setOrder(rows.map((row) => row.channelId));
+ scroll.current?.scrollTo({ top: 0 });
+ };
+ const matchingChannels = channels.filter((channel) =>
+ channelLabel(channel, feed.profiles)
+ .toLowerCase()
+ .includes(channelSearch.toLowerCase()),
+ );
const title = views.find((item) => item.id === view)?.title;
const loading =
list.status === "loading" ||
@@ -133,10 +160,6 @@ function PulseWorkspace({
return (
- {current ? (
+ {current && (
- ) : (
- <>
-
-
-
- YOUR COMMUNITY, AT A GLANCE
-
-
{title}
-
+ )}
+
+
+
+
{title}
+
+
+
+
+ {view === "for-you"
+ ? "Recent mentions and direct conversations"
+ : view === "search"
+ ? "Search the recent activity loaded here"
+ : "Recent conversations across your community"}
+
+ {view === "search" && (
+
+ )}
+
+ {pendingOrder && (
-
-
- {view === "for-you"
- ? "Your direct conversations and messages that mention you. Original excerpts, not AI summaries."
- : view === "search"
- ? "Find something in the recent activity loaded here."
- : "A quieter way to catch up. Open a conversation and pick up where it left off."}
-
- {view === "search" && (
-
)}
-
- {(feed.error || list.status === "error") && (
-
-
- Some activity couldn’t be loaded. This feed may be out of
- date.
-
-
-
- )}
- {loading && !rows.length ? (
-
- Gathering recent conversations…
+ {(feed.error || list.status === "error") && (
+
+
+ Some activity couldn’t be loaded. This feed may be out of
+ date.
- ) : rows.length ? (
- rows.map((row) => {
- const channel = channels.find(
- (item) => item.id === row.channelId,
- );
- if (!channel) return null;
- return (
-
-
+ )}
+ {loading && !rows.length ? (
+
+ Gathering recent conversations…
+
+ ) : rows.length ? (
+ orderedRows.map((row) => {
+ const channel = channels.find(
+ (item) => item.id === row.channelId,
+ );
+ if (!channel) return null;
+ return (
+
+
+ open(channel.id, undefined, event.currentTarget)
+ }
+ >
{channel.channelType === "dm"
- ? "Direct conversation"
- : `# ${channelLabel(channel, feed.profiles)}`}
-
- Open conversation ↗
-
- false}
- />
- open(channel.id, row.id)}
- >
-
- Open thread / reply
-
-
- );
- })
- ) : (
-
-
-
A little quiet here
-
- {search
- ? "No recent conversations match that search."
- : view === "for-you"
- ? "No direct conversations or mentions in this recent window."
- : "Recent conversations will appear here."}
-
-
- )}
-
-
- >
- )}
+ ? `DM · ${channelLabel(channel, feed.profiles)}`
+ : `#${channelLabel(channel, feed.profiles)}`}
+
+ }
+ footer={
+
+
+ open(channel.id, row.id, event.currentTarget)
+ }
+ >
+
+ Reply
+
+
+ open(channel.id, undefined, event.currentTarget)
+ }
+ >
+
+
+
+ }
+ profile={feed.profiles.get(row.authorId)}
+ participantProfiles={feed.profiles}
+ media={session.media}
+ extensions={extensions}
+ day={false}
+ retry={
+ session.outbox ? session.messages.retry : undefined
+ }
+ onOpenLink={() => false}
+ />
+
+ );
+ })
+ ) : (
+
+
+
A little quiet here
+
+ {search
+ ? "No recent conversations match that search."
+ : view === "for-you"
+ ? "No direct conversations or mentions in this recent window."
+ : "Recent conversations will appear here."}
+
+
+ )}
+
+
+
);
diff --git a/src/bundled/pulse/README.md b/src/bundled/pulse/README.md
index bead257e..c1dc7734 100644
--- a/src/bundled/pulse/README.md
+++ b/src/bundled/pulse/README.md
@@ -2,7 +2,7 @@
A bundled **source page plugin**, independently toggleable in Settings → Plugins.
This is not a self-contained external API-v1 install artifact. Run this branch of
-buzz-app and choose **Pulse** in the top navigation. Messages is unchanged.
+buzz-app and choose **Pulse** in the top navigation. Messages keeps its default row presentation.
## Design reference
@@ -27,8 +27,10 @@ Adapted to buzz-app:
No new socket, cache, signer, outbox, polling loop, or model-provider connection.
- Reuses `ChannelTimeline`, `MessageRow`, `MessageComposer`, `ThreadPanel` and
`PanelFrame`. Emoji/Mentions remain optional conversation contributions.
- Shared message styling is retained rather than copied or overridden with
- prototype bubble styles. Links keep ordinary external navigation in this pass;
+ An optional shared `presentation="bubbles"` prop carries the prototype's
+ directional bubbles and bottom-aligned avatars without a second message renderer.
+ Main's Markdown, safe attachments, profile-link props, and authoritative
+ timeline/thread reading hooks remain intact. Links keep ordinary external navigation in this pass;
only the host companion dock is embedded, not a second local object dock.
- Session-owned state remounts by scope **and** generation. Navigation and drafts
persist under stable scope, not generation. The full roster's membership key
@@ -39,13 +41,20 @@ Adapted to buzz-app:
**For you is not an AI briefing.** It filters recent original top-level posts to
DMs and exact `p`-tag mentions of the viewer. It does not infer requests from prose,
agent runtime status or unread state. No provider receives messages. buzz-app does
-not currently expose a shared summarization or read-marker capability.
+not currently expose a shared summarization capability. Pulse does not invent
+unread badges; the shared conversation readers retain main's real reading behavior.
The feed requests 200 recent events across the visible joined roster and shows at
-most 30 conversations, one newest matching root each. Host retained-view limits
-still apply. Busy channels may dominate the window; search covers retained activity,
+most 30 conversations, one newest matching root each. This is an ordinary multi-channel
+query: replies and auxiliary events consume that window, and roots are selected locally
+by the shared fold. It does not use the relay's single-channel `top_level` / `include_aux`
+window extension. Edits/deletes outside retained activity may be absent from excerpts;
+open the conversation for its authoritative channel-window read. The 200-event limit
+bounds the request, not the lifetime retained view: the host bounds retained remote
+evidence to 2,000 events / 8 MiB (`features/relay/projection.ts`). Busy channels may dominate the window; search covers retained activity,
not full history. The channel rail remains roster-ordered, not a fabricated activity
-ranking. A partial roster is labeled. Hidden non-DM and archived channels are omitted.
+ranking. It renders the authorized roster directly (not a virtualized rail); feed
+rendering is capped at 30 groups and conversation history uses shared virtualization. A partial roster is labeled. Hidden non-DM and archived channels are omitted.
Author edits/deletes use the shared fold. Delivery uncertainty remains visible.
Feed rows deliberately do not interpret relay thread summaries: the session does
@@ -59,16 +68,21 @@ selection and feed search do not become new URL routes.
From this worktree:
```sh
-bin/just web
+bin/pnpm install --frozen-lockfile
+bin/pnpm dev --host 127.0.0.1 --port 1432 --strictPort
```
-For actual community data, use the repository's existing opt-in live setup in
-[README](../../../../README.md#relay-channels), then `BUZZ_LIVE=1 bin/just web`.
+Check that 1432 is free first; leave existing 1430/1431 servers alone. For actual
+community data, use the repository's public `BUZZ_DEV_VIEWER` pin setup in
+[README](../../../../README.md#relay-channels). Main now enables the development
+broker from that pin; `BUZZ_LIVE=1` is no longer required.
No identity configuration is added by this plugin. Packaged login and native
acceptance remain the host's existing limitations.
## Checks
+- `MessageRow.presentation.test.tsx`: both row/bubble content and actions, explicit
+ viewer direction; upstream MessageRow tests remain separate and unchanged.
- `feed.test.ts`: visible sources, deterministic grouping, author edits/deletes,
failed-edit rollback, exact mentions, bounded search and DM names.
- App composition test: contribution registration, disable and shared-session lifetime.
diff --git a/src/bundled/pulse/usePulseFeed.ts b/src/bundled/pulse/usePulseFeed.ts
index 35fe9d0d..96d323f6 100644
--- a/src/bundled/pulse/usePulseFeed.ts
+++ b/src/bundled/pulse/usePulseFeed.ts
@@ -33,9 +33,10 @@ export function usePulseFeed(
{
kinds: [9, 40002, 40003, 5, 9005, 7],
"#h": key.split("\n"),
+ // Multi-channel activity uses ordinary filters. The relay's top_level /
+ // include_aux window extension requires exactly one channel; the shared
+ // fold selects roots and applies any retained author edits/deletes here.
limit: 200,
- top_level: true,
- include_aux: true,
},
]);
setError(undefined);
@@ -59,9 +60,9 @@ export function usePulseFeed(
const profileKey = [
...new Set([
...rows.slice(0, 200).map((row) => row.authorId),
- ...visibleChannels(roster).flatMap(
- (channel) => channel.participants ?? [],
- ),
+ ...visibleChannels(roster)
+ .filter((channel) => channel.channelType === "dm")
+ .flatMap((channel) => channel.participants ?? []),
]),
]
.sort()
diff --git a/src/features/messages/ChannelTimeline.tsx b/src/features/messages/ChannelTimeline.tsx
index 5f6497fe..c168e4b8 100644
--- a/src/features/messages/ChannelTimeline.tsx
+++ b/src/features/messages/ChannelTimeline.tsx
@@ -3,7 +3,7 @@ import type { ConversationExtensions } from "../conversation/contracts";
import type { RelaySession } from "../relay/session";
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Virtualizer, type VirtualizerHandle } from "virtua";
-import { MessageRow } from "./MessageRow";
+import { MessageRow, type MessagePresentation } from "./MessageRow";
import type { ChannelWindow } from "../relay/contracts";
import { useRowProfiles } from "../relay/react";
import { geometryFor, geometrySignature } from "./geometry";
@@ -60,7 +60,7 @@ function positionAt(
: {}),
};
}
-export type ChannelTimelineProps = {
+export type ChannelTimelineProps = MessagePresentation & {
extensions?: ConversationExtensions | undefined;
channelId: string;
scope: string;
@@ -83,6 +83,8 @@ export function ChannelTimeline(props: ChannelTimelineProps) {
}
function Timeline({
channelId,
+ presentation,
+ viewer,
extensions,
scope,
queries,
@@ -101,8 +103,9 @@ function Timeline({
const profiles = useRowProfiles(queries.profiles, rows);
const geometry = useMemo(() => geometryFor(queries.channels), [queries]);
const signature = useMemo(
- () => geometrySignature(rows, profiles),
- [rows, profiles],
+ () =>
+ `${presentation ?? "rows"}:${viewer ?? ""}:${geometrySignature(rows, profiles)}`,
+ [rows, profiles, presentation, viewer],
);
const scroller = useRef(null);
const handle = useRef(null);
@@ -358,6 +361,8 @@ function Timeline({
key={row.id}
row={row}
unread={queries.unread}
+ presentation={presentation}
+ viewer={viewer}
extensions={extensions}
profile={profiles.get(row.authorId)}
participantProfiles={profiles}
diff --git a/src/features/messages/MessageRow.presentation.test.tsx b/src/features/messages/MessageRow.presentation.test.tsx
new file mode 100644
index 00000000..b46eeb44
--- /dev/null
+++ b/src/features/messages/MessageRow.presentation.test.tsx
@@ -0,0 +1,56 @@
+import { expect, it } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+import { MessageRow, type MessagePresentation } from "./MessageRow";
+import type { ChannelMessage } from "../relay/contracts";
+
+const row: ChannelMessage = {
+ id: "root",
+ channelId: "alpha",
+ authorId: "alice",
+ createdAt: 1700000000,
+ content: "Original excerpt https://example.com",
+ mentions: [],
+ attachments: [{ url: "https://example.com/image.png", video: false }],
+ reactions: [{ content: "👍" }],
+ replyCount: 2,
+ participants: [],
+};
+function render(presentation: MessagePresentation = {}) {
+ return renderToStaticMarkup(
+ url}
+ day={false}
+ onOpenLink={() => false}
+ onOpenThread={() => {}}
+ retry={undefined}
+ context={#alpha}
+ footer={Open conversation}
+ />,
+ );
+}
+it("keeps standard rows opt-out and original content/actions in both presentations", () => {
+ for (const html of [render(), render({ presentation: "bubbles" })]) {
+ expect(html).toContain("Original excerpt");
+ expect(html).toContain('href="https://example.com/"');
+ expect(html).toContain("Open image attachment");
+ expect(html).toContain("👍");
+ expect(html).toContain("View thread: 2 replies");
+ expect(html).toContain("Open conversation");
+ expect(html).toContain("#alpha");
+ }
+ expect(render()).not.toContain("data-direction");
+});
+it("uses explicit viewer identity for directional bubbles, never names or missing identity", () => {
+ expect(render({ presentation: "bubbles" })).toContain(
+ 'data-direction="incoming"',
+ );
+ expect(render({ presentation: "bubbles", viewer: "Alice" })).toContain(
+ 'data-direction="incoming"',
+ );
+ expect(render({ presentation: "bubbles", viewer: "alice" })).toContain(
+ 'data-direction="outgoing"',
+ );
+});
diff --git a/src/features/messages/MessageRow.tsx b/src/features/messages/MessageRow.tsx
index 7e5e2e53..67eb7c19 100644
--- a/src/features/messages/MessageRow.tsx
+++ b/src/features/messages/MessageRow.tsx
@@ -1,4 +1,4 @@
-import { memo, useCallback, useSyncExternalStore } from "react";
+import { memo, useCallback, useSyncExternalStore, type ReactNode } from "react";
import type { UnreadCapability } from "../relay/unread";
import { profileTarget } from "../profiles/target";
import { InlineText } from "../conversation/InlineText";
@@ -10,7 +10,14 @@ import { safeMessageUrl } from "../relay/message-content";
import styles from "./Messages.module.css";
import { usesLargeEmojiPresentation } from "./emoji-size";
-export type MessageRowProps = {
+export type MessagePresentation = {
+ presentation?: "bubbles" | undefined;
+ viewer?: string | undefined;
+};
+
+export type MessageRowProps = MessagePresentation & {
+ context?: ReactNode;
+ footer?: ReactNode;
row: ChannelMessage;
unread?: UnreadCapability | undefined;
extensions?: ConversationExtensions | undefined;
@@ -27,6 +34,10 @@ export type MessageRowProps = {
export const MessageRow = memo(function MessageRow({
row,
unread,
+ presentation,
+ viewer,
+ context,
+ footer,
extensions,
profile,
media,
@@ -56,6 +67,40 @@ export const MessageRow = memo(function MessageRow({
const clickable = target && canOpenLink?.(target);
const AvatarTag = clickable ? "button" : "div";
const emojiOnly = usesLargeEmojiPresentation(row.content, row.emoji);
+ const avatar = (
+ ) => {
+ event.currentTarget.focus();
+ onOpenLink(target);
+ },
+ }
+ : {})}
+ >
+ {picture ? (
+
+ ) : (
+ name.slice(0, 2).toUpperCase()
+ )}
+
+ );
+ const text = (
+
+ );
+ const bubbles = presentation === "bubbles";
+ const outgoing = bubbles && viewer === row.authorId;
return (
{day && (
@@ -69,29 +114,18 @@ export const MessageRow = memo(function MessageRow({
)}
-
-
) => {
- event.currentTarget.focus();
- onOpenLink(target);
- },
- }
- : {})}
- >
- {picture ? (
-
- ) : (
- name.slice(0, 2).toUpperCase()
- )}
-
+
+ {!bubbles && avatar}
{name}
+ {context}
-
+ {bubbles ? (
+
+ {text}
+ {!outgoing && avatar}
+
+ ) : (
+ text
+ )}
{row.attachments.map((attachment) => {
const url = safeMessageUrl(attachment.url);
@@ -224,6 +257,7 @@ export const MessageRow = memo(function MessageRow({
)}
)}
+ {footer}
diff --git a/src/features/messages/Messages.module.css b/src/features/messages/Messages.module.css
index df8bdd2a..b110e90b 100644
--- a/src/features/messages/Messages.module.css
+++ b/src/features/messages/Messages.module.css
@@ -669,3 +669,74 @@ button.avatar:focus-visible,
text-overflow: ellipsis;
white-space: nowrap;
}
+
+/* Optional shared presentation. Default Messages layout is unchanged. */
+.bubbleMessage {
+ gap: 10px;
+ padding: 12px 0;
+ align-items: flex-end;
+}
+.bubbleMessage .avatar {
+ width: 28px;
+ height: 28px;
+ font-size: 10px;
+}
+.bubbleMessage .messageBody {
+ margin-left: 38px;
+ flex: 0 1 auto;
+ max-width: 82%;
+}
+.bubbleMessage .byline {
+ padding: 0 4px;
+ margin-bottom: 6px;
+ gap: 6px;
+ font-size: 12px;
+ line-height: 18px;
+}
+.bubbleMessage .text {
+ width: fit-content;
+ max-width: 100%;
+ margin: 0;
+ padding: 8px 14px;
+ border-radius: 20px;
+ background: var(--surface-control);
+}
+.bubbleAnchor {
+ position: relative;
+ width: fit-content;
+ max-width: 100%;
+}
+.bubbleAnchor .avatar {
+ position: absolute;
+ left: -38px;
+ bottom: 0;
+}
+.outgoing {
+ justify-content: flex-end;
+}
+.outgoing .messageBody {
+ margin-left: 0;
+}
+.outgoing .byline {
+ justify-content: flex-end;
+}
+.outgoing .bubbleAnchor {
+ margin-left: auto;
+}
+.outgoing .text {
+ margin-left: auto;
+ background: var(--primary);
+ color: var(--on-primary);
+}
+.outgoing .text :where(a, h1, h2, h3, h4, h5, h6, blockquote) {
+ color: inherit;
+}
+.outgoing .text :where(code, pre, table) {
+ color: var(--text);
+ background: var(--surface-control);
+}
+.bubbleThread {
+ border: 0;
+ border-radius: 0;
+ box-shadow: none;
+}
diff --git a/src/features/messages/ThreadPanel.tsx b/src/features/messages/ThreadPanel.tsx
index 80a01d32..17dc2f9c 100644
--- a/src/features/messages/ThreadPanel.tsx
+++ b/src/features/messages/ThreadPanel.tsx
@@ -12,13 +12,13 @@ import type { ConversationExtensions } from "../conversation/contracts";
import type { RelaySession } from "../relay/session";
import type { ThreadView } from "../relay/threads";
import { useRowProfiles } from "../relay/react";
-import { MessageRow } from "./MessageRow";
+import { MessageRow, type MessagePresentation } from "./MessageRow";
import { MessageComposer } from "./MessageComposer";
import styles from "./Messages.module.css";
import { useReading } from "./use-reading";
import { messageViewKey } from "./view-key";
-export type ThreadPanelProps = {
+export type ThreadPanelProps = MessagePresentation & {
extensions?: ConversationExtensions | undefined;
session: RelaySession;
scope: string;
@@ -46,6 +46,8 @@ export function ThreadPanel(props: ThreadPanelProps) {
}
function OwnedThreadPanel({
session,
+ presentation,
+ viewer,
extensions,
scope,
channelName,
@@ -77,7 +79,7 @@ function OwnedThreadPanel({
}, [session, channelId, messageId, attempt]);
return (