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:
Wes
2026-03-16 08:17:59 -07:00
committed by GitHub
parent 52e9cb57f7
commit 3814722c36
8 changed files with 185 additions and 23 deletions

View File

@@ -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."

View File

@@ -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");

View File

@@ -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" },
];

View File

@@ -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];
}

View File

@@ -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>

View 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>
);
}

View File

@@ -203,6 +203,7 @@ export type TokenScope =
| "channels:read"
| "channels:write"
| "users:read"
| "users:write"
| "files:read"
| "files:write";

View File

@@ -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;