Allow tasks from any message

This commit is contained in:
klopez4212
2026-06-30 13:56:35 +01:00
parent 7b5c0e4884
commit 81887699c8
9 changed files with 207 additions and 63 deletions
@@ -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}