mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Persist agent audiences with native inline mentions (#1949)
Signed-off-by: npub1n4y9luxx9y27pz5qz93vr9w8auyk7mmpgwf9gpe9tn4zv4kyhzjqtcntu7 <9d485ff0c62915e08a801162c195c7ef096f6f6143925407255cea2656c4b8a4@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1d6t84ajeg9skp2609l2k6axgcme8x7g7u7luj352r03hcwreg7lqnxcsex <6e967af659416160ab4f2fd56d74c8c6f273791ee7bfc9468a1be37c387947be@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1n4y9luxx9y27pz5qz93vr9w8auyk7mmpgwf9gpe9tn4zv4kyhzjqtcntu7 <9d485ff0c62915e08a801162c195c7ef096f6f6143925407255cea2656c4b8a4@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1d6t84ajeg9skp2609l2k6axgcme8x7g7u7luj352r03hcwreg7lqnxcsex <6e967af659416160ab4f2fd56d74c8c6f273791ee7bfc9468a1be37c387947be@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
co-authored by
npub1n4y9luxx9y27pz5qz93vr9w8auyk7mmpgwf9gpe9tn4zv4kyhzjqtcntu7
npub1d6t84ajeg9skp2609l2k6axgcme8x7g7u7luj352r03hcwreg7lqnxcsex
parent
b1ff68246f
commit
19dc33bda6
@@ -54,6 +54,7 @@ export default defineConfig({
|
||||
"**/composer-tooltip-dismiss.spec.ts",
|
||||
"**/mentions.spec.ts",
|
||||
"**/team-mentions.spec.ts",
|
||||
"**/persistent-agent-audience.spec.ts",
|
||||
"**/relay-reconnect.spec.ts",
|
||||
"**/relay-reconnect-affordance.spec.ts",
|
||||
"**/workflows.spec.ts",
|
||||
|
||||
@@ -460,7 +460,10 @@ const overrides = new Map([
|
||||
// +3: onLinkShortcutRef wiring (ref decl + editor option + assignment) for
|
||||
// the ⌘K link-editor shortcut, mirroring the existing onEditLinkRef
|
||||
// pattern. Queued to split with the rest of this list.
|
||||
["src/features/messages/ui/MessageComposer.tsx", 1036],
|
||||
// +35: persistent audience scope/hook wiring and chip component handoff. The
|
||||
// chip markup lives separately; remaining lines connect existing composer
|
||||
// send state to the audience store. Queued with the existing split.
|
||||
["src/features/messages/ui/MessageComposer.tsx", 1091],
|
||||
// global-agent-config: model-tuning section (BuzzAgentModelTuningFields via
|
||||
// EditAgentAdvancedFields) + providerValid gate + effectiveProvider derivation
|
||||
// + globalProvider threading into getPersonaProviderOptions. All load-bearing
|
||||
|
||||
@@ -754,6 +754,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
<WelcomeComposerBanner state={welcomeComposerBannerState} />
|
||||
) : null}
|
||||
<MessageComposer
|
||||
audienceContext={{ type: "timeline" }}
|
||||
channelId={activeChannel?.id ?? null}
|
||||
channelName={activeChannel?.name ?? "channel"}
|
||||
channelType={activeChannel?.channelType ?? null}
|
||||
|
||||
@@ -472,6 +472,10 @@ export function InboxDetailPane({
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10">
|
||||
<div className="pointer-events-auto">
|
||||
<MessageComposer
|
||||
audienceContext={{
|
||||
type: "thread",
|
||||
threadRootId: item.conversationId,
|
||||
}}
|
||||
channelId={item.item.channelId}
|
||||
channelName={item.channelLabel ?? "channel"}
|
||||
channelType={composerChannelType}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { filterEffectiveExplicitAgentPubkeys } from "./effectiveExplicitAgentPubkeys.ts";
|
||||
|
||||
const agentA = "a".repeat(64);
|
||||
const agentB = "b".repeat(64);
|
||||
const person = "c".repeat(64);
|
||||
|
||||
test("send without inviting excludes removed agents from audience promotion", () => {
|
||||
assert.deepEqual(
|
||||
filterEffectiveExplicitAgentPubkeys([agentA, agentB], [agentA, person]),
|
||||
[agentA],
|
||||
);
|
||||
});
|
||||
|
||||
test("effective audience promotion keeps authored order and dedupes", () => {
|
||||
assert.deepEqual(
|
||||
filterEffectiveExplicitAgentPubkeys(
|
||||
[agentB.toUpperCase(), agentA, agentB],
|
||||
[agentA, agentB],
|
||||
),
|
||||
[agentB, agentA],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
function normalizePubkey(pubkey: string): string {
|
||||
return pubkey.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function uniqueNormalizedPubkeys(pubkeys: Iterable<string>): string[] {
|
||||
return [...new Set([...pubkeys].map(normalizePubkey))].filter(Boolean);
|
||||
}
|
||||
|
||||
export function filterEffectiveExplicitAgentPubkeys(
|
||||
explicitAgentPubkeys: Iterable<string>,
|
||||
effectiveMentionPubkeys: Iterable<string>,
|
||||
): string[] {
|
||||
const effectivePubkeys = new Set(
|
||||
uniqueNormalizedPubkeys(effectiveMentionPubkeys),
|
||||
);
|
||||
return uniqueNormalizedPubkeys(explicitAgentPubkeys).filter((pubkey) =>
|
||||
effectivePubkeys.has(pubkey),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
function createStorage() {
|
||||
const values = new Map();
|
||||
return {
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
setItem: (key, value) => values.set(key, String(value)),
|
||||
};
|
||||
}
|
||||
|
||||
const agentA = "a".repeat(64);
|
||||
const agentB = "b".repeat(64);
|
||||
const agentC = "c".repeat(64);
|
||||
const ownerA = "1".repeat(64);
|
||||
const ownerB = "2".repeat(64);
|
||||
const storageKey = "buzz:persistent-agent-audiences:v2";
|
||||
|
||||
let loadSequence = 0;
|
||||
|
||||
async function loadStore(offset = 0) {
|
||||
globalThis.window = { localStorage: createStorage() };
|
||||
loadSequence += 1;
|
||||
return import(
|
||||
`./persistentAgentAudience.ts?test=${Date.now()}-${offset}-${loadSequence}`
|
||||
);
|
||||
}
|
||||
|
||||
function savedAudiences() {
|
||||
return JSON.parse(window.localStorage.getItem(storageKey));
|
||||
}
|
||||
|
||||
test("conversation scopes isolate identities, channels, and threads", async () => {
|
||||
const store = await loadStore();
|
||||
const channelA = store.getPersistentAgentAudienceScope({
|
||||
ownerPubkey: ownerA,
|
||||
channelId: "channel-a",
|
||||
});
|
||||
const channelB = store.getPersistentAgentAudienceScope({
|
||||
ownerPubkey: ownerA,
|
||||
channelId: "channel-b",
|
||||
});
|
||||
const threadA1 = store.getPersistentAgentAudienceScope({
|
||||
ownerPubkey: ownerA,
|
||||
channelId: "channel-a",
|
||||
threadRootId: "root-1",
|
||||
});
|
||||
const threadA2 = store.getPersistentAgentAudienceScope({
|
||||
ownerPubkey: ownerA,
|
||||
channelId: "channel-a",
|
||||
threadRootId: "root-2",
|
||||
});
|
||||
const otherIdentity = store.getPersistentAgentAudienceScope({
|
||||
ownerPubkey: ownerB,
|
||||
channelId: "channel-a",
|
||||
});
|
||||
|
||||
for (const scope of [channelA, channelB, threadA1, threadA2, otherIdentity]) {
|
||||
assert.ok(scope);
|
||||
store.setPersistentAgentAudience(scope, [agentA]);
|
||||
}
|
||||
|
||||
assert.equal(new Set(Object.keys(savedAudiences())).size, 5);
|
||||
});
|
||||
|
||||
test("successful fast send promotes without a persisted draft key", async () => {
|
||||
const store = await loadStore(1);
|
||||
const scope = store.getPersistentAgentAudienceScope({
|
||||
ownerPubkey: ownerA,
|
||||
channelId: "channel-a",
|
||||
});
|
||||
store.setPersistentAgentAudienceEnabled(true);
|
||||
|
||||
store.promotePersistentAgentAudience({
|
||||
expectedGeneration: store.getPersistentAgentAudienceGeneration(),
|
||||
scope,
|
||||
expectedRevision: store.getPersistentAgentAudienceRevision(scope),
|
||||
explicitAgentPubkeys: [agentA],
|
||||
});
|
||||
|
||||
assert.deepEqual(savedAudiences(), { [scope]: [agentA] });
|
||||
});
|
||||
|
||||
test("explicit recipients merge and dedupe after successful send", async () => {
|
||||
const store = await loadStore(2);
|
||||
const scope = `${ownerA}:channel-a:timeline`;
|
||||
store.setPersistentAgentAudienceEnabled(true);
|
||||
store.setPersistentAgentAudience(scope, [agentA]);
|
||||
const revision = store.getPersistentAgentAudienceRevision(scope);
|
||||
|
||||
store.promotePersistentAgentAudience({
|
||||
expectedGeneration: store.getPersistentAgentAudienceGeneration(),
|
||||
scope,
|
||||
expectedRevision: revision,
|
||||
explicitAgentPubkeys: [agentA, agentB],
|
||||
});
|
||||
|
||||
assert.deepEqual(savedAudiences(), { [scope]: [agentA, agentB] });
|
||||
});
|
||||
|
||||
test("successful send makes authored mention order authoritative", async () => {
|
||||
const store = await loadStore(100);
|
||||
const scope = `${ownerA}:channel-a:timeline`;
|
||||
store.setPersistentAgentAudienceEnabled(true);
|
||||
store.setPersistentAgentAudience(scope, [agentA, agentB]);
|
||||
|
||||
store.promotePersistentAgentAudience({
|
||||
expectedGeneration: store.getPersistentAgentAudienceGeneration(),
|
||||
scope,
|
||||
expectedRevision: store.getPersistentAgentAudienceRevision(scope),
|
||||
explicitAgentPubkeys: [agentB, agentA, agentC],
|
||||
});
|
||||
|
||||
assert.deepEqual(savedAudiences(), {
|
||||
[scope]: [agentB, agentA, agentC],
|
||||
});
|
||||
});
|
||||
|
||||
test("successful send retains saved targets absent from the draft", async () => {
|
||||
const store = await loadStore(101);
|
||||
const scope = `${ownerA}:channel-a:timeline`;
|
||||
store.setPersistentAgentAudienceEnabled(true);
|
||||
store.setPersistentAgentAudience(scope, [agentA, agentC]);
|
||||
|
||||
store.promotePersistentAgentAudience({
|
||||
expectedGeneration: store.getPersistentAgentAudienceGeneration(),
|
||||
scope,
|
||||
expectedRevision: store.getPersistentAgentAudienceRevision(scope),
|
||||
explicitAgentPubkeys: [agentB, agentA],
|
||||
});
|
||||
|
||||
assert.deepEqual(savedAudiences(), {
|
||||
[scope]: [agentB, agentA, agentC],
|
||||
});
|
||||
});
|
||||
|
||||
test("removal while send awaits wins over late success", async () => {
|
||||
const store = await loadStore(3);
|
||||
const scope = `${ownerA}:channel-a:timeline`;
|
||||
store.setPersistentAgentAudienceEnabled(true);
|
||||
store.setPersistentAgentAudience(scope, [agentA]);
|
||||
const revisionAtSubmit = store.getPersistentAgentAudienceRevision(scope);
|
||||
|
||||
store.removePersistentAgentAudienceMember(scope, agentA);
|
||||
store.promotePersistentAgentAudience({
|
||||
expectedGeneration: store.getPersistentAgentAudienceGeneration(),
|
||||
scope,
|
||||
expectedRevision: revisionAtSubmit,
|
||||
explicitAgentPubkeys: [agentA],
|
||||
});
|
||||
|
||||
assert.deepEqual(savedAudiences(), { [scope]: [] });
|
||||
});
|
||||
|
||||
test("removing final chip preserves an explicit empty scope", async () => {
|
||||
const store = await loadStore(4);
|
||||
const scope = `${ownerA}:channel-a:thread:root`;
|
||||
store.setPersistentAgentAudience(scope, [agentA]);
|
||||
store.removePersistentAgentAudienceMember(scope, agentA);
|
||||
|
||||
assert.deepEqual(savedAudiences(), { [scope]: [] });
|
||||
});
|
||||
|
||||
test("completion after disabling cannot repopulate audiences", async () => {
|
||||
const store = await loadStore(5);
|
||||
const scope = `${ownerA}:channel-a:timeline`;
|
||||
store.setPersistentAgentAudienceEnabled(true);
|
||||
store.setPersistentAgentAudience(scope, [agentA]);
|
||||
const revisionAtSubmit = store.getPersistentAgentAudienceRevision(scope);
|
||||
store.setPersistentAgentAudienceEnabled(false);
|
||||
|
||||
store.promotePersistentAgentAudience({
|
||||
expectedGeneration: store.getPersistentAgentAudienceGeneration(),
|
||||
scope,
|
||||
expectedRevision: revisionAtSubmit,
|
||||
explicitAgentPubkeys: [agentB],
|
||||
});
|
||||
|
||||
assert.deepEqual(savedAudiences(), {});
|
||||
});
|
||||
|
||||
test("invalid, duplicate, and differently-cased pubkeys normalize", async () => {
|
||||
const store = await loadStore(6);
|
||||
const scope = `${ownerA}:channel-a:timeline`;
|
||||
store.setPersistentAgentAudience(scope, [
|
||||
agentA.toUpperCase(),
|
||||
agentA,
|
||||
"bad",
|
||||
]);
|
||||
|
||||
assert.deepEqual(savedAudiences(), { [scope]: [agentA] });
|
||||
});
|
||||
|
||||
test("new recipients retain explicit mention order", async () => {
|
||||
const store = await loadStore(9);
|
||||
const scope = `${ownerA}:channel-a:timeline`;
|
||||
store.setPersistentAgentAudienceEnabled(true);
|
||||
|
||||
store.promotePersistentAgentAudience({
|
||||
expectedGeneration: store.getPersistentAgentAudienceGeneration(),
|
||||
scope,
|
||||
expectedRevision: store.getPersistentAgentAudienceRevision(scope),
|
||||
explicitAgentPubkeys: [agentB, agentA],
|
||||
});
|
||||
|
||||
assert.deepEqual(savedAudiences(), { [scope]: [agentB, agentA] });
|
||||
});
|
||||
|
||||
test("first new-message send resolves its destination after capturing generation", async () => {
|
||||
const store = await loadStore(7);
|
||||
const capturedGeneration = store.getPersistentAgentAudienceGeneration();
|
||||
store.setPersistentAgentAudienceEnabled(true);
|
||||
const scope = store.getPersistentAgentAudienceScope({
|
||||
ownerPubkey: ownerA,
|
||||
channelId: "resolved-dm",
|
||||
});
|
||||
|
||||
store.promotePersistentAgentAudience({
|
||||
expectedGeneration: capturedGeneration,
|
||||
expectedRevision: null,
|
||||
scope,
|
||||
explicitAgentPubkeys: [agentA],
|
||||
});
|
||||
|
||||
assert.deepEqual(savedAudiences(), { [scope]: [agentA] });
|
||||
});
|
||||
|
||||
test("disable during new-message destination preparation invalidates promotion", async () => {
|
||||
const store = await loadStore(8);
|
||||
store.setPersistentAgentAudienceEnabled(true);
|
||||
const capturedGeneration = store.getPersistentAgentAudienceGeneration();
|
||||
store.setPersistentAgentAudienceEnabled(false);
|
||||
store.setPersistentAgentAudienceEnabled(true);
|
||||
const scope = store.getPersistentAgentAudienceScope({
|
||||
ownerPubkey: ownerA,
|
||||
channelId: "resolved-dm",
|
||||
});
|
||||
|
||||
store.promotePersistentAgentAudience({
|
||||
expectedGeneration: capturedGeneration,
|
||||
expectedRevision: null,
|
||||
scope,
|
||||
explicitAgentPubkeys: [agentA],
|
||||
});
|
||||
|
||||
assert.deepEqual(savedAudiences(), {});
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
import * as React from "react";
|
||||
|
||||
const ENABLED_STORAGE_KEY = "buzz:keep-addressed-agents-active";
|
||||
const AUDIENCES_STORAGE_KEY = "buzz:persistent-agent-audiences:v2";
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
const revisions = new Map<string, number>();
|
||||
let revisionClock = 0;
|
||||
let defaultRevision = 0;
|
||||
let generation = 0;
|
||||
let enabled = readEnabled();
|
||||
let audiences = readAudiences();
|
||||
let snapshot = buildSnapshot();
|
||||
|
||||
export type PersistentAgentAudienceSnapshot = Readonly<{
|
||||
enabled: boolean;
|
||||
audiences: Readonly<Record<string, readonly string[]>>;
|
||||
generation: number;
|
||||
}>;
|
||||
|
||||
type PersistentAgentAudienceScopeInput = {
|
||||
ownerPubkey: string;
|
||||
channelId: string;
|
||||
threadRootId?: string | null;
|
||||
};
|
||||
|
||||
function normalizePubkeys(pubkeys: Iterable<string>): string[] {
|
||||
return [
|
||||
...new Set([...pubkeys].map((pubkey) => pubkey.trim().toLowerCase())),
|
||||
].filter((pubkey) => /^[0-9a-f]{64}$/.test(pubkey));
|
||||
}
|
||||
|
||||
function readEnabled(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
return window.localStorage.getItem(ENABLED_STORAGE_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function readAudiences(): Record<string, string[]> {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(
|
||||
window.localStorage.getItem(AUDIENCES_STORAGE_KEY) ?? "{}",
|
||||
);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
||||
return {};
|
||||
|
||||
const result: Record<string, string[]> = {};
|
||||
for (const [scope, value] of Object.entries(parsed)) {
|
||||
if (scope && Array.isArray(value)) {
|
||||
result[scope] = normalizePubkeys(
|
||||
value.filter((entry): entry is string => typeof entry === "string"),
|
||||
);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function buildSnapshot(): PersistentAgentAudienceSnapshot {
|
||||
return { enabled, audiences, generation };
|
||||
}
|
||||
|
||||
function emit(): void {
|
||||
snapshot = buildSnapshot();
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function persistAudiences(): void {
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
AUDIENCES_STORAGE_KEY,
|
||||
JSON.stringify(audiences),
|
||||
);
|
||||
} catch {
|
||||
// Persistence is best-effort; the live session still uses in-memory state.
|
||||
}
|
||||
}
|
||||
|
||||
function advanceRevision(scope: string): void {
|
||||
revisionClock += 1;
|
||||
revisions.set(scope, revisionClock);
|
||||
}
|
||||
|
||||
export function setPersistentAgentAudienceEnabled(nextEnabled: boolean): void {
|
||||
if (enabled === nextEnabled) return;
|
||||
enabled = nextEnabled;
|
||||
if (!nextEnabled) {
|
||||
generation += 1;
|
||||
revisionClock += 1;
|
||||
defaultRevision = revisionClock;
|
||||
revisions.clear();
|
||||
audiences = {};
|
||||
persistAudiences();
|
||||
}
|
||||
try {
|
||||
window.localStorage.setItem(ENABLED_STORAGE_KEY, nextEnabled ? "1" : "0");
|
||||
} catch {
|
||||
// Persistence is best-effort.
|
||||
}
|
||||
emit();
|
||||
}
|
||||
|
||||
export function getPersistentAgentAudienceScope({
|
||||
ownerPubkey,
|
||||
channelId,
|
||||
threadRootId = null,
|
||||
}: PersistentAgentAudienceScopeInput): string | null {
|
||||
const owner = ownerPubkey.trim().toLowerCase();
|
||||
if (!/^[0-9a-f]{64}$/.test(owner) || !channelId) return null;
|
||||
const conversation = threadRootId ? `thread:${threadRootId}` : "timeline";
|
||||
return `${owner}:${channelId}:${conversation}`;
|
||||
}
|
||||
|
||||
export function getPersistentAgentAudienceGeneration(): number {
|
||||
return generation;
|
||||
}
|
||||
|
||||
export function getPersistentAgentAudienceRevision(scope: string): number {
|
||||
return revisions.get(scope) ?? defaultRevision;
|
||||
}
|
||||
|
||||
export function setPersistentAgentAudience(
|
||||
scope: string,
|
||||
pubkeys: Iterable<string>,
|
||||
): void {
|
||||
if (!scope) return;
|
||||
const normalized = normalizePubkeys(pubkeys);
|
||||
const current = audiences[scope];
|
||||
if (
|
||||
current !== undefined &&
|
||||
current.length === normalized.length &&
|
||||
current.every((pubkey, index) => pubkey === normalized[index])
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
audiences = { ...audiences, [scope]: normalized };
|
||||
advanceRevision(scope);
|
||||
persistAudiences();
|
||||
emit();
|
||||
}
|
||||
|
||||
export function promotePersistentAgentAudience({
|
||||
expectedGeneration,
|
||||
expectedRevision,
|
||||
explicitAgentPubkeys,
|
||||
scope,
|
||||
}: {
|
||||
expectedGeneration: number;
|
||||
expectedRevision: number | null;
|
||||
explicitAgentPubkeys: string[];
|
||||
scope: string | null;
|
||||
}): void {
|
||||
if (
|
||||
!enabled ||
|
||||
expectedGeneration !== generation ||
|
||||
!scope ||
|
||||
(expectedRevision !== null &&
|
||||
getPersistentAgentAudienceRevision(scope) !== expectedRevision)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setPersistentAgentAudience(scope, [
|
||||
...explicitAgentPubkeys,
|
||||
...(audiences[scope] ?? []),
|
||||
]);
|
||||
}
|
||||
|
||||
export function removePersistentAgentAudienceMember(
|
||||
scope: string,
|
||||
pubkey: string,
|
||||
): void {
|
||||
setPersistentAgentAudience(
|
||||
scope,
|
||||
(audiences[scope] ?? []).filter(
|
||||
(candidate) => candidate !== pubkey.trim().toLowerCase(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
function getSnapshot(): PersistentAgentAudienceSnapshot {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
const serverSnapshot: PersistentAgentAudienceSnapshot = {
|
||||
enabled: false,
|
||||
audiences: {},
|
||||
generation: 0,
|
||||
};
|
||||
|
||||
export function usePersistentAgentAudience(scope: string | null): {
|
||||
enabled: boolean;
|
||||
pubkeys: readonly string[];
|
||||
generation: number;
|
||||
revision: number;
|
||||
setEnabled: (enabled: boolean) => void;
|
||||
promotePubkeys: typeof promotePersistentAgentAudience;
|
||||
removePubkey: (pubkey: string) => void;
|
||||
clear: () => void;
|
||||
} {
|
||||
const state = React.useSyncExternalStore(
|
||||
subscribe,
|
||||
getSnapshot,
|
||||
() => serverSnapshot,
|
||||
);
|
||||
const resolvedScope = scope ?? "";
|
||||
return {
|
||||
enabled: state.enabled,
|
||||
pubkeys: resolvedScope ? (state.audiences[resolvedScope] ?? []) : [],
|
||||
generation: state.generation,
|
||||
revision: resolvedScope
|
||||
? getPersistentAgentAudienceRevision(resolvedScope)
|
||||
: 0,
|
||||
setEnabled: setPersistentAgentAudienceEnabled,
|
||||
promotePubkeys: promotePersistentAgentAudience,
|
||||
removePubkey: React.useCallback(
|
||||
(pubkey) => removePersistentAgentAudienceMember(resolvedScope, pubkey),
|
||||
[resolvedScope],
|
||||
),
|
||||
clear: React.useCallback(
|
||||
() => setPersistentAgentAudience(resolvedScope, []),
|
||||
[resolvedScope],
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -74,6 +74,14 @@ function formatSearchUserSecondaryLabel(user: UserSearchResult) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function appendUniqueName(current: string[], name: string): string[] {
|
||||
return current.some(
|
||||
(candidate) => candidate.toLowerCase() === name.toLowerCase(),
|
||||
)
|
||||
? current
|
||||
: [...current, name];
|
||||
}
|
||||
|
||||
export function useMentions(
|
||||
channelId: string | null,
|
||||
externalMembers?: ChannelMember[],
|
||||
@@ -694,35 +702,43 @@ export function useMentions(
|
||||
personaMentionMapRef.current.delete(trimmedName);
|
||||
trimMapToSize(mentionMapRef.current, 200);
|
||||
|
||||
setSelectedMentionNames((current) => {
|
||||
if (
|
||||
current.some(
|
||||
(name) => name.toLowerCase() === trimmedName.toLowerCase(),
|
||||
)
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return [...current, trimmedName];
|
||||
});
|
||||
setSelectedMentionNames((current) =>
|
||||
appendUniqueName(current, trimmedName),
|
||||
);
|
||||
|
||||
if (options?.isAgent) {
|
||||
setSelectedAgentMentionNames((current) => {
|
||||
if (
|
||||
current.some(
|
||||
(name) => name.toLowerCase() === trimmedName.toLowerCase(),
|
||||
)
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return [...current, trimmedName];
|
||||
});
|
||||
setSelectedAgentMentionNames((current) =>
|
||||
appendUniqueName(current, trimmedName),
|
||||
);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const insertResolvedMention = React.useCallback(
|
||||
({
|
||||
displayName,
|
||||
pubkey,
|
||||
replaceFromOffset,
|
||||
replaceToOffset,
|
||||
isAgent = false,
|
||||
}: {
|
||||
displayName: string;
|
||||
pubkey: string;
|
||||
replaceFromOffset: number;
|
||||
replaceToOffset: number;
|
||||
isAgent?: boolean;
|
||||
}): AutocompleteEdit => {
|
||||
registerMentionPubkey(displayName, pubkey, { isAgent });
|
||||
return {
|
||||
replaceFromOffset,
|
||||
replaceToOffset,
|
||||
insertText: `@${displayName.trim()} `,
|
||||
};
|
||||
},
|
||||
[registerMentionPubkey],
|
||||
);
|
||||
|
||||
const getMentionDisplayName = React.useCallback(
|
||||
(pubkey: string): string | null => {
|
||||
const normalizedPubkey = normalizePubkey(pubkey);
|
||||
@@ -956,6 +972,7 @@ export function useMentions(
|
||||
handleMentionKeyDown,
|
||||
hasResolvedMembers: members !== undefined,
|
||||
insertMention,
|
||||
insertResolvedMention,
|
||||
agentKnownNames: agentHighlightNames,
|
||||
isAgentPubkey,
|
||||
isManagedAgentPubkey,
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
useMediaUpload,
|
||||
} from "@/features/messages/lib/useMediaUpload";
|
||||
import { useMentions } from "@/features/messages/lib/useMentions";
|
||||
import { getPersistentAgentAudienceScope } from "@/features/messages/lib/persistentAgentAudience";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import {
|
||||
hasMentionClipboardHtml,
|
||||
@@ -54,10 +56,16 @@ import {
|
||||
import { MessageComposerToolbar } from "./MessageComposerToolbar";
|
||||
import { NonMemberMentionDialog } from "./NonMemberMentionDialog";
|
||||
import { useMentionSendFlow } from "./useMentionSendFlow";
|
||||
import { usePersistentAgentMentionHydration } from "./usePersistentAgentMentionHydration";
|
||||
import { useComposerContentState } from "./useComposerContentState";
|
||||
import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot";
|
||||
|
||||
type MessageComposerAudienceContext =
|
||||
| { type: "timeline" }
|
||||
| { type: "thread"; threadRootId: string };
|
||||
|
||||
type MessageComposerProps = {
|
||||
audienceContext?: MessageComposerAudienceContext | null;
|
||||
channelId?: string | null;
|
||||
channelName: string;
|
||||
channelType?: ChannelType | null;
|
||||
@@ -137,6 +145,7 @@ type MessageComposerProps = {
|
||||
};
|
||||
|
||||
function MessageComposerImpl({
|
||||
audienceContext = null,
|
||||
channelId = null,
|
||||
channelName,
|
||||
channelType = null,
|
||||
@@ -186,7 +195,19 @@ function MessageComposerImpl({
|
||||
}, []);
|
||||
|
||||
const drafts = useDrafts();
|
||||
const identityQuery = useIdentityQuery();
|
||||
const effectiveDraftKey = draftKey ?? channelId;
|
||||
const ownerPubkey = identityQuery.data?.pubkey ?? null;
|
||||
const audienceThreadRootId =
|
||||
audienceContext?.type === "thread" ? audienceContext.threadRootId : null;
|
||||
const audienceScope =
|
||||
audienceContext && channelId && ownerPubkey
|
||||
? getPersistentAgentAudienceScope({
|
||||
ownerPubkey,
|
||||
channelId,
|
||||
threadRootId: audienceThreadRootId,
|
||||
})
|
||||
: null;
|
||||
const effectiveDraftKeyRef = React.useRef(effectiveDraftKey);
|
||||
effectiveDraftKeyRef.current = effectiveDraftKey;
|
||||
// Snapshot composer state before edit mode so cancel can restore it.
|
||||
@@ -321,6 +342,8 @@ function MessageComposerImpl({
|
||||
channelLinks.updateChannelQuery(text, cursor);
|
||||
emojiAutocomplete.updateEmojiQuery(text, cursor);
|
||||
|
||||
persistentMentionHydrationRef.current?.reconcile(text);
|
||||
|
||||
if (text.trim().length > 0) {
|
||||
notifyTyping();
|
||||
}
|
||||
@@ -338,6 +361,19 @@ function MessageComposerImpl({
|
||||
onLinkShortcutRef.current = linkEditor.openFromShortcut;
|
||||
useComposerSpoilerParticles(richText.editor, composerScrollRef);
|
||||
|
||||
const persistentMentionHydration = usePersistentAgentMentionHydration({
|
||||
audienceScope,
|
||||
hydrationKey: effectiveDraftKey,
|
||||
isEditing: editTarget != null,
|
||||
mentions,
|
||||
richText,
|
||||
});
|
||||
const persistentAudience = persistentMentionHydration.audience;
|
||||
const persistentMentionHydrationRef = React.useRef(
|
||||
persistentMentionHydration,
|
||||
);
|
||||
persistentMentionHydrationRef.current = persistentMentionHydration;
|
||||
|
||||
const mentionSendFlow = useMentionSendFlow({
|
||||
channelId,
|
||||
channelLinks,
|
||||
@@ -354,6 +390,17 @@ function MessageComposerImpl({
|
||||
setIsEmojiPickerOpen,
|
||||
setPendingImeta: media.setPendingImeta,
|
||||
setSpoileredAttachmentUrls,
|
||||
onSuccessfulExplicitAgentAudience:
|
||||
persistentAudience.enabled && audienceContext && ownerPubkey
|
||||
? ({ channelId: successfulChannelId, ...promotion }) => {
|
||||
const scope = getPersistentAgentAudienceScope({
|
||||
ownerPubkey,
|
||||
channelId: successfulChannelId,
|
||||
threadRootId: audienceThreadRootId,
|
||||
});
|
||||
persistentAudience.promotePubkeys({ ...promotion, scope });
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: editTarget?.id is the trigger
|
||||
@@ -617,6 +664,7 @@ function MessageComposerImpl({
|
||||
}
|
||||
|
||||
onPreparingMentionSendChange?.(true);
|
||||
persistentMentionHydration.beginSubmit();
|
||||
try {
|
||||
await mentionSendFlow.sendMessageWithMentionFlow({
|
||||
capturedChannelId: channelId,
|
||||
@@ -628,8 +676,11 @@ function MessageComposerImpl({
|
||||
),
|
||||
spoileredAttachmentUrls,
|
||||
trimmed,
|
||||
audienceGeneration: persistentAudience.generation,
|
||||
audienceRevision: audienceScope ? persistentAudience.revision : null,
|
||||
});
|
||||
} finally {
|
||||
persistentMentionHydration.endSubmit();
|
||||
onPreparingMentionSendChange?.(false);
|
||||
}
|
||||
}, [
|
||||
@@ -650,6 +701,10 @@ function MessageComposerImpl({
|
||||
syncComposerContentFromEditor,
|
||||
onCaptureSendContext,
|
||||
onPreparingMentionSendChange,
|
||||
audienceScope,
|
||||
persistentMentionHydration,
|
||||
persistentAudience.generation,
|
||||
persistentAudience.revision,
|
||||
]);
|
||||
submitMessageRef.current = submitMessage;
|
||||
|
||||
|
||||
@@ -881,6 +881,10 @@ export function MessageThreadPanel({
|
||||
>
|
||||
<div className="pointer-events-auto">
|
||||
<MessageComposer
|
||||
audienceContext={{
|
||||
type: "thread",
|
||||
threadRootId: threadHead.id,
|
||||
}}
|
||||
channelId={channelId}
|
||||
channelName={channelName}
|
||||
channelType={channel?.channelType ?? null}
|
||||
|
||||
@@ -594,6 +594,7 @@ export function NewMessageScreen() {
|
||||
) : null}
|
||||
|
||||
<MessageComposer
|
||||
audienceContext={{ type: "timeline" }}
|
||||
channelName="new message"
|
||||
channelType="dm"
|
||||
containerClassName="px-5"
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
async function source(relativePath) {
|
||||
return readFile(new URL(relativePath, import.meta.url), "utf8");
|
||||
}
|
||||
|
||||
test("supported conversation hosts opt into explicit audience contexts", async () => {
|
||||
const [channelPane, threadPanel, newMessage, inboxDetail] = await Promise.all(
|
||||
[
|
||||
source("../../channels/ui/ChannelPane.tsx"),
|
||||
source("./MessageThreadPanel.tsx"),
|
||||
source("./NewMessageScreen.tsx"),
|
||||
source("../../home/ui/InboxDetailPane.tsx"),
|
||||
],
|
||||
);
|
||||
|
||||
assert.match(channelPane, /audienceContext=\{\{ type: "timeline" \}\}/);
|
||||
assert.match(newMessage, /audienceContext=\{\{ type: "timeline" \}\}/);
|
||||
assert.match(
|
||||
threadPanel,
|
||||
/type: "thread"[\s\S]*threadRootId: threadHead\.id/,
|
||||
);
|
||||
assert.match(
|
||||
inboxDetail,
|
||||
/type: "thread"[\s\S]*threadRootId: item\.conversationId/,
|
||||
);
|
||||
});
|
||||
|
||||
test("video review remains explicitly outside persistent audience routing", async () => {
|
||||
const videoPlayer = await source("../../../shared/ui/VideoPlayer.tsx");
|
||||
const composer = videoPlayer.slice(videoPlayer.indexOf("<MessageComposer"));
|
||||
|
||||
assert.match(composer, /draftKey=/);
|
||||
assert.doesNotMatch(
|
||||
composer.slice(0, composer.indexOf("/>") + 2),
|
||||
/audienceContext=/,
|
||||
);
|
||||
});
|
||||
|
||||
test("composer never derives audience context from draft keys", async () => {
|
||||
const composer = await source("./MessageComposer.tsx");
|
||||
|
||||
assert.doesNotMatch(composer, /draftKey\?\.startsWith\("thread:"\)/);
|
||||
assert.match(composer, /audienceContext\?\.type === "thread"/);
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@/features/agents/hooks";
|
||||
import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime";
|
||||
import { useAddChannelMembersMutation } from "@/features/channels/hooks";
|
||||
import { filterEffectiveExplicitAgentPubkeys } from "@/features/messages/lib/effectiveExplicitAgentPubkeys";
|
||||
import type { UseChannelLinksResult } from "@/features/messages/lib/useChannelLinks";
|
||||
import type { UseEmojiAutocompleteResult } from "@/features/messages/lib/useEmojiAutocomplete";
|
||||
import {
|
||||
@@ -45,6 +46,10 @@ type PendingNonMemberMentionSend = {
|
||||
savedImeta: ImetaMedia[];
|
||||
savedSpoileredAttachmentUrls: Set<string>;
|
||||
sentDraftKey: string | null | undefined;
|
||||
audienceGeneration: number;
|
||||
audienceRevision: number | null;
|
||||
/** Agent mentions explicitly authored in this draft (never inferred). */
|
||||
explicitAgentPubkeys: string[];
|
||||
};
|
||||
|
||||
type SendMessageWithMentionFlowInput = {
|
||||
@@ -58,6 +63,8 @@ type SendMessageWithMentionFlowInput = {
|
||||
sentDraftKey: string | null | undefined;
|
||||
spoileredAttachmentUrls?: ReadonlySet<string>;
|
||||
trimmed: string;
|
||||
audienceGeneration?: number;
|
||||
audienceRevision?: number | null;
|
||||
};
|
||||
|
||||
type UseMentionSendFlowOptions = {
|
||||
@@ -91,6 +98,12 @@ type UseMentionSendFlowOptions = {
|
||||
setSpoileredAttachmentUrls?: React.Dispatch<
|
||||
React.SetStateAction<Set<string>>
|
||||
>;
|
||||
onSuccessfulExplicitAgentAudience?: (audience: {
|
||||
channelId: string;
|
||||
expectedGeneration: number;
|
||||
expectedRevision: number | null;
|
||||
explicitAgentPubkeys: string[];
|
||||
}) => void;
|
||||
};
|
||||
|
||||
function mergeOutgoingTagsWithReferenceMentions(
|
||||
@@ -145,6 +158,7 @@ export function useMentionSendFlow({
|
||||
setIsEmojiPickerOpen,
|
||||
setPendingImeta,
|
||||
setSpoileredAttachmentUrls,
|
||||
onSuccessfulExplicitAgentAudience,
|
||||
}: UseMentionSendFlowOptions) {
|
||||
const [pendingNonMemberSend, setPendingNonMemberSend] =
|
||||
React.useState<PendingNonMemberMentionSend | null>(null);
|
||||
@@ -487,6 +501,22 @@ export function useMentionSendFlow({
|
||||
sendChannelId,
|
||||
draft.capturedThreadContext,
|
||||
);
|
||||
const effectiveExplicitAgentPubkeys =
|
||||
filterEffectiveExplicitAgentPubkeys(
|
||||
draft.explicitAgentPubkeys,
|
||||
mentionPubkeys,
|
||||
);
|
||||
if (effectiveExplicitAgentPubkeys.length > 0) {
|
||||
// Promote only explicitly authored agents that remained effective
|
||||
// for this successful send. "Send without inviting" removes its
|
||||
// excluded recipients here as well as from event routing.
|
||||
onSuccessfulExplicitAgentAudience?.({
|
||||
channelId: sendChannelId ?? draft.capturedChannelId ?? "",
|
||||
expectedGeneration: draft.audienceGeneration,
|
||||
expectedRevision: draft.audienceRevision,
|
||||
explicitAgentPubkeys: effectiveExplicitAgentPubkeys,
|
||||
});
|
||||
}
|
||||
if (draft.sentDraftKey) {
|
||||
drafts.markDraftSent(
|
||||
draft.sentDraftKey,
|
||||
@@ -525,6 +555,7 @@ export function useMentionSendFlow({
|
||||
mentions.isAgentPubkey,
|
||||
onPrepareSendChannel,
|
||||
onSendRef,
|
||||
onSuccessfulExplicitAgentAudience,
|
||||
richText.setContent,
|
||||
setContent,
|
||||
setPendingImeta,
|
||||
@@ -597,6 +628,8 @@ export function useMentionSendFlow({
|
||||
sentDraftKey,
|
||||
spoileredAttachmentUrls = new Set(),
|
||||
trimmed,
|
||||
audienceGeneration = 0,
|
||||
audienceRevision = null,
|
||||
}: SendMessageWithMentionFlowInput) => {
|
||||
if (isMentionSendPendingRef.current) {
|
||||
return;
|
||||
@@ -643,10 +676,16 @@ export function useMentionSendFlow({
|
||||
const createdPersonaAgentPubkeySet = new Set(
|
||||
createdPersonaAgentPubkeys.map(normalizePubkey),
|
||||
);
|
||||
const pubkeys = uniqueNormalizedPubkeys([
|
||||
const explicitMentionPubkeys = uniqueNormalizedPubkeys([
|
||||
...mentions.extractMentionPubkeys(trimmed),
|
||||
...createdPersonaAgentPubkeys,
|
||||
]);
|
||||
const explicitAgentPubkeys = explicitMentionPubkeys.filter(
|
||||
(pubkey) =>
|
||||
mentions.isAgentPubkey(pubkey) ||
|
||||
createdPersonaAgentPubkeySet.has(pubkey),
|
||||
);
|
||||
const pubkeys = explicitMentionPubkeys;
|
||||
const { content: finalContent, mediaTags } = buildOutgoingMessage(
|
||||
trimmed,
|
||||
pendingImeta,
|
||||
@@ -691,6 +730,9 @@ export function useMentionSendFlow({
|
||||
savedImeta: [...pendingImeta],
|
||||
savedSpoileredAttachmentUrls: new Set(spoileredAttachmentUrls),
|
||||
sentDraftKey,
|
||||
audienceGeneration,
|
||||
audienceRevision,
|
||||
explicitAgentPubkeys,
|
||||
};
|
||||
|
||||
if (promptNonMemberPubkeys.length > 0) {
|
||||
@@ -714,6 +756,7 @@ export function useMentionSendFlow({
|
||||
getNonMemberMentionPubkeys,
|
||||
getDmThreadAgentMentionError,
|
||||
mentions.extractMentionPubkeys,
|
||||
mentions.isAgentPubkey,
|
||||
mentions.isManagedAgentPubkey,
|
||||
onPrepareSendChannel,
|
||||
],
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { usePersistentAgentAudience } from "@/features/messages/lib/persistentAgentAudience";
|
||||
import type { UseMentionsResult } from "@/features/messages/lib/useMentions";
|
||||
import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTextEditor";
|
||||
|
||||
export function usePersistentAgentMentionHydration({
|
||||
audienceScope,
|
||||
hydrationKey,
|
||||
isEditing,
|
||||
mentions,
|
||||
richText,
|
||||
}: {
|
||||
audienceScope: string | null;
|
||||
hydrationKey: string | null | undefined;
|
||||
isEditing: boolean;
|
||||
mentions: UseMentionsResult;
|
||||
richText: UseRichTextEditorResult;
|
||||
}) {
|
||||
const audience = usePersistentAgentAudience(audienceScope);
|
||||
const audienceRef = React.useRef(audience);
|
||||
audienceRef.current = audience;
|
||||
const scopeRef = React.useRef(audienceScope);
|
||||
scopeRef.current = audienceScope;
|
||||
const isEditingRef = React.useRef(isEditing);
|
||||
isEditingRef.current = isEditing;
|
||||
const isRestoringRef = React.useRef(false);
|
||||
const isSubmittingRef = React.useRef(false);
|
||||
const hydratedRef = React.useRef(false);
|
||||
|
||||
const hydrate = React.useCallback(() => {
|
||||
const capturedScope = audienceScope;
|
||||
if (
|
||||
!audience.enabled ||
|
||||
!capturedScope ||
|
||||
isEditingRef.current ||
|
||||
audience.pubkeys.length === 0
|
||||
) {
|
||||
hydratedRef.current = true;
|
||||
return;
|
||||
}
|
||||
isRestoringRef.current = true;
|
||||
const current = richText.getPlainTextAndCursor().text;
|
||||
const targets = audience.pubkeys
|
||||
.map((pubkey) => ({
|
||||
pubkey,
|
||||
displayName: mentions.getMentionDisplayName(pubkey),
|
||||
}))
|
||||
.filter((target): target is { pubkey: string; displayName: string } =>
|
||||
Boolean(target.displayName),
|
||||
);
|
||||
for (const target of targets)
|
||||
mentions.registerMentionPubkey(target.displayName, target.pubkey, {
|
||||
isAgent: true,
|
||||
});
|
||||
if (scopeRef.current !== capturedScope) {
|
||||
isRestoringRef.current = false;
|
||||
return;
|
||||
}
|
||||
const present = new Set(mentions.extractMentionPubkeys(current));
|
||||
let prefixLength = 0;
|
||||
for (const target of targets.filter(
|
||||
(candidate) => !present.has(candidate.pubkey),
|
||||
)) {
|
||||
if (scopeRef.current !== capturedScope) break;
|
||||
const edit = mentions.insertResolvedMention({
|
||||
...target,
|
||||
isAgent: true,
|
||||
replaceFromOffset: prefixLength,
|
||||
replaceToOffset: prefixLength,
|
||||
});
|
||||
richText.replacePlainTextRange(
|
||||
edit.replaceFromOffset,
|
||||
edit.replaceToOffset,
|
||||
edit.insertText,
|
||||
);
|
||||
prefixLength += edit.insertText.length;
|
||||
}
|
||||
hydratedRef.current = scopeRef.current === capturedScope;
|
||||
isRestoringRef.current = false;
|
||||
}, [audience.enabled, audience.pubkeys, audienceScope, mentions, richText]);
|
||||
|
||||
const reconcile = React.useCallback(
|
||||
(text: string) => {
|
||||
if (
|
||||
!hydratedRef.current ||
|
||||
isRestoringRef.current ||
|
||||
isSubmittingRef.current ||
|
||||
isEditingRef.current
|
||||
)
|
||||
return;
|
||||
const present = new Set(mentions.extractMentionPubkeys(text));
|
||||
for (const pubkey of audienceRef.current.pubkeys) {
|
||||
if (!present.has(pubkey)) audienceRef.current.removePubkey(pubkey);
|
||||
}
|
||||
},
|
||||
[mentions.extractMentionPubkeys],
|
||||
);
|
||||
|
||||
const scheduleHydration = React.useCallback(
|
||||
() => requestAnimationFrame(hydrate),
|
||||
[hydrate],
|
||||
);
|
||||
React.useEffect(() => {
|
||||
void hydrationKey;
|
||||
hydratedRef.current = false;
|
||||
const frame = scheduleHydration();
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [hydrationKey, scheduleHydration]);
|
||||
|
||||
return {
|
||||
audience,
|
||||
beginSubmit: () => {
|
||||
isSubmittingRef.current = true;
|
||||
},
|
||||
endSubmit: () => {
|
||||
isSubmittingRef.current = false;
|
||||
scheduleHydration();
|
||||
},
|
||||
reconcile,
|
||||
scheduleHydration,
|
||||
};
|
||||
}
|
||||
@@ -1,20 +1,46 @@
|
||||
import { usePreventSleepContext } from "@/features/agents/usePreventSleep";
|
||||
import { Switch } from "@/shared/ui/switch";
|
||||
import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup";
|
||||
import {
|
||||
setPersistentAgentAudienceEnabled,
|
||||
usePersistentAgentAudience,
|
||||
} from "@/features/messages/lib/persistentAgentAudience";
|
||||
import { SettingsSectionHeader } from "./SettingsSectionHeader";
|
||||
|
||||
export function PreventSleepSettingsCard() {
|
||||
const { enabled, setEnabled, hasRunningAgents, expired, clearExpired } =
|
||||
usePreventSleepContext();
|
||||
const persistentAudience = usePersistentAgentAudience(null);
|
||||
|
||||
return (
|
||||
<section className="min-w-0" data-testid="settings-agents">
|
||||
<SettingsSectionHeader
|
||||
title="Agents"
|
||||
description="Settings that affect how local managed agents run on this machine."
|
||||
description="Control how agents behave in conversations and run on this machine."
|
||||
/>
|
||||
|
||||
<SettingsOptionGroup>
|
||||
<SettingsOptionRow>
|
||||
<div className="min-w-0">
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor="persistent-agent-audience-switch"
|
||||
>
|
||||
Keep addressed agents active
|
||||
</label>
|
||||
<p className="text-sm font-normal text-muted-foreground">
|
||||
Keep agents you address selected for future messages in the same
|
||||
channel or thread. Remove them from the composer at any time.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={persistentAudience.enabled}
|
||||
data-testid="persistent-agent-audience-toggle"
|
||||
id="persistent-agent-audience-switch"
|
||||
onCheckedChange={setPersistentAgentAudienceEnabled}
|
||||
/>
|
||||
</SettingsOptionRow>
|
||||
|
||||
<SettingsOptionRow>
|
||||
<div className="min-w-0">
|
||||
<label
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
import { waitForAnimations } from "../helpers/animations";
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
|
||||
const SHOTS = "test-results/persistent-agent-audience";
|
||||
const OWNER = "deadbeef".repeat(8);
|
||||
const CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
|
||||
const AGENT_A = "a".repeat(64);
|
||||
const AGENT_B = "b".repeat(64);
|
||||
const SCOPE = `${OWNER}:${CHANNEL_ID}:timeline`;
|
||||
|
||||
async function seedAudience(page: Page, pubkeys: string[], theme = "buzz") {
|
||||
await page.addInitScript(
|
||||
({ audience, scope, selectedTheme }) => {
|
||||
window.localStorage.setItem("buzz:keep-addressed-agents-active", "1");
|
||||
window.localStorage.setItem(
|
||||
"buzz:persistent-agent-audiences:v2",
|
||||
JSON.stringify({ [scope]: audience }),
|
||||
);
|
||||
window.localStorage.setItem("buzz-theme", selectedTheme);
|
||||
},
|
||||
{ audience: pubkeys, scope: SCOPE, selectedTheme: theme },
|
||||
);
|
||||
}
|
||||
|
||||
async function openGeneral(page: Page) {
|
||||
await page.goto(`/#/channels/${CHANNEL_ID}`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
}
|
||||
|
||||
async function installAudienceFixtures(page: Page) {
|
||||
await installMockBridge(page, {
|
||||
managedAgents: [
|
||||
{
|
||||
pubkey: AGENT_A,
|
||||
name: "Morgarita",
|
||||
status: "running",
|
||||
channelNames: ["general"],
|
||||
},
|
||||
{
|
||||
pubkey: AGENT_B,
|
||||
name: "Vogue",
|
||||
status: "running",
|
||||
channelNames: ["general"],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
test("persistent agents restore through the native inline mention UI", async ({
|
||||
page,
|
||||
}) => {
|
||||
await seedAudience(page, [AGENT_B, AGENT_A]);
|
||||
await installAudienceFixtures(page);
|
||||
await openGeneral(page);
|
||||
|
||||
const input = page.getByTestId("message-input");
|
||||
await expect(input).toHaveText("@Vogue @Morgarita ");
|
||||
await expect(page.getByText("Talking to", { exact: true })).toHaveCount(0);
|
||||
await expect(input.locator(".agent-mention-highlight")).toHaveCount(2);
|
||||
|
||||
await input.fill("@Morgarita hello");
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
({ scope }) => {
|
||||
const stored = JSON.parse(
|
||||
localStorage.getItem("buzz:persistent-agent-audiences:v2") ?? "{}",
|
||||
);
|
||||
return stored[scope] ?? [];
|
||||
},
|
||||
{ scope: SCOPE },
|
||||
),
|
||||
)
|
||||
.toEqual([AGENT_A]);
|
||||
|
||||
await page.getByTestId("send-message").click();
|
||||
await expect(input).toContainText("@Morgarita");
|
||||
await expect(input).not.toContainText("@Vogue");
|
||||
await expect(input.locator(".agent-mention-highlight")).toHaveCount(1);
|
||||
});
|
||||
|
||||
for (const theme of ["buzz", "buzz-dark"]) {
|
||||
test(`captures native persistent mentions in ${theme}`, async ({ page }) => {
|
||||
await seedAudience(page, [AGENT_A, AGENT_B], theme);
|
||||
await installAudienceFixtures(page);
|
||||
await openGeneral(page);
|
||||
const composer = page.getByTestId("message-composer");
|
||||
await page.getByTestId("message-input").focus();
|
||||
await waitForAnimations(page);
|
||||
await composer.screenshot({
|
||||
path: `${SHOTS}/${theme}-native-mentions.png`,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("native persistent mentions fit the narrow composer", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 700, height: 760 });
|
||||
await seedAudience(page, [AGENT_A, AGENT_B]);
|
||||
await installAudienceFixtures(page);
|
||||
await openGeneral(page);
|
||||
const composer = page.getByTestId("message-composer");
|
||||
await expect(page.getByTestId("message-input")).toContainText("@Morgarita");
|
||||
await waitForAnimations(page);
|
||||
await composer.screenshot({ path: `${SHOTS}/narrow-native-mentions.png` });
|
||||
});
|
||||
Reference in New Issue
Block a user