mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
fix(desktop): unify owned-agent cloud provenance markers (#7129)
Unify presentation-only cloud provenance across agent identity surfaces. Keep successful local-inventory and verified-ownership gates; preserve main availability and mention spacing behavior. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
@@ -78,6 +78,7 @@ export default defineConfig({
|
||||
"**/composer-tooltip-dismiss.spec.ts",
|
||||
"**/mentions.spec.ts",
|
||||
"**/mention-spacing.spec.ts",
|
||||
"**/cloud-provenance.spec.ts",
|
||||
"**/team-mentions.spec.ts",
|
||||
"**/persistent-agent-audience.spec.ts",
|
||||
"**/relay-reconnect.spec.ts",
|
||||
|
||||
@@ -216,6 +216,11 @@ with a TypeScript lookup table or an id comparison in a component.
|
||||
Unqueried persona siblings are unknown. No presence state grants deletion or
|
||||
Stop authority; native local stop-before-remove remains independent. See
|
||||
[the availability contract](../../../../docs/agent-availability.md).
|
||||
The shared cloud marker means “Not managed on this device” only
|
||||
after ownership and successful local inventory are known. It does not imply
|
||||
hosting location, availability, or permission. Keep all identity surfaces on
|
||||
the shared provenance context, without per-row directory subscriptions. See
|
||||
[the provenance contract](../../../../docs/agent-management-provenance.md).
|
||||
14. **Thinking effort has two surfaces: a local-only WRITE control and a
|
||||
read-only two-facts DISPLAY.** The write control is `EffortPickerField`
|
||||
(`ui/EffortPickerField.tsx`), a self-contained section component mounted in
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { isOtherSetupAgent } from "./otherSetupAgent.ts";
|
||||
import {
|
||||
isOtherSetupAgent,
|
||||
isOwnedAgentNotManagedOnDevice,
|
||||
} from "./otherSetupAgent.ts";
|
||||
|
||||
const OWNER = "a".repeat(64);
|
||||
const AGENT = "b".repeat(64);
|
||||
@@ -20,7 +23,7 @@ test("fails closed while the local managed directory is unresolved", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("labels a viewer-owned non-local identity as another setup", () => {
|
||||
test("labels a viewer-owned identity as not managed on this device", () => {
|
||||
assert.equal(
|
||||
isOtherSetupAgent({
|
||||
agentDirectoriesReady: true,
|
||||
@@ -33,3 +36,38 @@ test("labels a viewer-owned non-local identity as another setup", () => {
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("a locally managed provider is not labeled as another device", () => {
|
||||
assert.equal(
|
||||
isOtherSetupAgent({
|
||||
agentDirectoriesReady: true,
|
||||
currentPubkey: OWNER,
|
||||
managedAgents: [{ pubkey: AGENT, backend: { type: "provider" } }],
|
||||
profileOwnerPubkey: OWNER,
|
||||
pubkey: AGENT,
|
||||
relayAgents: [],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
for (const [name, overrides, expected] of [
|
||||
["owned absent key", {}, true],
|
||||
["loading local inventory", { localInventoryReady: false }, false],
|
||||
["exact local provider record", { isLocallyManaged: true }, false],
|
||||
["different owner", { ownerPubkey: "b".repeat(64) }, false],
|
||||
["unknown ownership", { ownerPubkey: null }, false],
|
||||
]) {
|
||||
test(`shared provenance: ${name}`, () => {
|
||||
assert.equal(
|
||||
isOwnedAgentNotManagedOnDevice({
|
||||
currentPubkey: "a".repeat(64),
|
||||
ownerPubkey: "A".repeat(64),
|
||||
localInventoryReady: true,
|
||||
isLocallyManaged: false,
|
||||
...overrides,
|
||||
}),
|
||||
expected,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ManagedAgent, RelayAgent } from "@/shared/api/types";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
/** Owned identity absent from the loaded local inventory; not evidence of hosting location. */
|
||||
export function isOtherSetupAgent({
|
||||
agentDirectoriesReady,
|
||||
currentPubkey,
|
||||
@@ -32,8 +33,31 @@ export function isOtherSetupAgent({
|
||||
)?.ownerPubkey;
|
||||
const ownerPubkey = profileOwnerPubkey ?? relayOwnerPubkey;
|
||||
|
||||
return isOwnedAgentNotManagedOnDevice({
|
||||
currentPubkey,
|
||||
ownerPubkey,
|
||||
localInventoryReady: agentDirectoriesReady,
|
||||
isLocallyManaged: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** Presentation provenance only; neither hosting location nor availability. */
|
||||
export function isOwnedAgentNotManagedOnDevice({
|
||||
currentPubkey,
|
||||
ownerPubkey,
|
||||
localInventoryReady,
|
||||
isLocallyManaged,
|
||||
}: {
|
||||
currentPubkey?: string;
|
||||
ownerPubkey?: string | null;
|
||||
localInventoryReady: boolean;
|
||||
isLocallyManaged: boolean;
|
||||
}): boolean {
|
||||
return Boolean(
|
||||
ownerPubkey &&
|
||||
localInventoryReady &&
|
||||
!isLocallyManaged &&
|
||||
currentPubkey &&
|
||||
ownerPubkey &&
|
||||
normalizePubkey(ownerPubkey) === normalizePubkey(currentPubkey),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Cloud } from "lucide-react";
|
||||
|
||||
import { useIsOtherSetupAgent } from "../useKnownAgentPubkeys";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
const OTHER_SETUP_LABEL = "From another Buzz setup";
|
||||
const OTHER_SETUP_LABEL = "Not managed on this device";
|
||||
|
||||
export function OtherSetupAgentMarker({
|
||||
className,
|
||||
@@ -23,3 +25,21 @@ export function OtherSetupAgentMarker({
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Connected marker for identity details; shares the app's directory subscriptions. */
|
||||
export function AgentManagementMarker({
|
||||
pubkey,
|
||||
ownerPubkey,
|
||||
className,
|
||||
testId,
|
||||
}: {
|
||||
pubkey?: string | null;
|
||||
ownerPubkey?: string | null;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
}) {
|
||||
const show = useIsOtherSetupAgent(pubkey, ownerPubkey);
|
||||
return show ? (
|
||||
<OtherSetupAgentMarker className={className} testId={testId} />
|
||||
) : null;
|
||||
}
|
||||
|
||||
73
desktop/src/features/agents/useKnownAgentPubkeys.test.mjs
Normal file
73
desktop/src/features/agents/useKnownAgentPubkeys.test.mjs
Normal file
@@ -0,0 +1,73 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, test } from "node:test";
|
||||
import { createElement } from "react";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import {
|
||||
KnownAgentPubkeysProvider,
|
||||
useIsOtherSetupAgent,
|
||||
} from "./useKnownAgentPubkeys.tsx";
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
url: "http://localhost",
|
||||
});
|
||||
Object.assign(globalThis, {
|
||||
window: dom.window,
|
||||
document: dom.window.document,
|
||||
HTMLElement: dom.window.HTMLElement,
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
});
|
||||
after(() => dom.window.close());
|
||||
|
||||
test("provenance context follows exact local inventory and rejects failed cached reads", async () => {
|
||||
const { act, renderHook, cleanup } = await import("@testing-library/react");
|
||||
const owner = "a".repeat(64),
|
||||
remote = "b".repeat(64),
|
||||
local = "c".repeat(64);
|
||||
const client = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { enabled: false, retry: false, staleTime: Infinity },
|
||||
},
|
||||
});
|
||||
client.setQueryData(["identity"], { pubkey: owner });
|
||||
client.setQueryData(
|
||||
["managed-agents"],
|
||||
[{ pubkey: local, status: "stopped" }],
|
||||
);
|
||||
client.setQueryData(
|
||||
["relay-agents"],
|
||||
[{ pubkey: remote, ownerPubkey: owner }],
|
||||
);
|
||||
const wrapper = ({ children }) =>
|
||||
createElement(
|
||||
QueryClientProvider,
|
||||
{ client },
|
||||
createElement(KnownAgentPubkeysProvider, null, children),
|
||||
);
|
||||
const { result } = renderHook(
|
||||
() => [
|
||||
useIsOtherSetupAgent(remote),
|
||||
useIsOtherSetupAgent(local, owner),
|
||||
useIsOtherSetupAgent("d".repeat(64), owner),
|
||||
],
|
||||
{ wrapper },
|
||||
);
|
||||
assert.deepEqual(result.current, [true, false, true]);
|
||||
await act(async () =>
|
||||
client.setQueryData(
|
||||
["managed-agents"],
|
||||
[{ pubkey: remote, status: "deployed" }],
|
||||
),
|
||||
);
|
||||
assert.deepEqual(result.current, [false, true, true]);
|
||||
await act(async () => {
|
||||
client
|
||||
.getQueryCache()
|
||||
.find({ queryKey: ["managed-agents"] })
|
||||
.setState({ error: new Error("inventory unavailable"), status: "error" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
});
|
||||
assert.deepEqual(result.current, [false, false, false]);
|
||||
cleanup();
|
||||
client.clear();
|
||||
});
|
||||
@@ -5,10 +5,25 @@ import {
|
||||
useRelayAgentsQuery,
|
||||
} from "@/features/agents/hooks";
|
||||
import { mergeKnownAgentPubkeys } from "@/features/agents/knownAgentPubkeys";
|
||||
import { useStableSet } from "@/shared/hooks/useStableReference";
|
||||
import { useStableMap, useStableSet } from "@/shared/hooks/useStableReference";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { isAgentDirectoryReady } from "./lib/agentAutocompleteEligibility";
|
||||
import { isOwnedAgentNotManagedOnDevice } from "./lib/otherSetupAgent";
|
||||
|
||||
const EMPTY_KNOWN_AGENT_PUBKEYS: ReadonlySet<string> = new Set();
|
||||
|
||||
const AgentManagementContext = React.createContext<{
|
||||
currentPubkey?: string;
|
||||
localInventoryReady: boolean;
|
||||
localPubkeys: ReadonlySet<string>;
|
||||
relayOwners: ReadonlyMap<string, string | null>;
|
||||
}>({
|
||||
localInventoryReady: false,
|
||||
localPubkeys: new Set(),
|
||||
relayOwners: new Map(),
|
||||
});
|
||||
|
||||
const KnownAgentPubkeysContext = React.createContext<ReadonlySet<string>>(
|
||||
EMPTY_KNOWN_AGENT_PUBKEYS,
|
||||
);
|
||||
@@ -39,8 +54,36 @@ export function KnownAgentPubkeysProvider({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const managedAgents = useManagedAgentsQuery().data;
|
||||
const relayAgents = useRelayAgentsQuery().data;
|
||||
const managedQuery = useManagedAgentsQuery();
|
||||
const relayQuery = useRelayAgentsQuery();
|
||||
const currentPubkey = useIdentityQuery().data?.pubkey;
|
||||
const managedAgents = managedQuery.data;
|
||||
const relayAgents = relayQuery.data;
|
||||
const localPubkeys = useStableSet(
|
||||
new Set(
|
||||
(managedAgents ?? []).map((agent) => normalizePubkey(agent.pubkey)),
|
||||
),
|
||||
);
|
||||
const relayOwners = useStableMap(
|
||||
new Map(
|
||||
isAgentDirectoryReady(relayQuery)
|
||||
? (relayAgents ?? []).map((agent) => [
|
||||
normalizePubkey(agent.pubkey),
|
||||
agent.ownerPubkey,
|
||||
])
|
||||
: [],
|
||||
),
|
||||
);
|
||||
const localInventoryReady = isAgentDirectoryReady(managedQuery);
|
||||
const management = React.useMemo(
|
||||
() => ({
|
||||
currentPubkey,
|
||||
localInventoryReady,
|
||||
localPubkeys,
|
||||
relayOwners,
|
||||
}),
|
||||
[currentPubkey, localInventoryReady, localPubkeys, relayOwners],
|
||||
);
|
||||
|
||||
const merged = React.useMemo(
|
||||
() => mergeKnownAgentPubkeys(managedAgents, relayAgents),
|
||||
@@ -50,7 +93,9 @@ export function KnownAgentPubkeysProvider({
|
||||
|
||||
return (
|
||||
<KnownAgentPubkeysContext.Provider value={stable}>
|
||||
{children}
|
||||
<AgentManagementContext.Provider value={management}>
|
||||
{children}
|
||||
</AgentManagementContext.Provider>
|
||||
</KnownAgentPubkeysContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -82,3 +127,21 @@ export function KnownAgentPubkeysProvider({
|
||||
export function useKnownAgentPubkeys(): ReadonlySet<string> {
|
||||
return React.useContext(KnownAgentPubkeysContext);
|
||||
}
|
||||
|
||||
/** Shared provenance without per-row query observers; exact keys, never personas. */
|
||||
export function useIsOtherSetupAgent(
|
||||
pubkey?: string | null,
|
||||
profileOwnerPubkey?: string | null,
|
||||
): boolean {
|
||||
const state = React.useContext(AgentManagementContext);
|
||||
const key = normalizePubkey(pubkey ?? "");
|
||||
return (
|
||||
Boolean(key) &&
|
||||
isOwnedAgentNotManagedOnDevice({
|
||||
currentPubkey: state.currentPubkey,
|
||||
ownerPubkey: profileOwnerPubkey ?? state.relayOwners.get(key),
|
||||
localInventoryReady: state.localInventoryReady,
|
||||
isLocallyManaged: state.localPubkeys.has(key),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AgentManagementMarker } from "@/features/agents/ui/OtherSetupAgentMarker";
|
||||
import { Bot } from "lucide-react";
|
||||
import type { UserSearchResult } from "@/shared/api/types";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
@@ -60,6 +61,10 @@ export function AddMemberSearchResultRow({
|
||||
<Bot aria-hidden="true" className="h-4 w-4" />
|
||||
agent
|
||||
</span>
|
||||
<AgentManagementMarker
|
||||
pubkey={user.pubkey}
|
||||
ownerPubkey={user.ownerPubkey}
|
||||
/>
|
||||
</div>
|
||||
<span className="block truncate font-mono text-2xs text-muted-foreground">
|
||||
{truncatePubkey(user.pubkey)}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ProfileAvatarWithStatus,
|
||||
scaleProfileAvatarStatusGeometry,
|
||||
} from "@/features/profile/ui/ProfileAvatarWithStatus";
|
||||
import { AgentManagementMarker } from "@/features/agents/ui/OtherSetupAgentMarker";
|
||||
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import type { Channel, PresenceStatus } from "@/shared/api/types";
|
||||
@@ -194,9 +195,17 @@ export function ChannelScreenHeader({
|
||||
) : undefined
|
||||
}
|
||||
statusBadge={
|
||||
<ChannelHeaderStatusBadge
|
||||
ephemeralDisplay={activeChannelEphemeralDisplay}
|
||||
/>
|
||||
<>
|
||||
<ChannelHeaderStatusBadge
|
||||
ephemeralDisplay={activeChannelEphemeralDisplay}
|
||||
/>
|
||||
{!isGroupDm && activeDmParticipant ? (
|
||||
<AgentManagementMarker
|
||||
pubkey={activeDmParticipant.pubkey}
|
||||
testId="chat-header-agent-provenance"
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
title={activeChannelTitle}
|
||||
transparentChrome={transparentChrome}
|
||||
|
||||
@@ -12,10 +12,8 @@ import {
|
||||
coalesceAgentAutocompleteCandidates,
|
||||
getMentionableAgentPubkeys,
|
||||
getSharedChannelIds,
|
||||
isAgentDirectoryReady,
|
||||
isAgentIdentityInAllowedList,
|
||||
} from "@/features/agents/lib/agentAutocompleteEligibility";
|
||||
import { isOtherSetupAgent } from "@/features/agents/lib/otherSetupAgent";
|
||||
import { useIsArchivedPredicate } from "@/features/identity-archive/hooks";
|
||||
import { useClassifiedMembers } from "@/features/channels/lib/useClassifiedMembers";
|
||||
import { formatMemberName } from "@/features/channels/lib/memberUtils";
|
||||
@@ -184,9 +182,6 @@ export function MembersSidebar({
|
||||
managedAgentsQuery,
|
||||
relayAgentsQuery,
|
||||
} = useClassifiedMembers(rawMembers, currentPubkey);
|
||||
const agentDirectoriesReady =
|
||||
isAgentDirectoryReady(managedAgentsQuery) &&
|
||||
isAgentDirectoryReady(relayAgentsQuery);
|
||||
const activeMembers = React.useMemo(
|
||||
() =>
|
||||
[...people, ...bots].sort((left, right) =>
|
||||
@@ -618,16 +613,6 @@ export function MembersSidebar({
|
||||
const managedAgent = memberIsBot
|
||||
? managedAgentByPubkey.get(normalizePubkey(member.pubkey))
|
||||
: undefined;
|
||||
const showOtherSetupMarker =
|
||||
memberIsBot &&
|
||||
isOtherSetupAgent({
|
||||
agentDirectoriesReady,
|
||||
currentPubkey,
|
||||
managedAgents: managedAgentsQuery.data ?? [],
|
||||
profileOwnerPubkey: memberProfile?.ownerPubkey,
|
||||
pubkey: member.pubkey,
|
||||
relayAgents: relayAgentsQuery.data ?? [],
|
||||
});
|
||||
const managedAgentRuntime =
|
||||
memberIsBot && relayUrl
|
||||
? findManagedAgentRuntime(
|
||||
@@ -685,7 +670,7 @@ export function MembersSidebar({
|
||||
pairAction={pairAction}
|
||||
presenceStatus={getAvailability(member.pubkey)}
|
||||
profileAvatarUrl={memberProfile?.avatarUrl ?? null}
|
||||
showOtherSetupMarker={showOtherSetupMarker}
|
||||
profileOwnerPubkey={memberProfile?.ownerPubkey}
|
||||
viewerIsOwner={viewerIsOwner}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
getManagedAgentPrimaryActionLabel,
|
||||
isManagedAgentActive,
|
||||
} from "@/features/agents/lib/managedAgentControlActions";
|
||||
import { OtherSetupAgentMarker } from "@/features/agents/ui/OtherSetupAgentMarker";
|
||||
import { AgentManagementMarker } from "@/features/agents/ui/OtherSetupAgentMarker";
|
||||
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
|
||||
import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
|
||||
import {
|
||||
@@ -75,7 +75,7 @@ type MembersSidebarMemberCardProps = {
|
||||
onViewActivity?: (pubkey: string) => void;
|
||||
presenceStatus?: PresenceStatus | null;
|
||||
profileAvatarUrl?: string | null;
|
||||
showOtherSetupMarker?: boolean;
|
||||
profileOwnerPubkey?: string | null;
|
||||
viewerIsOwner: boolean;
|
||||
};
|
||||
|
||||
@@ -144,7 +144,7 @@ export function MembersSidebarMemberCard({
|
||||
onViewActivity,
|
||||
presenceStatus,
|
||||
profileAvatarUrl,
|
||||
showOtherSetupMarker = false,
|
||||
profileOwnerPubkey,
|
||||
viewerIsOwner,
|
||||
}: MembersSidebarMemberCardProps) {
|
||||
const roleLabel = formatRoleLabel(member, memberIsBot);
|
||||
@@ -199,11 +199,11 @@ export function MembersSidebarMemberCard({
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{showOtherSetupMarker ? (
|
||||
<OtherSetupAgentMarker
|
||||
testId={`sidebar-member-agent-provenance-${member.pubkey}`}
|
||||
/>
|
||||
) : null}
|
||||
<AgentManagementMarker
|
||||
pubkey={member.pubkey}
|
||||
ownerPubkey={profileOwnerPubkey}
|
||||
testId={`sidebar-member-agent-provenance-${member.pubkey}`}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
|
||||
@@ -149,3 +149,22 @@ test("policy-only discovery stays selectable without claiming active presence",
|
||||
assert.equal(candidate.isActiveAgent, false);
|
||||
assert.equal(candidate.ownerPubkey, MEMBER_PUBKEY);
|
||||
});
|
||||
|
||||
for (const locallyManaged of [true, false]) {
|
||||
test(`roster candidate preserves exact local management: ${locallyManaged}`, () => {
|
||||
const [candidate] = buildMentionCandidates(
|
||||
input({
|
||||
members: [{ pubkey: AGENT_PUBKEY, displayName: "Scout", role: "bot" }],
|
||||
managedAgentNamesByPubkey: new Map(
|
||||
locallyManaged ? [[AGENT_PUBKEY, "Scout"]] : [],
|
||||
),
|
||||
managedAgents: locallyManaged
|
||||
? [{ pubkey: AGENT_PUBKEY, name: "Scout", status: "deployed" }]
|
||||
: [],
|
||||
mentionableAgentPubkeys: new Set([AGENT_PUBKEY]),
|
||||
}),
|
||||
);
|
||||
assert.equal(candidate.isMember, true);
|
||||
assert.equal(Boolean(candidate.isManagedAgent), locallyManaged);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -154,6 +154,7 @@ export function buildMentionCandidates({
|
||||
managedAgentNamesByPubkey.has(pubkey) ||
|
||||
relayAgentNamesByPubkey.has(pubkey),
|
||||
isActiveAgent: activeAgentPubkeys.has(pubkey),
|
||||
isManagedAgent: managedAgentNamesByPubkey.has(pubkey),
|
||||
ownerPubkey: profile?.ownerPubkey ?? null,
|
||||
personaName: personaNameByPubkey.get(pubkey) ?? null,
|
||||
role: member.role,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isOwnedAgentNotManagedOnDevice } from "@/features/agents/lib/otherSetupAgent";
|
||||
import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { formatOwnerLabel } from "@/features/profile/lib/identity";
|
||||
@@ -61,10 +62,12 @@ export function mapMentionCandidateToSuggestion(opts: {
|
||||
agentProvenanceReady && candidate.kind === "identity" && candidate.isAgent
|
||||
? candidate.isManagedAgent
|
||||
? "managed-here"
|
||||
: candidate.ownerPubkey &&
|
||||
currentPubkey &&
|
||||
normalizePubkey(candidate.ownerPubkey) ===
|
||||
normalizePubkey(currentPubkey)
|
||||
: isOwnedAgentNotManagedOnDevice({
|
||||
currentPubkey: currentPubkey ?? undefined,
|
||||
ownerPubkey: candidate.ownerPubkey,
|
||||
localInventoryReady: agentProvenanceReady,
|
||||
isLocallyManaged: Boolean(candidate.isManagedAgent),
|
||||
})
|
||||
? "managed-elsewhere"
|
||||
: undefined
|
||||
: undefined,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AgentManagementMarker } from "@/features/agents/ui/OtherSetupAgentMarker";
|
||||
import { ArrowUp, AtSign, Square, X } from "lucide-react";
|
||||
import {
|
||||
AnimatePresence,
|
||||
@@ -270,7 +271,7 @@ export function ComposerMentionButton({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Don't automatically mention {agent.displayName} in this
|
||||
thread
|
||||
thread <AgentManagementMarker pubkey={agent.pubkey} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
|
||||
@@ -334,25 +334,56 @@ function suggestion(agentProvenance) {
|
||||
|
||||
test("duplicate owned agents mark only the other setup", () => {
|
||||
assert.equal(
|
||||
showMentionAgentProvenanceMarker(suggestion("managed-here"), true),
|
||||
showMentionAgentProvenanceMarker(suggestion("managed-here")),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
showMentionAgentProvenanceMarker(suggestion("managed-elsewhere"), true),
|
||||
showMentionAgentProvenanceMarker(suggestion("managed-elsewhere")),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("unique agents omit management provenance", () => {
|
||||
test("unique agents mark the same not-locally-managed provenance", () => {
|
||||
assert.equal(
|
||||
showMentionAgentProvenanceMarker(suggestion("managed-here"), false),
|
||||
showMentionAgentProvenanceMarker(suggestion("managed-elsewhere")),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
showMentionAgentProvenanceMarker(suggestion("managed-here")),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("agents without trustworthy provenance omit management provenance", () => {
|
||||
assert.equal(
|
||||
showMentionAgentProvenanceMarker(suggestion(undefined), true),
|
||||
false,
|
||||
);
|
||||
assert.equal(showMentionAgentProvenanceMarker(suggestion(undefined)), false);
|
||||
});
|
||||
|
||||
for (const duplicate of [false, true]) {
|
||||
test(`rendered provenance is truthful (${duplicate ? "duplicate" : "unique"} names)`, async () => {
|
||||
const React = await import("react");
|
||||
const { render } = await import("@testing-library/react");
|
||||
const { MentionAutocomplete } = await import("./MentionAutocomplete.tsx");
|
||||
const { TooltipProvider } = await import("@/shared/ui/tooltip");
|
||||
const remote = suggestion("managed-elsewhere");
|
||||
const suggestions = duplicate
|
||||
? [remote, { ...suggestion("managed-here"), pubkey: "2".repeat(64) }]
|
||||
: [remote];
|
||||
const view = render(
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(MentionAutocomplete, {
|
||||
suggestions,
|
||||
selectedIndex: 0,
|
||||
onSelect() {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
const markers = view.getAllByRole("img", {
|
||||
name: "Not managed on this device",
|
||||
});
|
||||
assert.equal(markers.length, 1);
|
||||
assert.equal(markers[0].title, "Not managed on this device");
|
||||
assert.equal(view.queryByTitle("From another Buzz setup"), null);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -52,9 +52,8 @@ type MentionAutocompleteProps = {
|
||||
|
||||
export function showMentionAgentProvenanceMarker(
|
||||
suggestion: MentionSuggestion,
|
||||
hasNameCollision: boolean,
|
||||
): boolean {
|
||||
return hasNameCollision && suggestion.agentProvenance === "managed-elsewhere";
|
||||
return suggestion.agentProvenance === "managed-elsewhere";
|
||||
}
|
||||
|
||||
/** Focus the now-always-visible automatic-mentions switch. */
|
||||
@@ -254,10 +253,8 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
|
||||
suggestion.displayName;
|
||||
const hasNameCollision =
|
||||
(nameCounts.get(suggestion.displayName.toLowerCase()) ?? 0) > 1;
|
||||
const showAgentProvenanceMarker = showMentionAgentProvenanceMarker(
|
||||
suggestion,
|
||||
hasNameCollision,
|
||||
);
|
||||
const showAgentProvenanceMarker =
|
||||
showMentionAgentProvenanceMarker(suggestion);
|
||||
const ownerLabel =
|
||||
hasNameCollision && suggestion.agentProvenance
|
||||
? null
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { AgentManagementMarker } from "@/features/agents/ui/OtherSetupAgentMarker";
|
||||
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
|
||||
@@ -37,6 +38,10 @@ export function MessageAgentAddressPrefix({
|
||||
interactive
|
||||
>
|
||||
{label}
|
||||
<AgentManagementMarker
|
||||
pubkey={pubkey}
|
||||
ownerPubkey={profile?.ownerPubkey}
|
||||
/>
|
||||
</InlineChip>
|
||||
</UserProfilePopover>{" "}
|
||||
</React.Fragment>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { AgentManagementMarker } from "@/features/agents/ui/OtherSetupAgentMarker";
|
||||
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
type MessageHeaderRowProps = {
|
||||
@@ -104,3 +107,40 @@ export function MessageAuthorText({
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
|
||||
/** Author navigation and provenance always refer to the same exact identity. */
|
||||
export function MessageAuthorIdentity({
|
||||
pubkey,
|
||||
ownerPubkey,
|
||||
role,
|
||||
displayName,
|
||||
children,
|
||||
}: {
|
||||
pubkey?: string | null;
|
||||
ownerPubkey?: string | null;
|
||||
role?: string;
|
||||
displayName: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{pubkey ? (
|
||||
<UserProfilePopover
|
||||
pubkey={pubkey}
|
||||
role={role}
|
||||
botIdenticonValue={displayName}
|
||||
>
|
||||
<button
|
||||
className="truncate rounded leading-message-author focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
type="button"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</UserProfilePopover>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
<AgentManagementMarker pubkey={pubkey} ownerPubkey={ownerPubkey} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ import { toast } from "sonner";
|
||||
import { MessageAgentOwner } from "./MessageAgentOwner";
|
||||
import {
|
||||
MessageAuthorText,
|
||||
MessageAuthorIdentity,
|
||||
MessageHeaderRow,
|
||||
MessageMetaSegments,
|
||||
} from "./MessageHeader";
|
||||
@@ -646,22 +647,14 @@ export const MessageRow = React.memo(
|
||||
|
||||
const headerNode = isDisplayedAsContinuation ? null : (
|
||||
<MessageHeaderRow>
|
||||
{message.pubkey ? (
|
||||
<UserProfilePopover
|
||||
pubkey={message.pubkey}
|
||||
role={profilePopoverRole}
|
||||
botIdenticonValue={message.author}
|
||||
>
|
||||
<button
|
||||
className="truncate rounded leading-message-author focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
type="button"
|
||||
>
|
||||
{authorNode}
|
||||
</button>
|
||||
</UserProfilePopover>
|
||||
) : (
|
||||
authorNode
|
||||
)}
|
||||
<MessageAuthorIdentity
|
||||
pubkey={message.pubkey}
|
||||
ownerPubkey={message.ownerPubkey}
|
||||
role={profilePopoverRole}
|
||||
displayName={message.author}
|
||||
>
|
||||
{authorNode}
|
||||
</MessageAuthorIdentity>
|
||||
{/* Author is not a segment: "Alice 9:53 AM" needs no divider. */}
|
||||
<MessageMetaSegments
|
||||
segments={[
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AgentManagementMarker } from "@/features/agents/ui/OtherSetupAgentMarker";
|
||||
import { Bot } from "lucide-react";
|
||||
|
||||
import { formatOwnerLabel } from "@/features/profile/lib/identity";
|
||||
@@ -133,6 +134,10 @@ export function NewMessageResultRow({
|
||||
/>
|
||||
agent
|
||||
</span>
|
||||
<AgentManagementMarker
|
||||
pubkey={user.pubkey}
|
||||
ownerPubkey={user.ownerPubkey}
|
||||
/>
|
||||
</div>
|
||||
{ownerLabel ? (
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AgentManagementMarker } from "@/features/agents/ui/OtherSetupAgentMarker";
|
||||
import { SmilePlus } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
@@ -285,6 +286,7 @@ function ProfileName({
|
||||
interactive={Boolean(pubkey)}
|
||||
>
|
||||
{children}
|
||||
<AgentManagementMarker pubkey={pubkey} />
|
||||
</InlineChip>
|
||||
) : (
|
||||
<span
|
||||
@@ -295,6 +297,7 @@ function ProfileName({
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<AgentManagementMarker pubkey={pubkey} />
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -914,6 +917,10 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({
|
||||
<MessageAuthorText as="div" className="text-foreground">
|
||||
{description.title}
|
||||
</MessageAuthorText>
|
||||
<AgentManagementMarker
|
||||
pubkey={displayedIdentityPubkey}
|
||||
ownerPubkey={displayedOwnerPubkey}
|
||||
/>
|
||||
{displayedIdentityIsAgent ? (
|
||||
<>
|
||||
<MessageAgentOwner
|
||||
|
||||
@@ -759,6 +759,7 @@ export function UserProfilePanel({
|
||||
{
|
||||
agentSettingsMenu,
|
||||
effectivePubkey,
|
||||
ownerPubkey: profile?.ownerPubkey,
|
||||
logCopyValue: isDiagnosticsLikeView ? managedAgentLogContent : null,
|
||||
logSubtitle: logHeaderSubtitle,
|
||||
onBack: () => setView("summary"),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { AgentManagementMarker } from "@/features/agents/ui/OtherSetupAgentMarker";
|
||||
import { CopyButton } from "@/features/agents/ui/CopyButton";
|
||||
import { MemoryRefreshButton } from "@/features/agent-memory/ui/MemorySection";
|
||||
import {
|
||||
@@ -16,6 +17,7 @@ import { Button } from "@/shared/ui/button";
|
||||
export function getUserProfilePanelHeaderContent({
|
||||
agentSettingsMenu,
|
||||
effectivePubkey,
|
||||
ownerPubkey,
|
||||
logCopyValue,
|
||||
logSubtitle,
|
||||
onBack,
|
||||
@@ -25,6 +27,7 @@ export function getUserProfilePanelHeaderContent({
|
||||
}: {
|
||||
agentSettingsMenu: ReactNode;
|
||||
effectivePubkey: string | null;
|
||||
ownerPubkey?: string | null;
|
||||
logCopyValue?: string | null;
|
||||
logSubtitle?: string | null;
|
||||
onBack: () => void;
|
||||
@@ -47,6 +50,13 @@ export function getUserProfilePanelHeaderContent({
|
||||
subtitleTitle={logSubtitle ?? undefined}
|
||||
title={title}
|
||||
/>
|
||||
{view !== "summary" ? (
|
||||
<AgentManagementMarker
|
||||
pubkey={effectivePubkey}
|
||||
ownerPubkey={ownerPubkey}
|
||||
testId="user-profile-header-agent-provenance"
|
||||
/>
|
||||
) : null}
|
||||
</AuxiliaryPanelHeaderGroup>
|
||||
);
|
||||
const headerActions = (
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import * as React from "react";
|
||||
import { ChevronDown, ChevronUp, Pencil } from "lucide-react";
|
||||
|
||||
import { OtherSetupAgentMarker } from "@/features/agents/ui/OtherSetupAgentMarker";
|
||||
import { useIsOtherSetupAgent } from "@/features/agents/useKnownAgentPubkeys";
|
||||
import { useAgentWorking } from "@/features/agents/agentWorkingSignal";
|
||||
import { agentPresenceStartBlockReason } from "@/features/agents/lib/useAgentAvailability";
|
||||
import {
|
||||
@@ -189,6 +191,7 @@ export function ProfileSummaryView({
|
||||
unfollowMutation,
|
||||
userStatus,
|
||||
}: ProfileSummaryViewProps) {
|
||||
const notManagedOnDevice = useIsOtherSetupAgent(pubkey, profile?.ownerPubkey);
|
||||
const activeTurns = useAgentWorking(isBot ? pubkey : null).channels;
|
||||
const stickyLayoutRef = React.useRef<HTMLDivElement>(null);
|
||||
const [primaryActionsConcealed, setPrimaryActionsConcealed] =
|
||||
@@ -398,6 +401,7 @@ export function ProfileSummaryView({
|
||||
>
|
||||
<ProfileHero
|
||||
displayName={displayName}
|
||||
notManagedOnDevice={notManagedOnDevice}
|
||||
isBot={isBot}
|
||||
onEditAgent={canEditAgent ? handleEditAgent : undefined}
|
||||
presenceStatus={presenceStatus}
|
||||
@@ -600,6 +604,7 @@ export function ProfileSummaryView({
|
||||
// ── Hero & metadata ──────────────────────────────────────────────────────────
|
||||
|
||||
function ProfileHero({
|
||||
notManagedOnDevice,
|
||||
displayName,
|
||||
isBot,
|
||||
onEditAgent,
|
||||
@@ -608,6 +613,7 @@ function ProfileHero({
|
||||
userStatus,
|
||||
}: {
|
||||
displayName: string;
|
||||
notManagedOnDevice?: boolean;
|
||||
isBot: boolean;
|
||||
onEditAgent?: () => void;
|
||||
presenceStatus: "online" | "away" | "offline" | undefined;
|
||||
@@ -674,6 +680,7 @@ function ProfileHero({
|
||||
{displayName}
|
||||
</span>
|
||||
{botIndicator}
|
||||
{notManagedOnDevice ? <OtherSetupAgentMarker /> : null}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute top-1/2 left-full ml-1 -translate-y-1/2 text-muted-foreground opacity-0 transition-[color,opacity] duration-150 ease-out group-hover:text-foreground group-hover:opacity-100 group-focus-visible:opacity-100"
|
||||
@@ -690,6 +697,7 @@ function ProfileHero({
|
||||
>
|
||||
<span className="truncate">{displayName}</span>
|
||||
{botIndicator}
|
||||
{notManagedOnDevice ? <OtherSetupAgentMarker /> : null}
|
||||
</h3>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import * as React from "react";
|
||||
import { Activity, Headphones, MessageSquare } from "lucide-react";
|
||||
|
||||
import { AgentManagementMarker } from "@/features/agents/ui/OtherSetupAgentMarker";
|
||||
|
||||
import { useChannelsQuery } from "@/features/channels/hooks";
|
||||
import {
|
||||
useUserProfileQuery,
|
||||
@@ -390,6 +392,11 @@ function UserProfilePopoverBody({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<HoverPubkeyName displayName={displayName} pubkey={pubkey} />
|
||||
<AgentManagementMarker
|
||||
pubkey={pubkey}
|
||||
ownerPubkey={ownerPubkey}
|
||||
testId="user-profile-popover-agent-provenance"
|
||||
/>
|
||||
{isBotProfile && botIdenticonValue ? (
|
||||
<BotIdenticon
|
||||
value={botIdenticonValue}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ContextMenuTrigger,
|
||||
} from "@/shared/ui/context-menu";
|
||||
|
||||
import { AgentManagementMarker } from "@/features/agents/ui/OtherSetupAgentMarker";
|
||||
import { ChannelContextMenuItems } from "@/features/sidebar/ui/ChannelContextMenu";
|
||||
import type { ActiveChannelTurnSummary } from "@/features/agents/activeAgentTurnsStore";
|
||||
import { formatElapsed } from "@/features/agents/ui/agentSessionUtils";
|
||||
@@ -336,6 +337,12 @@ export function ChannelMenuButton({
|
||||
variant="sidebar"
|
||||
/>
|
||||
) : null}
|
||||
{channel.channelType === "dm" && dmParticipants?.length === 1 ? (
|
||||
<AgentManagementMarker
|
||||
pubkey={dmParticipants[0].pubkey}
|
||||
testId={`channel-agent-provenance-${channel.id}`}
|
||||
/>
|
||||
) : null}
|
||||
{activeWorking ? (
|
||||
<ChannelWorkingBadge
|
||||
channelName={channel.name}
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
resolveMessageLinkRenderTarget,
|
||||
type ParsedMessageLink,
|
||||
} from "@/features/messages/lib/messageLink";
|
||||
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
|
||||
import { renderAudioMessageAttachment } from "@/features/messages/ui/AudioMessageAttachment";
|
||||
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
@@ -23,7 +22,7 @@ import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
|
||||
import { useRelayOrigin } from "@/shared/lib/useRelayOrigin";
|
||||
import { AttachmentGroup } from "@/shared/ui/attachment";
|
||||
import { ConfigNudgeCard } from "@/shared/ui/config-nudge-attachment";
|
||||
import { InlineChip } from "@/shared/ui/InlineChip";
|
||||
import { MarkdownMention } from "./markdown/MarkdownMention";
|
||||
import { LinkPreviewList } from "@/shared/ui/link-preview-list";
|
||||
import { useSmoothCorners } from "@/shared/ui/smoothCorners";
|
||||
import {
|
||||
@@ -1576,48 +1575,9 @@ export function createMarkdownComponents(
|
||||
ul: ({ children }) => (
|
||||
<ul className={cn("list-disc", listClassName)}>{children}</ul>
|
||||
),
|
||||
mention: function MarkdownMention({
|
||||
children,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const { agentMentionPubkeysByName, mentionPubkeysByName } =
|
||||
useMarkdownRuntime();
|
||||
const mentionText = String(children ?? "");
|
||||
const mentionName = mentionText.replace(/^@/, "").trim().toLowerCase();
|
||||
const pubkey = mentionPubkeysByName?.[mentionName];
|
||||
const isAgentMention =
|
||||
pubkey !== undefined &&
|
||||
agentMentionPubkeysByName?.[mentionName] === pubkey;
|
||||
const mentionLabel = mentionText.replace(/^@/, "");
|
||||
// Only chips that actually open a profile get the clickable affordance.
|
||||
// A mention whose pubkey didn't resolve stays a plain chip — a pointer
|
||||
// cursor there promises a click that does nothing.
|
||||
const opensProfile = interactive && pubkey !== undefined;
|
||||
const mentionNode = (
|
||||
<InlineChip
|
||||
data-mention=""
|
||||
className={cn(isAgentMention && "agent-mention-highlight")}
|
||||
icon={isAgentMention ? "agent" : "human"}
|
||||
interactive={opensProfile}
|
||||
>
|
||||
{mentionLabel}
|
||||
</InlineChip>
|
||||
);
|
||||
|
||||
return opensProfile ? (
|
||||
<UserProfilePopover
|
||||
botIdenticonValue={mentionLabel}
|
||||
pubkey={pubkey}
|
||||
role={isAgentMention ? "bot" : undefined}
|
||||
triggerElement="span"
|
||||
>
|
||||
{mentionNode}
|
||||
</UserProfilePopover>
|
||||
) : (
|
||||
mentionNode
|
||||
);
|
||||
},
|
||||
mention: ({ children }: { children?: React.ReactNode }) => (
|
||||
<MarkdownMention interactive={interactive}>{children}</MarkdownMention>
|
||||
),
|
||||
emoji: ({ src, alt }: { src?: string; alt?: string }) => {
|
||||
const resolvedSrc = src ? rewriteRelayUrl(src) : src;
|
||||
if (!resolvedSrc) {
|
||||
|
||||
52
desktop/src/shared/ui/markdown/MarkdownMention.tsx
Normal file
52
desktop/src/shared/ui/markdown/MarkdownMention.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import type * as React from "react";
|
||||
import { AgentManagementMarker } from "@/features/agents/ui/OtherSetupAgentMarker";
|
||||
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
|
||||
import { InlineChip } from "@/shared/ui/InlineChip";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { useMarkdownRuntime } from "./runtimeContext";
|
||||
|
||||
/** Exact-identity mention chip and its shared management provenance. */
|
||||
export function MarkdownMention({
|
||||
children,
|
||||
interactive,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
interactive: boolean;
|
||||
}) {
|
||||
const { agentMentionPubkeysByName, mentionPubkeysByName } =
|
||||
useMarkdownRuntime();
|
||||
const mentionText = String(children ?? "");
|
||||
const mentionName = mentionText.replace(/^@/, "").trim().toLowerCase();
|
||||
const pubkey = mentionPubkeysByName?.[mentionName];
|
||||
const isAgentMention =
|
||||
pubkey !== undefined && agentMentionPubkeysByName?.[mentionName] === pubkey;
|
||||
const mentionLabel = mentionText.replace(/^@/, "");
|
||||
// Only chips that actually open a profile get the clickable affordance.
|
||||
// A mention whose pubkey didn't resolve stays a plain chip — a pointer
|
||||
// cursor there promises a click that does nothing.
|
||||
const opensProfile = interactive && pubkey !== undefined;
|
||||
const mentionNode = (
|
||||
<InlineChip
|
||||
data-mention=""
|
||||
className={cn(isAgentMention && "agent-mention-highlight")}
|
||||
icon={isAgentMention ? "agent" : "human"}
|
||||
interactive={opensProfile}
|
||||
>
|
||||
{mentionLabel}
|
||||
{isAgentMention ? <AgentManagementMarker pubkey={pubkey} /> : null}
|
||||
</InlineChip>
|
||||
);
|
||||
|
||||
return opensProfile ? (
|
||||
<UserProfilePopover
|
||||
botIdenticonValue={mentionLabel}
|
||||
pubkey={pubkey}
|
||||
role={isAgentMention ? "bot" : undefined}
|
||||
triggerElement="span"
|
||||
>
|
||||
{mentionNode}
|
||||
</UserProfilePopover>
|
||||
) : (
|
||||
mentionNode
|
||||
);
|
||||
}
|
||||
200
desktop/tests/e2e/cloud-provenance.spec.ts
Normal file
200
desktop/tests/e2e/cloud-provenance.spec.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { waitForAnimations } from "../helpers/animations";
|
||||
import { installMockBridge, openNewMessagePage } from "../helpers/bridge";
|
||||
|
||||
const OWNER = "deadbeef".repeat(8);
|
||||
const REMOTE = "ed".repeat(32);
|
||||
const GENERAL = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
|
||||
const LABEL = "Not managed on this device";
|
||||
|
||||
// The delayed fixture exceeds the marker assertion's 5s deadline, proving that
|
||||
// source readiness is awaited independently of whether a cloud ever renders.
|
||||
for (const agentListDelayMs of [0, 6_000]) {
|
||||
test(`existing owned member uses one cloud across picker, members, author, chip, hover, profile and DM (directory delay ${agentListDelayMs}ms)`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await installMockBridge(page, {
|
||||
agentListDelayMs,
|
||||
managedAgents: [],
|
||||
searchProfiles: [
|
||||
{
|
||||
pubkey: REMOTE,
|
||||
displayName: "Remote Scout",
|
||||
ownerPubkey: OWNER,
|
||||
isAgent: true,
|
||||
},
|
||||
],
|
||||
relayAgents: [
|
||||
{
|
||||
pubkey: REMOTE,
|
||||
name: "Remote Scout",
|
||||
ownerPubkey: OWNER,
|
||||
respondTo: "owner-only",
|
||||
channelNames: ["general"],
|
||||
status: "offline",
|
||||
},
|
||||
],
|
||||
});
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
// The channel shell can mount before the provider's directory queries settle.
|
||||
// Wait for successful source reads, not a delay or a larger marker timeout:
|
||||
// the assertions below must still fail if ready data renders no cloud.
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(() => {
|
||||
const client = window.__BUZZ_E2E_QUERY_CLIENT__;
|
||||
return Object.fromEntries(
|
||||
["identity", "managed-agents", "relay-agents"].map((key) => {
|
||||
const state = client?.getQueryState([key]);
|
||||
return [
|
||||
key,
|
||||
{ status: state?.status, fetchStatus: state?.fetchStatus },
|
||||
];
|
||||
}),
|
||||
);
|
||||
}),
|
||||
{
|
||||
message:
|
||||
"Cloud provenance requires successful identity and agent directory reads",
|
||||
timeout: 10_000,
|
||||
},
|
||||
)
|
||||
.toMatchObject({
|
||||
identity: { status: "success" },
|
||||
"managed-agents": { status: "success" },
|
||||
"relay-agents": { status: "success" },
|
||||
});
|
||||
// Fixture setup only: the UI workflow starts with membership and a DM already present.
|
||||
const dm = await page.evaluate(
|
||||
async ({ pubkey, channelId }) => {
|
||||
await window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__?.("add_channel_members", {
|
||||
channelId,
|
||||
pubkeys: [pubkey],
|
||||
role: "bot",
|
||||
});
|
||||
const dm = (await window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__?.("open_dm", {
|
||||
pubkeys: [pubkey],
|
||||
})) as { id: string };
|
||||
await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({
|
||||
queryKey: ["channels"],
|
||||
});
|
||||
return dm;
|
||||
},
|
||||
{ pubkey: REMOTE, channelId: GENERAL },
|
||||
);
|
||||
await page.getByTestId("channel-general").click();
|
||||
const input = page.getByTestId("message-input");
|
||||
await input.fill("@Remote");
|
||||
const suggestion = page.getByTestId(`mention-suggestion-${REMOTE}`);
|
||||
await expect(
|
||||
suggestion.getByRole("img", { name: LABEL }).locator("svg.lucide-cloud"),
|
||||
).toBeVisible();
|
||||
await waitForAnimations(page);
|
||||
await page
|
||||
.getByTestId("mention-autocomplete")
|
||||
.screenshot({ path: testInfo.outputPath("cloud-picker.png") });
|
||||
await input.press("Escape");
|
||||
await input.fill("");
|
||||
|
||||
await page.getByTestId("channel-members-trigger").click();
|
||||
const members = page.getByTestId("members-sidebar");
|
||||
const memberMarker = page.getByTestId(
|
||||
`sidebar-member-agent-provenance-${REMOTE}`,
|
||||
);
|
||||
await expect(memberMarker).toHaveAttribute("aria-label", LABEL);
|
||||
await expect(memberMarker.locator("svg.lucide-cloud")).toBeVisible();
|
||||
await waitForAnimations(page);
|
||||
await members.screenshot({
|
||||
path: testInfo.outputPath("cloud-members.png"),
|
||||
});
|
||||
await members.getByRole("button", { name: "Close", exact: true }).click();
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
|
||||
channelName: "general",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
await page.evaluate((pubkey) => {
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "general",
|
||||
content: "Cloud provenance example: @Remote Scout",
|
||||
pubkey,
|
||||
// A self-reference is non-notifying, but still resolves a mention chip.
|
||||
extraTags: [["mention", pubkey]],
|
||||
kind: 40002,
|
||||
});
|
||||
}, REMOTE);
|
||||
const article = page
|
||||
.getByTestId("message-row")
|
||||
.filter({ hasText: "Cloud provenance example:" });
|
||||
await expect(
|
||||
article.getByTestId("message-header").getByRole("img", { name: LABEL }),
|
||||
).toBeVisible();
|
||||
const chip = article.locator("[data-mention]");
|
||||
await expect(chip.locator("svg.lucide-cloud")).toBeVisible();
|
||||
await waitForAnimations(page);
|
||||
await article.screenshot({
|
||||
path: testInfo.outputPath("cloud-author-chip.png"),
|
||||
});
|
||||
await chip.hover();
|
||||
const hover = page.getByTestId("user-profile-popover");
|
||||
await expect(
|
||||
hover.getByTestId("user-profile-popover-agent-provenance"),
|
||||
).toHaveAttribute("title", LABEL);
|
||||
await waitForAnimations(page);
|
||||
await hover.screenshot({ path: testInfo.outputPath("cloud-hover.png") });
|
||||
await chip.click();
|
||||
const panel = page.getByTestId("user-profile-panel");
|
||||
await expect(
|
||||
panel.getByRole("img", { name: LABEL }).locator("svg.lucide-cloud"),
|
||||
).toBeVisible();
|
||||
await waitForAnimations(page);
|
||||
await panel.screenshot({ path: testInfo.outputPath("cloud-profile.png") });
|
||||
await page.getByTestId("auxiliary-panel-close").click();
|
||||
await expect(
|
||||
page
|
||||
.getByTestId(`channel-agent-provenance-${dm.id}`)
|
||||
.locator("svg.lucide-cloud"),
|
||||
).toBeVisible();
|
||||
await page.locator(`[data-channel-id="${dm.id}"]`).click();
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("chat-header-agent-provenance")
|
||||
.locator("svg.lucide-cloud"),
|
||||
).toBeVisible();
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("cloud-dm.png") });
|
||||
await openNewMessagePage(page);
|
||||
await page.getByTestId("new-dm-search").fill("Remote Scout");
|
||||
const recipient = page.getByTestId(`new-dm-result-${REMOTE}`);
|
||||
await expect(
|
||||
recipient.getByRole("img", { name: LABEL }).locator("svg.lucide-cloud"),
|
||||
).toBeVisible();
|
||||
await waitForAnimations(page);
|
||||
await recipient.screenshot({
|
||||
path: testInfo.outputPath("cloud-recipient.png"),
|
||||
});
|
||||
await page.getByTestId("channel-random").click();
|
||||
await page.getByTestId("channel-members-trigger").click();
|
||||
await page.getByPlaceholder("Add people and agents").fill("Remote Scout");
|
||||
const addResult = page.getByTestId(`channel-user-search-result-${REMOTE}`);
|
||||
await expect(
|
||||
addResult.getByRole("img", { name: LABEL }).locator("svg.lucide-cloud"),
|
||||
).toBeVisible();
|
||||
await waitForAnimations(page);
|
||||
await addResult.screenshot({
|
||||
path: testInfo.outputPath("cloud-add-member-picker.png"),
|
||||
});
|
||||
// No invitation or local lifecycle action is exercised by this presentation test.
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Invite", exact: true }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
@@ -386,11 +386,11 @@ test("duplicate owned agents preserve provenance and exact pubkey selection", as
|
||||
);
|
||||
await expect(relayProvenanceMarker).toHaveAttribute(
|
||||
"aria-label",
|
||||
"From another Buzz setup",
|
||||
"Not managed on this device",
|
||||
);
|
||||
await expect(relayProvenanceMarker).toHaveAttribute(
|
||||
"title",
|
||||
"From another Buzz setup",
|
||||
"Not managed on this device",
|
||||
);
|
||||
await expect(relayProvenanceMarker).toBeVisible();
|
||||
await expect(relayProvenanceMarker).toHaveText("");
|
||||
@@ -465,7 +465,7 @@ test("duplicate owned agents preserve provenance and exact pubkey selection", as
|
||||
).toHaveCount(0);
|
||||
await expect(remoteSidebarMarker).toHaveAttribute(
|
||||
"aria-label",
|
||||
"From another Buzz setup",
|
||||
"Not managed on this device",
|
||||
);
|
||||
await expect(remoteSidebarMarker).toHaveText("");
|
||||
await expect(remoteSidebarMarker.locator("svg")).toBeVisible();
|
||||
|
||||
30
docs/agent-management-provenance.md
Normal file
30
docs/agent-management-provenance.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Agent management provenance
|
||||
|
||||
The shared **Not managed on this device** cloud marker means a viewer-owned
|
||||
identity has no exact-key record in successfully loaded local managed inventory.
|
||||
The glyph is the approved visual convention, not proof of physical cloud hosting.
|
||||
Locally managed provider-backed agents, unknown/failed local inventory, unknown
|
||||
owners, and other people's identities do not get the marker. Neither lifecycle
|
||||
state nor relay presence decides management provenance.
|
||||
|
||||
`KnownAgentPubkeysProvider` supplies stable inventory and ownership context to
|
||||
identity surfaces; rows must not create their own directory query observers.
|
||||
Picker candidates preserve exact local-management flags and use the same pure
|
||||
predicate. Profile ownership can supplement the shared directory evidence.
|
||||
|
||||
Pickers, member cards, authors, markdown mention chips, address controls, hover
|
||||
cards, profile headers/hero/subviews, new-message recipients and DM headers/sidebar
|
||||
rows use the same glyph and accessible label. The marker grants no membership,
|
||||
mention eligibility, availability or local management capability. This change does
|
||||
not change profile navigation, presence, native discovery or invitation/routing.
|
||||
|
||||
Regression gates: `otherSetupAgent.test.mjs`, `useKnownAgentPubkeys.test.mjs`,
|
||||
`buildMentionCandidates.test.mjs`, `MentionAutocomplete.test.mjs`, and
|
||||
`tests/e2e/cloud-provenance.spec.ts`. Browser evidence is mock-Tauri rendered UI,
|
||||
using an already eligible channel member and an existing DM, not a discovery or
|
||||
invitation acceptance test. `mentions.spec.ts` retains its marker-label assertions.
|
||||
The cloud smoke waits (at most 10 seconds, with per-query status diagnostics) for
|
||||
successful identity, local inventory and relay directory reads before opening the
|
||||
picker. Both immediate and delayed-directory fixtures exercise the same rendered
|
||||
workflow. Each cloud assertion retains its own normal deadline: successful data
|
||||
loading must not hide a missing glyph or accessible marker.
|
||||
Reference in New Issue
Block a user