mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Allow tasks from any message
This commit is contained in:
@@ -155,7 +155,12 @@ test("continued conversation mention routing preserves explicit multi-agent ment
|
||||
);
|
||||
});
|
||||
|
||||
function markerEvent({ content = {}, createdAt = 1, id = "marker" } = {}) {
|
||||
function markerEvent({
|
||||
content = {},
|
||||
createdAt = 1,
|
||||
id = "marker",
|
||||
includeAgent = true,
|
||||
} = {}) {
|
||||
return {
|
||||
id,
|
||||
pubkey: "starter",
|
||||
@@ -165,15 +170,15 @@ function markerEvent({ content = {}, createdAt = 1, id = "marker" } = {}) {
|
||||
["h", "channel"],
|
||||
["e", "root", "", "root"],
|
||||
["e", "agent-reply", "", "agent-reply"],
|
||||
["p", "agent"],
|
||||
...(includeAgent ? [["p", "agent"]] : []),
|
||||
["title", "Data in Buzz app"],
|
||||
],
|
||||
content: JSON.stringify({
|
||||
version: 1,
|
||||
title: "Data in Buzz app",
|
||||
titleStatus: "resolved",
|
||||
agentName: "Fizz",
|
||||
agentPubkey: "agent",
|
||||
agentName: includeAgent ? "Fizz" : "",
|
||||
agentPubkey: includeAgent ? "agent" : "",
|
||||
threadRootId: "root",
|
||||
threadRootMessageId: "root",
|
||||
parentMessageId: "root",
|
||||
@@ -226,6 +231,16 @@ test("continued conversation marker parses summary metadata", () => {
|
||||
assert.equal(marker?.summaryCreatedAt, 12);
|
||||
});
|
||||
|
||||
test("continued conversation marker can anchor a task without a primary agent", () => {
|
||||
const marker = parseAgentConversationMarker(
|
||||
markerEvent({ includeAgent: false }),
|
||||
);
|
||||
|
||||
assert.equal(marker?.agentName, "Task");
|
||||
assert.equal(marker?.agentPubkey, "");
|
||||
assert.equal(marker?.agentReplyId, "agent-reply");
|
||||
});
|
||||
|
||||
test("continued conversations persist across app restarts", () => {
|
||||
withMockLocalStorage(() => {
|
||||
const workspaceScope = "wss://relay.example.com";
|
||||
@@ -265,6 +280,33 @@ test("continued conversations persist across app restarts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("message-anchored tasks persist without a primary agent", () => {
|
||||
withMockLocalStorage(() => {
|
||||
const root = message({
|
||||
body: "Can someone turn this into a task?",
|
||||
createdAt: 1,
|
||||
id: "root",
|
||||
});
|
||||
const conversation = buildAgentConversation({
|
||||
agentName: "",
|
||||
agentPubkey: "",
|
||||
agentReply: root,
|
||||
channel: { id: "channel", name: "general" },
|
||||
contextMessages: [root],
|
||||
parentMessage: null,
|
||||
threadRootMessage: root,
|
||||
});
|
||||
|
||||
writePersistedAgentConversations("human", [conversation]);
|
||||
const persisted = readPersistedAgentConversations("human");
|
||||
|
||||
assert.equal(persisted.length, 1);
|
||||
assert.equal(persisted[0].id, conversation.id);
|
||||
assert.equal(persisted[0].agentPubkey, "");
|
||||
assert.equal(persisted[0].agentReply.id, "root");
|
||||
});
|
||||
});
|
||||
|
||||
test("continued conversation marker summary update replaces earlier marker", () => {
|
||||
const markers = buildAgentConversationMarkers([
|
||||
markerEvent({
|
||||
|
||||
@@ -41,6 +41,7 @@ export type AgentConversation = {
|
||||
export type OpenAgentConversationInput = {
|
||||
agentName: string;
|
||||
agentPubkey: string;
|
||||
/** Source message the task was started from. Kept as `agentReply` for link compatibility. */
|
||||
agentReply: TimelineMessage;
|
||||
channel: Pick<Channel, "id" | "name">;
|
||||
contextMessages?: TimelineMessage[];
|
||||
@@ -258,8 +259,8 @@ function parseStoredAgentConversation(
|
||||
}
|
||||
|
||||
const id = maybeString(value.id);
|
||||
const agentName = maybeString(value.agentName);
|
||||
const agentPubkey = maybeString(value.agentPubkey);
|
||||
const agentName = maybeString(value.agentName) ?? "Task";
|
||||
const agentPubkey = maybeString(value.agentPubkey) ?? "";
|
||||
const channelId = maybeString(value.channelId);
|
||||
const channelName = maybeString(value.channelName);
|
||||
const threadRootId = maybeString(value.threadRootId);
|
||||
@@ -289,8 +290,6 @@ function parseStoredAgentConversation(
|
||||
|
||||
if (
|
||||
!id ||
|
||||
!agentName ||
|
||||
!agentPubkey ||
|
||||
!agentReply ||
|
||||
!channelId ||
|
||||
!channelName ||
|
||||
@@ -469,7 +468,7 @@ export function parseAgentConversationMarker(
|
||||
(typeof content.agentReplyId === "string" ? content.agentReplyId : null);
|
||||
const agentPubkey =
|
||||
getTagValue(event.tags, "p") ??
|
||||
(typeof content.agentPubkey === "string" ? content.agentPubkey : null);
|
||||
(typeof content.agentPubkey === "string" ? content.agentPubkey : "");
|
||||
const parentMessageId =
|
||||
typeof content.parentMessageId === "string"
|
||||
? content.parentMessageId
|
||||
@@ -478,7 +477,7 @@ export function parseAgentConversationMarker(
|
||||
typeof content.threadRootMessageId === "string"
|
||||
? content.threadRootMessageId
|
||||
: null;
|
||||
const agentName = trimmedString(content.agentName) || agentPubkey || "Agent";
|
||||
const agentName = trimmedString(content.agentName) || agentPubkey || "Task";
|
||||
const title =
|
||||
trimmedString(content.title) ??
|
||||
getTagValue(event.tags, "title") ??
|
||||
@@ -496,7 +495,7 @@ export function parseAgentConversationMarker(
|
||||
? content.startedAt
|
||||
: event.created_at;
|
||||
|
||||
if (!channelId || !threadRootId || !agentReplyId || !agentPubkey) {
|
||||
if (!channelId || !threadRootId || !agentReplyId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -624,16 +623,20 @@ export async function publishAgentConversationMarker(
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
const tags = [
|
||||
["h", conversation.channelId],
|
||||
["e", conversation.threadRootId, "", "root"],
|
||||
["e", conversation.agentReply.id, "", "agent-reply"],
|
||||
["title", conversation.title],
|
||||
];
|
||||
if (conversation.agentPubkey) {
|
||||
tags.splice(3, 0, ["p", conversation.agentPubkey]);
|
||||
}
|
||||
|
||||
const event = await signRelayEvent({
|
||||
kind: KIND_AGENT_CONVERSATION_COMPAT,
|
||||
content,
|
||||
tags: [
|
||||
["h", conversation.channelId],
|
||||
["e", conversation.threadRootId, "", "root"],
|
||||
["e", conversation.agentReply.id, "", "agent-reply"],
|
||||
["p", conversation.agentPubkey],
|
||||
["title", conversation.title],
|
||||
],
|
||||
tags,
|
||||
});
|
||||
|
||||
return relayClient.publishEvent(
|
||||
|
||||
@@ -370,7 +370,8 @@ export function buildKnownAgentParticipants({
|
||||
});
|
||||
}
|
||||
|
||||
if (!participants.has(normalizePubkey(conversation.agentPubkey))) {
|
||||
const primaryAgentKey = normalizePubkey(conversation.agentPubkey);
|
||||
if (primaryAgentKey && !participants.has(primaryAgentKey)) {
|
||||
add({
|
||||
canMessage: true,
|
||||
displayName: conversation.agentName,
|
||||
|
||||
@@ -275,11 +275,10 @@ export function AgentConversationScreen({
|
||||
conversationSourceMessages,
|
||||
knownAgentParticipants,
|
||||
);
|
||||
const primaryAgentKey = normalizePubkey(conversation.agentPubkey);
|
||||
if (
|
||||
!pubkeys.some(
|
||||
(pubkey) =>
|
||||
normalizePubkey(pubkey) === normalizePubkey(conversation.agentPubkey),
|
||||
)
|
||||
primaryAgentKey &&
|
||||
!pubkeys.some((pubkey) => normalizePubkey(pubkey) === primaryAgentKey)
|
||||
) {
|
||||
pubkeys.unshift(conversation.agentPubkey);
|
||||
}
|
||||
@@ -517,6 +516,9 @@ export function AgentConversationScreen({
|
||||
[restrictedAgentNames],
|
||||
);
|
||||
const composerPlaceholder = React.useMemo(() => {
|
||||
if (agentParticipants.length === 0) {
|
||||
return "Message task";
|
||||
}
|
||||
if (!canMessageAnyAgent) {
|
||||
return "Reply to conversation";
|
||||
}
|
||||
@@ -527,9 +529,11 @@ export function AgentConversationScreen({
|
||||
return "Message conversation";
|
||||
}, [agentParticipants, canMessageAnyAgent]);
|
||||
const emptyDescription =
|
||||
agentParticipants.length === 1
|
||||
? "Send a message below to keep working with this agent on the topic."
|
||||
: "Send a message below to keep working with these agents on the topic.";
|
||||
agentParticipants.length === 0
|
||||
? "Send a message below to start working on this task."
|
||||
: agentParticipants.length === 1
|
||||
? "Send a message below to keep working with this agent on the topic."
|
||||
: "Send a message below to keep working with these agents on the topic.";
|
||||
const [isPublishingThreadSummary, setIsPublishingThreadSummary] =
|
||||
React.useState(false);
|
||||
const lastPublishedThreadRecapRef = React.useRef<string | null>(null);
|
||||
|
||||
@@ -51,6 +51,7 @@ import * as agentSessionSelection from "@/features/channels/ui/agentSessionSelec
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel";
|
||||
import { isBroadcastReply } from "@/features/messages/lib/threading";
|
||||
import { collectMessageMentionPubkeys } from "@/features/messages/lib/formatTimelineMessages";
|
||||
import { useRenderScopedReactionHydration } from "@/features/messages/lib/useRenderScopedReactionHydration";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import { isWelcomeChannel } from "@/features/onboarding/welcome";
|
||||
@@ -59,6 +60,7 @@ import { useAppShell } from "@/app/AppShellContext";
|
||||
import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile";
|
||||
import { channelChrome } from "@/shared/layout/chromeLayout";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
export const ChannelPane = React.memo(function ChannelPane({
|
||||
activeChannel,
|
||||
agentConversationMarkers,
|
||||
@@ -280,6 +282,63 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
|
||||
return pubkeys;
|
||||
}, [activityAgents, agentPubkeys, agentSessionAgents]);
|
||||
const knownAgentByPubkey = React.useMemo(() => {
|
||||
const agents = new Map<string, { name: string; pubkey: string }>();
|
||||
const addAgent = (pubkey: string, name?: string | null) => {
|
||||
const key = normalizePubkey(pubkey);
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
const profileName = profiles?.[key]?.displayName?.trim();
|
||||
const fallbackName = name?.trim() || profileName || pubkey;
|
||||
const current = agents.get(key);
|
||||
agents.set(key, {
|
||||
name:
|
||||
current?.name && current.name !== current.pubkey
|
||||
? current.name
|
||||
: fallbackName,
|
||||
pubkey: current?.pubkey ?? pubkey,
|
||||
});
|
||||
};
|
||||
|
||||
for (const agent of agentSessionAgents) {
|
||||
addAgent(agent.pubkey, agent.name);
|
||||
}
|
||||
for (const agent of activityAgents) {
|
||||
addAgent(agent.pubkey, agent.name);
|
||||
}
|
||||
for (const pubkey of agentPubkeys ?? []) {
|
||||
addAgent(pubkey);
|
||||
}
|
||||
|
||||
return agents;
|
||||
}, [activityAgents, agentPubkeys, agentSessionAgents, profiles]);
|
||||
const resolveTaskAgentForMessage = React.useCallback(
|
||||
(message: TimelineMessage) => {
|
||||
if (message.pubkey) {
|
||||
const directAgent = knownAgentByPubkey.get(
|
||||
normalizePubkey(message.pubkey),
|
||||
);
|
||||
if (directAgent) {
|
||||
return {
|
||||
name: message.author?.trim() || directAgent.name,
|
||||
pubkey: directAgent.pubkey,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
for (const pubkey of collectMessageMentionPubkeys([message])) {
|
||||
const mentionedAgent = knownAgentByPubkey.get(normalizePubkey(pubkey));
|
||||
if (mentionedAgent) {
|
||||
return mentionedAgent;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[knownAgentByPubkey],
|
||||
);
|
||||
const completeWelcomeComposerBanner = React.useCallback(() => {
|
||||
if (!activeChannelId || !isActiveWelcomeChannel) {
|
||||
return;
|
||||
@@ -341,7 +400,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
if (
|
||||
!enableAgentConversations ||
|
||||
!activeChannel ||
|
||||
!message.pubkey ||
|
||||
message.pending ||
|
||||
!canOpenAgentConversationInChannel({
|
||||
channel: activeChannel,
|
||||
publishMarker: options?.publishMarker,
|
||||
@@ -350,6 +409,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
return;
|
||||
}
|
||||
|
||||
const taskAgent = resolveTaskAgentForMessage(message);
|
||||
const rootId = message.rootId ?? message.parentId ?? message.id;
|
||||
const contextMessages = messages.filter(
|
||||
(candidate) =>
|
||||
@@ -360,8 +420,8 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
);
|
||||
openAgentConversation(
|
||||
{
|
||||
agentName: message.author,
|
||||
agentPubkey: message.pubkey,
|
||||
agentName: taskAgent?.name ?? "",
|
||||
agentPubkey: taskAgent?.pubkey ?? "",
|
||||
agentReply: message,
|
||||
channel: activeChannel,
|
||||
contextMessages,
|
||||
@@ -377,7 +437,13 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
options,
|
||||
);
|
||||
},
|
||||
[activeChannel, enableAgentConversations, messages, openAgentConversation],
|
||||
[
|
||||
activeChannel,
|
||||
enableAgentConversations,
|
||||
messages,
|
||||
openAgentConversation,
|
||||
resolveTaskAgentForMessage,
|
||||
],
|
||||
);
|
||||
const handleGoToTaskMessage = React.useCallback(
|
||||
(
|
||||
|
||||
@@ -731,7 +731,9 @@ export function ChannelScreen({
|
||||
);
|
||||
useAgentConversationRouteTarget({
|
||||
activeChannel,
|
||||
activeChannelId,
|
||||
agentConversationMarkers,
|
||||
agentPubkeys,
|
||||
enabled: isChannelTasksEnabled,
|
||||
goChannel,
|
||||
messageProfilesReady,
|
||||
openAgentConversation,
|
||||
|
||||
@@ -281,8 +281,8 @@ export function ChannelTasksView({
|
||||
No tasks yet
|
||||
</p>
|
||||
<p className="mx-auto mt-2 max-w-sm text-sm leading-5 text-muted-foreground">
|
||||
New tasks will appear here when an agent conversation is
|
||||
opened from this channel.
|
||||
New tasks will appear here when one is started from a message
|
||||
in this channel.
|
||||
</p>
|
||||
</div>
|
||||
{olderTasksLoader}
|
||||
|
||||
@@ -1,44 +1,58 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
import type { OpenAgentConversationInput } from "@/features/agents/agentConversations";
|
||||
import type {
|
||||
AgentConversationMarker,
|
||||
OpenAgentConversationInput,
|
||||
} from "@/features/agents/agentConversations";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
type GoChannel = ReturnType<typeof useAppNavigation>["goChannel"];
|
||||
type OpenAgentConversation = (
|
||||
input: OpenAgentConversationInput,
|
||||
options?: { publishMarker?: boolean },
|
||||
) => void;
|
||||
type GoChannel = (
|
||||
channelId: string,
|
||||
options?: {
|
||||
messageId?: string;
|
||||
replace?: boolean;
|
||||
taskReplyId?: string;
|
||||
threadRootId?: string | null;
|
||||
},
|
||||
) => Promise<boolean>;
|
||||
|
||||
type UseAgentConversationRouteTargetOptions = {
|
||||
type UseAgentConversationRouteTargetInput = {
|
||||
activeChannel: Channel | null;
|
||||
activeChannelId: string | null;
|
||||
agentConversationMarkers: readonly AgentConversationMarker[];
|
||||
agentPubkeys: ReadonlySet<string>;
|
||||
enabled: boolean;
|
||||
goChannel: GoChannel;
|
||||
messageProfilesReady: boolean;
|
||||
openAgentConversation: OpenAgentConversation;
|
||||
openAgentConversation: (
|
||||
input: OpenAgentConversationInput,
|
||||
options?: { publishMarker?: boolean },
|
||||
) => void;
|
||||
targetAgentConversationReplyId: string | null;
|
||||
timelineMessages: readonly TimelineMessage[];
|
||||
};
|
||||
|
||||
export function useAgentConversationRouteTarget({
|
||||
activeChannel,
|
||||
activeChannelId,
|
||||
agentConversationMarkers,
|
||||
agentPubkeys,
|
||||
enabled,
|
||||
goChannel,
|
||||
messageProfilesReady,
|
||||
openAgentConversation,
|
||||
targetAgentConversationReplyId,
|
||||
timelineMessages,
|
||||
}: UseAgentConversationRouteTargetOptions) {
|
||||
}: UseAgentConversationRouteTargetInput) {
|
||||
const handledRouteTargetRef = React.useRef<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!targetAgentConversationReplyId) {
|
||||
if (!enabled || !targetAgentConversationReplyId) {
|
||||
handledRouteTargetRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const targetKey = `${activeChannelId ?? "none"}:${targetAgentConversationReplyId}`;
|
||||
const targetKey = `${activeChannel?.id ?? "none"}:${targetAgentConversationReplyId}`;
|
||||
if (handledRouteTargetRef.current === targetKey) {
|
||||
return;
|
||||
}
|
||||
@@ -49,26 +63,40 @@ export function useAgentConversationRouteTarget({
|
||||
return;
|
||||
}
|
||||
|
||||
const agentReply =
|
||||
const marker =
|
||||
agentConversationMarkers.find(
|
||||
(candidate) =>
|
||||
candidate.channelId === activeChannel.id &&
|
||||
candidate.agentReplyId === targetAgentConversationReplyId,
|
||||
) ?? null;
|
||||
const sourceMessage =
|
||||
timelineMessages.find(
|
||||
(message) => message.id === targetAgentConversationReplyId,
|
||||
) ?? null;
|
||||
const agentReplyPubkey = agentReply?.pubkey;
|
||||
if (!agentReply || !agentReplyPubkey) {
|
||||
if (!sourceMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rootId = agentReply.rootId ?? agentReply.parentId ?? agentReply.id;
|
||||
const sourceAuthorIsAgent = sourceMessage.pubkey
|
||||
? agentPubkeys.has(normalizePubkey(sourceMessage.pubkey))
|
||||
: false;
|
||||
const taskAgentPubkey =
|
||||
marker?.agentPubkey ||
|
||||
(sourceAuthorIsAgent ? (sourceMessage.pubkey ?? "") : "");
|
||||
const taskAgentName =
|
||||
marker?.agentName || (taskAgentPubkey ? sourceMessage.author : "");
|
||||
const rootId =
|
||||
sourceMessage.rootId ?? sourceMessage.parentId ?? sourceMessage.id;
|
||||
const contextMessages = timelineMessages.filter(
|
||||
(candidate) =>
|
||||
candidate.id === rootId ||
|
||||
candidate.id === agentReply.id ||
|
||||
candidate.id === sourceMessage.id ||
|
||||
candidate.rootId === rootId ||
|
||||
candidate.parentId === rootId,
|
||||
);
|
||||
const parentMessage = agentReply.parentId
|
||||
const parentMessage = sourceMessage.parentId
|
||||
? (timelineMessages.find(
|
||||
(candidate) => candidate.id === agentReply.parentId,
|
||||
(candidate) => candidate.id === sourceMessage.parentId,
|
||||
) ?? null)
|
||||
: null;
|
||||
const threadRootMessage =
|
||||
@@ -78,9 +106,9 @@ export function useAgentConversationRouteTarget({
|
||||
void goChannel(activeChannel.id, { replace: true }).then(() => {
|
||||
openAgentConversation(
|
||||
{
|
||||
agentName: agentReply.author,
|
||||
agentPubkey: agentReplyPubkey,
|
||||
agentReply,
|
||||
agentName: taskAgentName,
|
||||
agentPubkey: taskAgentPubkey,
|
||||
agentReply: sourceMessage,
|
||||
channel: activeChannel,
|
||||
contextMessages,
|
||||
parentMessage,
|
||||
@@ -91,7 +119,9 @@ export function useAgentConversationRouteTarget({
|
||||
});
|
||||
}, [
|
||||
activeChannel,
|
||||
activeChannelId,
|
||||
agentConversationMarkers,
|
||||
agentPubkeys,
|
||||
enabled,
|
||||
goChannel,
|
||||
messageProfilesReady,
|
||||
openAgentConversation,
|
||||
|
||||
@@ -272,10 +272,6 @@ export const MessageRow = React.memo(
|
||||
message.tags,
|
||||
);
|
||||
const bodyOffsetClass = emojiOnly ? "mt-1" : "-mt-0.5";
|
||||
const isAgentMessage =
|
||||
message.pubkey != null &&
|
||||
!message.pending &&
|
||||
resolvedAgentPubkeys.has(normalizePubkey(message.pubkey));
|
||||
const { channels } = useChannelNavigation();
|
||||
const channelNames = React.useMemo(
|
||||
() => channels.filter((c) => c.channelType !== "dm").map((c) => c.name),
|
||||
@@ -475,7 +471,7 @@ export const MessageRow = React.memo(
|
||||
isUnread={isUnread}
|
||||
message={message}
|
||||
onContinueConversation={
|
||||
isAgentMessage ? onOpenAgentConversation : undefined
|
||||
message.pending ? undefined : onOpenAgentConversation
|
||||
}
|
||||
onDelete={onDelete}
|
||||
onEdit={onEdit}
|
||||
|
||||
Reference in New Issue
Block a user