mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat: agent users:write scope + system messages in chat (#73)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -563,6 +563,8 @@ export function AppShell() {
|
||||
<React.Fragment key={activeChannel?.id ?? "no-channel"}>
|
||||
<MessageTimeline
|
||||
activeReplyTargetId={replyTargetId}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
profiles={messageProfilesQuery.data?.profiles}
|
||||
emptyDescription={
|
||||
activeChannel?.channelType === "forum"
|
||||
? "Select a stream or DM to load real message history in this first integration pass."
|
||||
|
||||
@@ -49,7 +49,13 @@ export function CreateAgentDialog({
|
||||
const [tokenName, setTokenName] = React.useState("");
|
||||
const [selectedScopes, setSelectedScopes] = React.useState<Set<TokenScope>>(
|
||||
() =>
|
||||
new Set<TokenScope>(["messages:read", "messages:write", "channels:read"]),
|
||||
new Set<TokenScope>([
|
||||
"messages:read",
|
||||
"messages:write",
|
||||
"channels:read",
|
||||
"users:read",
|
||||
"users:write",
|
||||
]),
|
||||
);
|
||||
const [turnTimeoutSeconds, setTurnTimeoutSeconds] = React.useState("300");
|
||||
const [selectedProviderId, setSelectedProviderId] =
|
||||
@@ -138,7 +144,13 @@ export function CreateAgentDialog({
|
||||
setSpawnAfterCreate(true);
|
||||
setTokenName("");
|
||||
setSelectedScopes(
|
||||
new Set<TokenScope>(["messages:read", "messages:write", "channels:read"]),
|
||||
new Set<TokenScope>([
|
||||
"messages:read",
|
||||
"messages:write",
|
||||
"channels:read",
|
||||
"users:read",
|
||||
"users:write",
|
||||
]),
|
||||
);
|
||||
setAcpCommand("sprout-acp");
|
||||
setAgentCommand("goose");
|
||||
|
||||
@@ -7,6 +7,7 @@ export const AGENT_SCOPE_OPTIONS: Array<{ value: TokenScope; label: string }> =
|
||||
{ value: "channels:read", label: "Channels read" },
|
||||
{ value: "channels:write", label: "Channels write" },
|
||||
{ value: "users:read", label: "Users read" },
|
||||
{ value: "users:write", label: "Users write" },
|
||||
{ value: "files:read", label: "Files read" },
|
||||
{ value: "files:write", label: "Files write" },
|
||||
];
|
||||
|
||||
@@ -14,12 +14,14 @@ import {
|
||||
KIND_REACTION,
|
||||
KIND_STREAM_MESSAGE,
|
||||
KIND_STREAM_MESSAGE_DIFF,
|
||||
KIND_SYSTEM_MESSAGE,
|
||||
} from "@/shared/constants/kinds";
|
||||
|
||||
function isTimelineContentEvent(event: RelayEvent) {
|
||||
return (
|
||||
event.kind === KIND_STREAM_MESSAGE ||
|
||||
event.kind === KIND_STREAM_MESSAGE_DIFF
|
||||
event.kind === KIND_STREAM_MESSAGE_DIFF ||
|
||||
event.kind === KIND_SYSTEM_MESSAGE
|
||||
);
|
||||
}
|
||||
|
||||
@@ -274,12 +276,42 @@ export function formatTimelineMessages(
|
||||
});
|
||||
}
|
||||
|
||||
export function collectMessageAuthorPubkeys(events: RelayEvent[]) {
|
||||
return [
|
||||
...new Set(
|
||||
events
|
||||
.filter(isTimelineContentEvent)
|
||||
.map((event) => getEffectiveAuthorPubkey(event).toLowerCase()),
|
||||
),
|
||||
];
|
||||
function extractSystemMessagePubkeys(event: RelayEvent): string[] {
|
||||
if (event.kind !== KIND_SYSTEM_MESSAGE) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(event.content);
|
||||
const pubkeys: string[] = [];
|
||||
if (typeof payload.actor === "string") {
|
||||
pubkeys.push(payload.actor.toLowerCase());
|
||||
}
|
||||
if (typeof payload.target === "string") {
|
||||
pubkeys.push(payload.target.toLowerCase());
|
||||
}
|
||||
return pubkeys;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function collectMessageAuthorPubkeys(events: RelayEvent[]) {
|
||||
const pubkeys = new Set<string>();
|
||||
|
||||
for (const event of events) {
|
||||
if (!isTimelineContentEvent(event)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.kind === KIND_SYSTEM_MESSAGE) {
|
||||
for (const pk of extractSystemMessagePubkeys(event)) {
|
||||
pubkeys.add(pk);
|
||||
}
|
||||
} else {
|
||||
pubkeys.add(getEffectiveAuthorPubkey(event).toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
return [...pubkeys];
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { ArrowDown } from "lucide-react";
|
||||
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Separator } from "@/shared/ui/separator";
|
||||
import { MessageRow } from "./MessageRow";
|
||||
import { SystemMessageRow } from "./SystemMessageRow";
|
||||
import { TimelineSkeleton } from "./TimelineSkeleton";
|
||||
import { useTimelineScrollManager } from "./useTimelineScrollManager";
|
||||
|
||||
@@ -13,6 +16,8 @@ type MessageTimelineProps = {
|
||||
emptyTitle?: string;
|
||||
emptyDescription?: string;
|
||||
activeReplyTargetId?: string | null;
|
||||
currentPubkey?: string;
|
||||
profiles?: UserProfileLookup;
|
||||
onReply?: (message: TimelineMessage) => void;
|
||||
onToggleReaction?: (
|
||||
message: TimelineMessage,
|
||||
@@ -29,6 +34,8 @@ export function MessageTimeline({
|
||||
emptyTitle = "No messages yet",
|
||||
emptyDescription = "Send the first message to start the thread.",
|
||||
activeReplyTargetId = null,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
onReply,
|
||||
onToggleReaction,
|
||||
targetMessageId = null,
|
||||
@@ -90,18 +97,28 @@ export function MessageTimeline({
|
||||
) : null}
|
||||
|
||||
{!isLoading
|
||||
? messages.map((message) => (
|
||||
<MessageRow
|
||||
activeReplyTargetId={activeReplyTargetId}
|
||||
key={message.id}
|
||||
message={{
|
||||
...message,
|
||||
highlighted: message.id === highlightedMessageId,
|
||||
}}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onReply={onReply}
|
||||
/>
|
||||
))
|
||||
? messages.map((message) =>
|
||||
message.kind === KIND_SYSTEM_MESSAGE ? (
|
||||
<SystemMessageRow
|
||||
body={message.body}
|
||||
currentPubkey={currentPubkey}
|
||||
key={message.id}
|
||||
profiles={profiles}
|
||||
time={message.time}
|
||||
/>
|
||||
) : (
|
||||
<MessageRow
|
||||
activeReplyTargetId={activeReplyTargetId}
|
||||
key={message.id}
|
||||
message={{
|
||||
...message,
|
||||
highlighted: message.id === highlightedMessageId,
|
||||
}}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onReply={onReply}
|
||||
/>
|
||||
),
|
||||
)
|
||||
: null}
|
||||
<div aria-hidden className="h-px" ref={bottomAnchorRef} />
|
||||
</div>
|
||||
|
||||
95
desktop/src/features/messages/ui/SystemMessageRow.tsx
Normal file
95
desktop/src/features/messages/ui/SystemMessageRow.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { ArrowRightLeft } from "lucide-react";
|
||||
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { resolveUserLabel } from "@/features/profile/lib/identity";
|
||||
|
||||
type SystemMessagePayload = {
|
||||
type: string;
|
||||
actor?: string;
|
||||
target?: string;
|
||||
topic?: string;
|
||||
purpose?: string;
|
||||
};
|
||||
|
||||
function resolveLabel(
|
||||
pubkey: string | undefined,
|
||||
currentPubkey: string | undefined,
|
||||
profiles: UserProfileLookup | undefined,
|
||||
): string {
|
||||
if (!pubkey) {
|
||||
return "Someone";
|
||||
}
|
||||
return resolveUserLabel({ pubkey, currentPubkey, profiles });
|
||||
}
|
||||
|
||||
function describeSystemEvent(
|
||||
payload: SystemMessagePayload,
|
||||
currentPubkey: string | undefined,
|
||||
profiles: UserProfileLookup | undefined,
|
||||
): string | null {
|
||||
const actor = resolveLabel(payload.actor, currentPubkey, profiles);
|
||||
|
||||
switch (payload.type) {
|
||||
case "member_joined": {
|
||||
const target = resolveLabel(payload.target, currentPubkey, profiles);
|
||||
if (payload.actor === payload.target) {
|
||||
return `${actor} joined the channel`;
|
||||
}
|
||||
return `${actor} added ${target} to the channel`;
|
||||
}
|
||||
case "member_left": {
|
||||
return `${actor} left the channel`;
|
||||
}
|
||||
case "member_removed": {
|
||||
const target = resolveLabel(payload.target, currentPubkey, profiles);
|
||||
return `${actor} removed ${target} from the channel`;
|
||||
}
|
||||
case "topic_changed":
|
||||
return `${actor} changed the topic to "${payload.topic}"`;
|
||||
case "purpose_changed":
|
||||
return `${actor} changed the purpose to "${payload.purpose}"`;
|
||||
case "channel_created":
|
||||
return `${actor} created this channel`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function SystemMessageRow({
|
||||
body,
|
||||
time,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
}: {
|
||||
body: string;
|
||||
time: string;
|
||||
currentPubkey?: string;
|
||||
profiles?: UserProfileLookup;
|
||||
}) {
|
||||
let payload: SystemMessagePayload;
|
||||
try {
|
||||
payload = JSON.parse(body);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const description = describeSystemEvent(payload, currentPubkey, profiles);
|
||||
if (!description) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-3 px-2 py-1.5"
|
||||
data-testid="system-message-row"
|
||||
>
|
||||
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted">
|
||||
<ArrowRightLeft className="h-3 w-3 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
<span className="ml-auto whitespace-nowrap text-xs text-muted-foreground/60">
|
||||
{time}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -203,6 +203,7 @@ export type TokenScope =
|
||||
| "channels:read"
|
||||
| "channels:write"
|
||||
| "users:read"
|
||||
| "users:write"
|
||||
| "files:read"
|
||||
| "files:write";
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ export const KIND_STREAM_MESSAGE = 9;
|
||||
export const KIND_STREAM_MESSAGE_V2 = 40002;
|
||||
export const KIND_STREAM_MESSAGE_EDIT = 40003;
|
||||
export const KIND_STREAM_MESSAGE_DIFF = 40008;
|
||||
export const KIND_SYSTEM_MESSAGE = 40099;
|
||||
|
||||
export const CHANNEL_EVENT_KINDS = [
|
||||
KIND_DELETION, // 5 — NIP-09 event deletions
|
||||
@@ -11,4 +12,5 @@ export const CHANNEL_EVENT_KINDS = [
|
||||
KIND_STREAM_MESSAGE, // 9 — NIP-29 group chat messages
|
||||
40001, // legacy: pre-migration stream messages
|
||||
KIND_STREAM_MESSAGE_DIFF, // 40008 — message diffs
|
||||
KIND_SYSTEM_MESSAGE, // 40099 — system messages (join, leave, etc.)
|
||||
] as const;
|
||||
|
||||
Reference in New Issue
Block a user