diff --git a/desktop/src/features/agents/agentConversationLink.test.mjs b/desktop/src/features/agents/agentConversationLink.test.mjs index e8492af67..fd50994fc 100644 --- a/desktop/src/features/agents/agentConversationLink.test.mjs +++ b/desktop/src/features/agents/agentConversationLink.test.mjs @@ -3,31 +3,124 @@ import test from "node:test"; import { buildAgentConversationLink, + isAgentConversationLink, parseAgentConversationLink, + resolveAgentConversationLinkRenderTarget, + trimAgentConversationLinkMatch, } from "./agentConversationLink.ts"; -test("buildAgentConversationLink -> parseAgentConversationLink round-trips", () => { - const href = buildAgentConversationLink({ - agentReplyId: "reply-1", - channelId: "channel-1", +const CHANNEL = "f570339f-8f8a-4e08-a779-8d954aa44109"; +const REPLY = + "b04819ffc1f7c8ffb49c6d30b5899f470198264680d02e78894a658e30a9059f"; + +test("buildAgentConversationLink → parseAgentConversationLink round-trips", () => { + const url = buildAgentConversationLink({ + agentReplyId: REPLY, + channelId: CHANNEL, }); + assert.equal(url, `buzz://task?channel=${CHANNEL}&reply=${REPLY}`); - assert.deepEqual(parseAgentConversationLink(href), { - ok: true, - value: { - agentReplyId: "reply-1", - channelId: "channel-1", - }, + const parsed = parseAgentConversationLink(url); + assert.equal(parsed.ok, true); + assert.deepEqual(parsed.ok && parsed.value, { + agentReplyId: REPLY, + channelId: CHANNEL, }); }); +test("buildAgentConversationLink rejects missing required params", () => { + assert.throws(() => + buildAgentConversationLink({ agentReplyId: REPLY, channelId: "" }), + ); + assert.throws(() => + buildAgentConversationLink({ agentReplyId: "", channelId: CHANNEL }), + ); +}); + +test("parseAgentConversationLink rejects unsupported schemes and hosts", () => { + assert.deepEqual( + parseAgentConversationLink( + `https://example.com/?channel=${CHANNEL}&reply=${REPLY}`, + ), + { ok: false, reason: "wrong-scheme" }, + ); + assert.deepEqual( + parseAgentConversationLink("buzz://message?channel=c&id=m"), + { + ok: false, + reason: "wrong-host", + }, + ); +}); + test("parseAgentConversationLink rejects missing required params", () => { - assert.deepEqual(parseAgentConversationLink("buzz://task?channel=c1"), { - ok: false, - reason: "missing-reply", - }); - assert.deepEqual(parseAgentConversationLink("buzz://task?reply=m1"), { + assert.deepEqual(parseAgentConversationLink(`buzz://task?reply=${REPLY}`), { ok: false, reason: "missing-channel", }); + assert.deepEqual( + parseAgentConversationLink(`buzz://task?channel=${CHANNEL}`), + { + ok: false, + reason: "missing-reply", + }, + ); +}); + +test("isAgentConversationLink matches task links only", () => { + assert.equal( + isAgentConversationLink(`buzz://task?channel=${CHANNEL}&reply=${REPLY}`), + true, + ); + assert.equal(isAgentConversationLink("buzz://message?channel=c&id=m"), false); + assert.equal(isAgentConversationLink("https://example.com"), false); + assert.equal(isAgentConversationLink(undefined), false); + assert.equal(isAgentConversationLink(""), false); +}); + +test("resolveAgentConversationLinkRenderTarget distinguishes cards from labels", () => { + const href = `buzz://task?channel=${CHANNEL}&reply=${REPLY}`; + const link = { + agentReplyId: REPLY, + channelId: CHANNEL, + }; + + assert.deepEqual( + resolveAgentConversationLinkRenderTarget({ href, label: href }), + { + kind: "card", + link, + }, + ); + assert.deepEqual( + resolveAgentConversationLinkRenderTarget({ href, label: "task" }), + { + kind: "label", + link, + }, + ); + assert.deepEqual( + resolveAgentConversationLinkRenderTarget({ + href: "https://example.com", + label: href, + }), + { kind: "none" }, + ); +}); + +test("trimAgentConversationLinkMatch keeps sentence punctuation outside links", () => { + const href = `buzz://task?channel=${CHANNEL}&reply=${REPLY}`; + + assert.deepEqual(trimAgentConversationLinkMatch(`${href}.`), { + value: href, + trailing: ".", + }); + assert.deepEqual(trimAgentConversationLinkMatch(`${href})`), { + value: href, + trailing: ")", + }); + assert.deepEqual(trimAgentConversationLinkMatch(`${href}]`), { + value: href, + trailing: "]", + }); }); diff --git a/desktop/src/features/agents/agentConversationLink.ts b/desktop/src/features/agents/agentConversationLink.ts index f22f0e0c9..e3cafed12 100644 --- a/desktop/src/features/agents/agentConversationLink.ts +++ b/desktop/src/features/agents/agentConversationLink.ts @@ -96,3 +96,28 @@ export function trimAgentConversationLinkMatch(matchText: string) { } return { value, trailing: matchText.slice(value.length) }; } + +type AgentConversationLinkRenderInput = { + href: string; + label: string; +}; + +export type AgentConversationLinkRenderTarget = + | { kind: "card"; link: ParsedAgentConversationLink } + | { kind: "label"; link: ParsedAgentConversationLink } + | { kind: "none" }; + +export function resolveAgentConversationLinkRenderTarget({ + href, + label, +}: AgentConversationLinkRenderInput): AgentConversationLinkRenderTarget { + if (!isAgentConversationLink(href)) return { kind: "none" }; + + const parsed = parseAgentConversationLink(href); + if (!parsed.ok) return { kind: "none" }; + + return { + kind: label === href ? "card" : "label", + link: parsed.value, + }; +} diff --git a/desktop/src/features/agents/agentConversations.test.mjs b/desktop/src/features/agents/agentConversations.test.mjs index 8443ce477..317e7d26e 100644 --- a/desktop/src/features/agents/agentConversations.test.mjs +++ b/desktop/src/features/agents/agentConversations.test.mjs @@ -2,12 +2,10 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - buildAgentConversationMentionPubkeys, buildAgentConversation, buildAgentConversationRecap, buildAgentConversationMarkers, deriveAgentConversationTitle, - getAutoRoutedAgentConversationPubkeys, getHiddenAgentConversationMessageIds, parseAgentConversationMarker, readPersistedAgentConversations, @@ -62,48 +60,6 @@ test("continued conversation title condenses a refined Buzz data thread", () => }); }); -test("continued conversation auto-routes only a single messageable agent", () => { - assert.deepEqual( - getAutoRoutedAgentConversationPubkeys([ - { canMessage: true, pubkey: "agent-one" }, - ]), - ["agent-one"], - ); - - assert.deepEqual( - getAutoRoutedAgentConversationPubkeys([ - { canMessage: true, pubkey: "agent-one" }, - { canMessage: true, pubkey: "agent-two" }, - ]), - [], - ); - - assert.deepEqual( - getAutoRoutedAgentConversationPubkeys([ - { canMessage: false, pubkey: "agent-one" }, - ]), - [], - ); -}); - -test("continued conversation mention routing preserves explicit multi-agent mentions", () => { - assert.deepEqual( - buildAgentConversationMentionPubkeys({ - autoRouteAgentPubkeys: [], - mentionPubkeys: ["agent-one"], - }), - ["agent-one"], - ); - - assert.deepEqual( - buildAgentConversationMentionPubkeys({ - autoRouteAgentPubkeys: ["AGENT-ONE"], - mentionPubkeys: ["agent-one", "agent-two"], - }), - ["AGENT-ONE", "agent-two"], - ); -}); - function markerEvent({ content = {}, createdAt = 1, id = "marker" } = {}) { return { id, diff --git a/desktop/src/features/agents/agentConversations.ts b/desktop/src/features/agents/agentConversations.ts index 41224823a..f0744bdb7 100644 --- a/desktop/src/features/agents/agentConversations.ts +++ b/desktop/src/features/agents/agentConversations.ts @@ -6,7 +6,7 @@ import { KIND_AGENT_CONVERSATION, KIND_AGENT_CONVERSATION_COMPAT, } from "@/shared/constants/kinds"; -import { normalizePubkey } from "@/shared/lib/pubkey"; +import { parseAgentConversationLink } from "./agentConversationLink"; import { collectConversationContextMessages, deriveTitleFromContext, @@ -80,11 +80,6 @@ export type AgentConversationRecapInput = { messages: readonly TimelineMessage[]; }; -export type AgentConversationRouteableParticipant = { - canMessage: boolean; - pubkey: string; -}; - function normalizeAgentConversationStorageScope( workspaceScope: string | null | undefined, ): string { @@ -106,44 +101,17 @@ export function agentConversationsStorageKey( return `${AGENT_CONVERSATIONS_STORAGE_PREFIX}:${normalizeAgentConversationStorageScope(workspaceScope)}:${pubkey}`; } -export function getAutoRoutedAgentConversationPubkeys( - participants: readonly AgentConversationRouteableParticipant[], -): string[] { - if (participants.length !== 1) { - return []; - } - - const [participant] = participants; - return participant.canMessage ? [participant.pubkey] : []; -} - -export function buildAgentConversationMentionPubkeys({ - autoRouteAgentPubkeys, - mentionPubkeys, -}: { - autoRouteAgentPubkeys: readonly string[]; - mentionPubkeys: readonly string[]; -}): string[] { - const seenPubkeys = new Set(); - const merged: string[] = []; - const add = (pubkey: string) => { - const normalized = normalizePubkey(pubkey); - if (!normalized || seenPubkeys.has(normalized)) { - return; - } - - seenPubkeys.add(normalized); - merged.push(pubkey); - }; - - for (const pubkey of autoRouteAgentPubkeys) { - add(pubkey); - } - for (const pubkey of mentionPubkeys) { - add(pubkey); - } - - return merged; +export function getAgentConversationMarkerTitleForHref( + markers: readonly AgentConversationMarker[] | undefined, + href: string, +) { + const parsed = parseAgentConversationLink(href); + if (!parsed.ok) return undefined; + return markers?.find( + (marker) => + marker.channelId === parsed.value.channelId && + marker.agentReplyId === parsed.value.agentReplyId, + )?.title; } export function readHiddenAgentConversationIds( diff --git a/desktop/src/features/agents/remarkAgentConversationLinks.ts b/desktop/src/features/agents/remarkAgentConversationLinks.ts index e767f994d..5e3359003 100644 --- a/desktop/src/features/agents/remarkAgentConversationLinks.ts +++ b/desktop/src/features/agents/remarkAgentConversationLinks.ts @@ -1,7 +1,7 @@ /** * Remark plugin that detects bare `buzz://task?…` URLs and replaces each with - * a custom task-link element. The renderer turns that element into an in-app - * task link instead of leaving a raw custom-scheme URL as inert text. + * a custom task-link element. The renderer turns that element into the same + * native task card shown when a dedicated conversation is started. */ import { createRemarkPrefixPlugin } from "../../shared/lib/createRemarkPrefixPlugin.ts"; diff --git a/desktop/src/features/agents/ui/AgentConversationScreen.tsx b/desktop/src/features/agents/ui/AgentConversationScreen.tsx index 361750165..4d12d9ebb 100644 --- a/desktop/src/features/agents/ui/AgentConversationScreen.tsx +++ b/desktop/src/features/agents/ui/AgentConversationScreen.tsx @@ -2,13 +2,14 @@ import * as React from "react"; import { Bot, ChevronRight, Copy, createLucideIcon } from "lucide-react"; import { toast } from "sonner"; -import { buildAgentConversationLink } from "@/features/agents/agentConversationLink"; import { - buildAgentConversationMentionPubkeys, + buildAgentConversationLink, + parseAgentConversationLink, +} from "@/features/agents/agentConversationLink"; +import { buildAgentConversationMarkers, buildAgentConversationRecap, deriveAgentConversationTitle, - getAutoRoutedAgentConversationPubkeys, type AgentConversation, publishAgentConversationMarker, } from "@/features/agents/agentConversations"; @@ -456,10 +457,6 @@ export function AgentConversationScreen({ .map((participant) => participant.pubkey), [agentParticipants], ); - const autoRoutedAgentPubkeys = React.useMemo( - () => getAutoRoutedAgentConversationPubkeys(agentParticipants), - [agentParticipants], - ); const canMessageAnyAgent = routeableAgentPubkeys.length > 0; const restrictedAgentNames = React.useMemo( () => @@ -541,14 +538,11 @@ export function AgentConversationScreen({ await sendMessageMutation.mutateAsync({ content, mediaTags, - mentionPubkeys: buildAgentConversationMentionPubkeys({ - autoRouteAgentPubkeys: autoRoutedAgentPubkeys, - mentionPubkeys, - }), + mentionPubkeys, parentEventId: replyParentEventId, }); }, - [autoRoutedAgentPubkeys, replyParentEventId, sendMessageMutation], + [replyParentEventId, sendMessageMutation], ); const isComposerDisabled = @@ -695,6 +689,21 @@ export function AgentConversationScreen({ toast.error("Failed to copy task link"); } }, [channel?.id, conversation.agentReply.id, conversation.channelId]); + const getAgentConversationTitleForHref = React.useCallback( + (href: string) => { + const parsed = parseAgentConversationLink(href); + if ( + !parsed.ok || + parsed.value.channelId !== conversation.channelId || + parsed.value.agentReplyId !== conversation.agentReply.id + ) { + return undefined; + } + + return conversation.title; + }, + [conversation.agentReply.id, conversation.channelId, conversation.title], + ); const headerTitleTrailingContent = ( <>
{ + assert.equal( + canOpenAgentConversationInChannel({ + channel: channel(), + }), + true, + ); + assert.equal( + canOpenAgentConversationInChannel({ + channel: channel({ archivedAt: "2026-06-27T00:00:00.000Z" }), + }), + false, + ); + assert.equal( + canOpenAgentConversationInChannel({ + channel: channel({ isMember: false }), + }), + false, + ); +}); + +test("existing agent conversation markers can open in read-only channels", () => { + assert.equal( + canOpenAgentConversationInChannel({ + channel: channel({ archivedAt: "2026-06-27T00:00:00.000Z" }), + publishMarker: false, + }), + true, + ); + assert.equal( + canOpenAgentConversationInChannel({ + channel: channel({ isMember: false }), + publishMarker: false, + }), + true, + ); +}); test("DM composer auto-routes only when exactly one other participant is an agent", () => { const knownAgentPubkeys = new Set(["agent-one", "agent-two"]); @@ -93,15 +107,26 @@ test("DM composer auto-routes only when exactly one other participant is an agen ); }); -test("thread composer auto-routes only for one human and one known agent", () => { +test("auto-routed mentions merge with explicit mentions without duplicates", () => { + assert.deepEqual( + mergeAutoRouteMentionPubkeys({ + autoRouteAgentPubkeys: ["AGENT-ONE"], + mentionPubkeys: ["agent-one", "agent-two"], + }), + ["AGENT-ONE", "agent-two"], + ); +}); + +test("thread composer auto-routes exactly one current human and one known agent", () => { const knownAgentPubkeys = new Set(["agent-one", "agent-two"]); assert.deepEqual( getThreadAutoRouteAgentPubkeys({ + currentPubkey: "human", knownAgentPubkeys, messages: [ - message({ id: "root", tags: [["p", "agent-one"]] }), - message({ id: "agent-reply", pubkey: "agent-one" }), + { id: "root", pubkey: "human", tags: [["p", "agent-one"]] }, + { id: "reply", pubkey: "agent-one", tags: [] }, ], }), ["agent-one"], @@ -109,25 +134,30 @@ test("thread composer auto-routes only for one human and one known agent", () => assert.deepEqual( getThreadAutoRouteAgentPubkeys({ + currentPubkey: "human", + knownAgentPubkeys, + messages: [ + { id: "root", pubkey: "human", tags: [["p", "agent-one"]] }, + { id: "reply", pubkey: "other-human", tags: [] }, + ], + }), + [], + ); + + assert.deepEqual( + getThreadAutoRouteAgentPubkeys({ + currentPubkey: "human-one", knownAgentPubkeys, messages: [ - message({ + { id: "root", pubkey: "human-one", - tags: [ - ["p", "human-one"], - ["p", "agent-one"], - ], - }), - message({ - id: "human-two-reply", - pubkey: "human-two", tags: [ ["p", "human-two"], ["p", "agent-one"], ], - }), - message({ id: "agent-reply", pubkey: "agent-one" }), + }, + { id: "reply", pubkey: "agent-one", tags: [] }, ], }), [], @@ -135,60 +165,19 @@ test("thread composer auto-routes only for one human and one known agent", () => assert.deepEqual( getThreadAutoRouteAgentPubkeys({ + currentPubkey: "human", knownAgentPubkeys, messages: [ - message({ id: "agent-one-reply", pubkey: "agent-one" }), - message({ id: "agent-two-reply", pubkey: "agent-two" }), + { + id: "root", + pubkey: "human", + tags: [ + ["p", "agent-one"], + ["p", "agent-two"], + ], + }, ], }), [], ); }); - -test("auto-routed mentions merge with explicit mentions without duplicates", () => { - assert.deepEqual( - mergeAutoRouteMentionPubkeys({ - autoRouteAgentPubkeys: ["AGENT-ONE"], - mentionPubkeys: ["agent-one", "agent-two"], - }), - ["AGENT-ONE", "agent-two"], - ); -}); - -test("new agent conversations require a writable channel", () => { - assert.equal( - canOpenAgentConversationInChannel({ - channel: channel(), - }), - true, - ); - assert.equal( - canOpenAgentConversationInChannel({ - channel: channel({ archivedAt: "2026-06-27T00:00:00.000Z" }), - }), - false, - ); - assert.equal( - canOpenAgentConversationInChannel({ - channel: channel({ isMember: false }), - }), - false, - ); -}); - -test("existing agent conversation markers can open in read-only channels", () => { - assert.equal( - canOpenAgentConversationInChannel({ - channel: channel({ archivedAt: "2026-06-27T00:00:00.000Z" }), - publishMarker: false, - }), - true, - ); - assert.equal( - canOpenAgentConversationInChannel({ - channel: channel({ isMember: false }), - publishMarker: false, - }), - true, - ); -}); diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.ts b/desktop/src/features/channels/ui/ChannelPane.helpers.ts index 9525a846d..22aee9aa3 100644 --- a/desktop/src/features/channels/ui/ChannelPane.helpers.ts +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.ts @@ -1,9 +1,9 @@ import { isEphemeralChannel } from "@/features/channels/lib/ephemeralChannel"; +import { collectMessageMentionPubkeys } from "@/features/messages/lib/formatTimelineMessages"; import type { TimelineMessage } from "@/features/messages/types"; import type { Channel } from "@/shared/api/types"; import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; import { normalizePubkey } from "@/shared/lib/pubkey"; -import { getMentionTagPubkey } from "@/shared/lib/resolveMentionNames"; export function getChannelIntroKind(channel: Channel): string { const isPrivate = channel.visibility === "private"; @@ -122,43 +122,57 @@ export function getDmAutoRouteAgentPubkeys({ } export function getThreadAutoRouteAgentPubkeys({ + currentPubkey, knownAgentPubkeys, messages, }: { + currentPubkey?: string; knownAgentPubkeys: ReadonlySet; messages: readonly TimelineMessage[]; }) { const agentPubkeys = new Map(); const humanPubkeys = new Set(); - const addParticipant = (pubkey: string | null | undefined) => { - if (!pubkey) { + const normalizedCurrentPubkey = currentPubkey + ? normalizePubkey(currentPubkey) + : null; + + const addAuthor = (pubkey?: string | null) => { + if (!pubkey) return; + const normalized = normalizePubkey(pubkey); + if (!normalized) return; + if (knownAgentPubkeys.has(normalized)) { + agentPubkeys.set(normalized, pubkey); return; } + humanPubkeys.add(normalized); + }; + for (const message of messages) { + addAuthor(message.pubkey); + } + + for (const pubkey of collectMessageMentionPubkeys([...messages])) { const normalized = normalizePubkey(pubkey); if (!normalized) { - return; + continue; } if (knownAgentPubkeys.has(normalized)) { agentPubkeys.set(normalized, pubkey); - return; + continue; } humanPubkeys.add(normalized); - }; - - for (const message of messages) { - addParticipant(message.pubkey); + } - for (const tag of message.tags ?? []) { - addParticipant(getMentionTagPubkey(tag)); - } + if (agentPubkeys.size !== 1 || humanPubkeys.size !== 1) { + return []; + } + if (normalizedCurrentPubkey && !humanPubkeys.has(normalizedCurrentPubkey)) { + return []; } - return agentPubkeys.size === 1 && humanPubkeys.size === 1 - ? [...agentPubkeys.values()] - : []; + return [...agentPubkeys.values()]; } export function mergeAutoRouteMentionPubkeys({ diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 649bbaf2e..764e458cb 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -51,7 +51,9 @@ import { canOpenAgentConversationInChannel, getChannelIntroDescription, getChannelIntroKind, + getThreadAutoRouteAgentPubkeys, isWelcomeSetupSystemMessage, + mergeAutoRouteMentionPubkeys, mentionsKnownAgent, } from "@/features/channels/ui/ChannelPane.helpers"; import * as agentSessionSelection from "@/features/channels/ui/agentSessionSelection"; @@ -355,7 +357,6 @@ export const ChannelPane = React.memo(function ChannelPane({ threadMessages.some((entry) => entry.message.id === editTarget.id)); const mainEditTarget = editTarget && !isEditInThread ? editTarget : null; const threadEditTarget = editTarget && isEditInThread ? editTarget : null; - const findLastOwnEditable = React.useCallback( (candidates: TimelineMessage[]): TimelineMessage | null => { if (!onEdit || !currentPubkey) return null; @@ -376,7 +377,6 @@ export const ChannelPane = React.memo(function ChannelPane({ }, [onEdit, currentPubkey], ); - const handleEditLastOwnMainMessage = React.useCallback((): boolean => { const target = findLastOwnEditable(messages); if (!target || !onEdit) return false; @@ -700,6 +700,25 @@ export const ChannelPane = React.memo(function ChannelPane({ ...threadMessages.map((entry) => entry.message), ]; }, [threadHeadMessage, threadMessages]); + const threadAutoRouteAgentPubkeys = React.useMemo( + () => + getThreadAutoRouteAgentPubkeys({ + currentPubkey, + knownAgentPubkeys, + messages: threadSourceMessages, + }), + [currentPubkey, knownAgentPubkeys, threadSourceMessages], + ); + const handleSendThreadReply = React.useCallback( + (content: string, mentionPubkeys: string[], mediaTags?: string[][]) => { + const sendMentionPubkeys = mergeAutoRouteMentionPubkeys({ + autoRouteAgentPubkeys: threadAutoRouteAgentPubkeys, + mentionPubkeys, + }); + return onSendThreadReply(content, sendMentionPubkeys, mediaTags); + }, + [onSendThreadReply, threadAutoRouteAgentPubkeys], + ); const hiddenAgentConversationMessageIds = React.useMemo(() => { const hiddenIds = getHiddenAgentConversationMessageIds( baseVisibleMessages, @@ -1029,7 +1048,6 @@ export const ChannelPane = React.memo(function ChannelPane({ ) : null} ) : null} - {!isTasksSurface && channelManagementOpen && activeChannel ? ( { + const pubkeys = new Set(agentPubkeys); + for (const [pubkey, profile] of Object.entries(messageProfiles)) { + if (profile?.isAgent) { + pubkeys.add(normalizePubkey(pubkey)); + } + } + return pubkeys; + }, [agentPubkeys, messageProfiles]); + const dmAutoRouteAgentPubkeys = React.useMemo( + () => + getDmAutoRouteAgentPubkeys({ + channel: activeChannel, + currentPubkey, + knownAgentPubkeys: routingAgentPubkeys, + }), + [activeChannel, currentPubkey, routingAgentPubkeys], + ); const personasQuery = usePersonasQuery(); const { personaLookup, respondToLookup } = React.useMemo(() => { const agents = managedAgentsQuery.data ?? []; @@ -477,37 +494,6 @@ export function ChannelScreen({ timelineMessages.find((message) => message.id === editTargetId) ?? null, [editTargetId, timelineMessages], ); - const routingAgentPubkeys = React.useMemo(() => { - const pubkeys = new Set(agentPubkeys); - for (const [pubkey, profile] of Object.entries(messageProfiles)) { - if (profile?.isAgent) { - pubkeys.add(normalizePubkey(pubkey)); - } - } - return pubkeys; - }, [agentPubkeys, messageProfiles]); - const messageAutoRouteAgentPubkeys = React.useMemo( - () => - getDmAutoRouteAgentPubkeys({ - channel: activeChannel, - currentPubkey, - knownAgentPubkeys: routingAgentPubkeys, - }), - [activeChannel, currentPubkey, routingAgentPubkeys], - ); - const threadAutoRouteAgentPubkeys = React.useMemo(() => { - if (!openThreadHeadMessage) { - return []; - } - - return getThreadAutoRouteAgentPubkeys({ - knownAgentPubkeys: routingAgentPubkeys, - messages: [ - openThreadHeadMessage, - ...threadMessages.map((entry) => entry.message), - ], - }); - }, [openThreadHeadMessage, routingAgentPubkeys, threadMessages]); const { handleCancelEdit, handleCancelThreadReply, @@ -525,7 +511,6 @@ export function ChannelScreen({ deleteMessageMutation, editMessageMutation, editTargetId, - messageAutoRouteAgentPubkeys, expandedThreadReplyIds, getFirstReplyIdForMessage, getReplyDescendantIdsForMessage, @@ -538,10 +523,26 @@ export function ChannelScreen({ setOpenThreadHeadId, setThreadReplyTargetId, setThreadScrollTargetId, - threadAutoRouteAgentPubkeys, threadReplyTargetId, toggleReactionMutation, }); + const handleSendMessageWithDmAutoRoute = React.useCallback( + async ( + content: string, + mentionPubkeys: string[], + mediaTags?: string[][], + ) => { + await handleSendMessage( + content, + mergeAutoRouteMentionPubkeys({ + autoRouteAgentPubkeys: dmAutoRouteAgentPubkeys, + mentionPubkeys, + }), + mediaTags, + ); + }, + [dmAutoRouteAgentPubkeys, handleSendMessage], + ); const effectiveToggleReaction = React.useMemo( () => activeChannel && !activeChannel.archivedAt && activeChannel.isMember @@ -902,7 +903,7 @@ export function ChannelScreen({ activeChannel={activeChannel} activityAgents={channelAgentSessionAgents} agentConversationMarkers={agentConversationMarkers} - agentPubkeys={agentPubkeys} + agentPubkeys={routingAgentPubkeys} agentSessionAgents={agentSessionAgents} botTypingEntries={botTypingEntries} channelFind={channelFind} @@ -977,7 +978,7 @@ export function ChannelScreen({ onCloseProfilePanel={handleCloseProfilePanel} onOpenThread={handleOpenThreadAndCloseAgentSession} onSelectThreadReplyTarget={handleSelectThreadReplyTarget} - onSendMessage={handleSendMessage} + onSendMessage={handleSendMessageWithDmAutoRoute} onSendVideoReviewComment={effectiveSendVideoReviewComment} onSendThreadReply={handleSendThreadReply} onThreadScrollTargetChange={setThreadScrollTargetId} diff --git a/desktop/src/features/channels/ui/ChannelTasksView.tsx b/desktop/src/features/channels/ui/ChannelTasksView.tsx index b7d701909..9d1e00058 100644 --- a/desktop/src/features/channels/ui/ChannelTasksView.tsx +++ b/desktop/src/features/channels/ui/ChannelTasksView.tsx @@ -105,7 +105,7 @@ function ChannelTaskRow({
diff --git a/desktop/src/features/channels/useChannelPaneHandlers.ts b/desktop/src/features/channels/useChannelPaneHandlers.ts index 8491f930d..e753f4cbb 100644 --- a/desktop/src/features/channels/useChannelPaneHandlers.ts +++ b/desktop/src/features/channels/useChannelPaneHandlers.ts @@ -8,7 +8,6 @@ import type { useSendMessageMutation, useToggleReactionMutation, } from "@/features/messages/hooks"; -import { mergeAutoRouteMentionPubkeys } from "@/features/channels/ui/ChannelPane.helpers"; /** * Stable callback references for ChannelPane so that keystroke-driven @@ -22,7 +21,6 @@ export function useChannelPaneHandlers({ deleteMessageMutation, editMessageMutation, editTargetId, - messageAutoRouteAgentPubkeys, expandedThreadReplyIds, getFirstReplyIdForMessage, getReplyDescendantIdsForMessage, @@ -35,14 +33,12 @@ export function useChannelPaneHandlers({ setOpenThreadHeadId, setThreadReplyTargetId, setThreadScrollTargetId, - threadAutoRouteAgentPubkeys, threadReplyTargetId, toggleReactionMutation, }: { deleteMessageMutation: ReturnType; editMessageMutation: ReturnType; editTargetId: string | null; - messageAutoRouteAgentPubkeys: readonly string[]; expandedThreadReplyIds: ReadonlySet; getFirstReplyIdForMessage: (messageId: string) => string | null; getReplyDescendantIdsForMessage: (messageId: string) => string[]; @@ -57,7 +53,6 @@ export function useChannelPaneHandlers({ setOpenThreadHeadId: (value: string | null) => void; setThreadReplyTargetId: React.Dispatch>; setThreadScrollTargetId: React.Dispatch>; - threadAutoRouteAgentPubkeys: readonly string[]; threadReplyTargetId: string | null; toggleReactionMutation: ReturnType; }) { @@ -74,16 +69,6 @@ export function useChannelPaneHandlers({ const expandedThreadReplyIdsRef = React.useRef(expandedThreadReplyIds); expandedThreadReplyIdsRef.current = expandedThreadReplyIds; - const messageAutoRouteAgentPubkeysRef = React.useRef( - messageAutoRouteAgentPubkeys, - ); - messageAutoRouteAgentPubkeysRef.current = messageAutoRouteAgentPubkeys; - - const threadAutoRouteAgentPubkeysRef = React.useRef( - threadAutoRouteAgentPubkeys, - ); - threadAutoRouteAgentPubkeysRef.current = threadAutoRouteAgentPubkeys; - const sendMutateRef = React.useRef(sendMessageMutation.mutateAsync); sendMutateRef.current = sendMessageMutation.mutateAsync; @@ -242,10 +227,7 @@ export function useChannelPaneHandlers({ ) => { await sendMutateRef.current({ content, - mentionPubkeys: mergeAutoRouteMentionPubkeys({ - autoRouteAgentPubkeys: messageAutoRouteAgentPubkeysRef.current, - mentionPubkeys, - }), + mentionPubkeys, mediaTags, }); }, @@ -279,10 +261,7 @@ export function useChannelPaneHandlers({ const sentMessage = await sendMutateRef.current({ content, - mentionPubkeys: mergeAutoRouteMentionPubkeys({ - autoRouteAgentPubkeys: threadAutoRouteAgentPubkeysRef.current, - mentionPubkeys, - }), + mentionPubkeys, parentEventId, mediaTags, }); diff --git a/desktop/src/features/messages/lib/agentConversationLinkNode.tsx b/desktop/src/features/messages/lib/agentConversationLinkNode.tsx new file mode 100644 index 000000000..3a261252e --- /dev/null +++ b/desktop/src/features/messages/lib/agentConversationLinkNode.tsx @@ -0,0 +1,239 @@ +import { mergeAttributes, Node } from "@tiptap/core"; +import { + NodeViewWrapper, + ReactNodeViewRenderer, + type NodeViewProps, +} from "@tiptap/react"; +import { ClipboardPlus } from "lucide-react"; + +import { + AGENT_CONVERSATION_LINK_URL_PATTERN, + isAgentConversationLink, + trimAgentConversationLinkMatch, +} from "@/features/agents/agentConversationLink"; +import { AGENT_CONVERSATION_LINK_NODE_NAME } from "./agentConversationLinkNodeName"; + +export type AgentConversationLinkNodeOptions = { + titleForHref?: (href: string) => string | undefined; +}; + +function resolveTaskLinkFromElement(element: HTMLElement) { + const dataHref = element.getAttribute("data-href"); + if (isAgentConversationLink(dataHref)) { + return dataHref; + } + + const href = element.getAttribute("href"); + if (isAgentConversationLink(href)) { + return href; + } + + const title = element.getAttribute("title"); + if (isAgentConversationLink(title)) { + return title; + } + + return null; +} + +function getDisplayTitle( + href: string, + explicitTitle: string | null | undefined, + options: AgentConversationLinkNodeOptions, +) { + return ( + explicitTitle?.trim() || options.titleForHref?.(href)?.trim() || "Task" + ); +} + +function ComposerAgentConversationLinkView({ extension, node }: NodeViewProps) { + const href = String(node.attrs.href ?? ""); + const title = getDisplayTitle( + href, + String(node.attrs.title ?? ""), + extension.options as AgentConversationLinkNodeOptions, + ); + + return ( + + + + + + + + Task + + + {title} + + + + Open + + + + ); +} + +function registerAgentConversationLinkMarkdownIt( + // biome-ignore lint/suspicious/noExplicitAny: markdown-it is untyped here + md: any, + options: AgentConversationLinkNodeOptions, +): void { + const RULE_NAME = "buzz_agent_conversation_link"; + const TOKEN_TYPE = "buzz_agent_conversation_link"; + + if (md.renderer.rules[TOKEN_TYPE]) return; + + // biome-ignore lint/suspicious/noExplicitAny: markdown-it state/silent + const rule = (state: any, silent: boolean): boolean => { + AGENT_CONVERSATION_LINK_URL_PATTERN.lastIndex = state.pos; + const match = AGENT_CONVERSATION_LINK_URL_PATTERN.exec(state.src); + if (!match || match.index !== state.pos) { + return false; + } + + const { value } = trimAgentConversationLinkMatch(match[0]); + if (!isAgentConversationLink(value)) { + return false; + } + + if (!silent) { + const token = state.push(TOKEN_TYPE, "span", 0); + token.meta = { + href: value, + title: options.titleForHref?.(value) ?? "", + }; + } + state.pos += value.length; + return true; + }; + + md.inline.ruler.before("text", RULE_NAME, rule); + + // biome-ignore lint/suspicious/noExplicitAny: markdown-it token + md.renderer.rules[TOKEN_TYPE] = (tokens: any[], idx: number): string => { + const { href, title } = tokens[idx].meta as { + href: string; + title: string; + }; + const esc = md.utils.escapeHtml; + return ``; + }; +} + +export const AgentConversationLinkNode = + Node.create({ + name: AGENT_CONVERSATION_LINK_NODE_NAME, + + group: "inline", + inline: true, + atom: true, + selectable: true, + + addOptions() { + return { + titleForHref: undefined, + }; + }, + + addAttributes() { + return { + href: { + default: "", + parseHTML: (element) => + resolveTaskLinkFromElement(element as HTMLElement) ?? "", + renderHTML: (attrs) => ({ "data-href": attrs.href }), + }, + title: { + default: "", + parseHTML: (element) => + (element as HTMLElement).getAttribute("data-title") ?? "", + renderHTML: (attrs) => ({ "data-title": attrs.title }), + }, + }; + }, + + parseHTML() { + return [{ tag: "[data-agent-conversation-link]" }]; + }, + + renderHTML({ HTMLAttributes, node }) { + const href = String(node.attrs.href ?? ""); + const title = getDisplayTitle( + href, + String(node.attrs.title ?? ""), + this.options, + ); + + return [ + "span", + mergeAttributes(HTMLAttributes, { + "data-agent-conversation-link": "", + "data-href": href, + "data-title": title, + title: href, + }), + [ + "span", + { + class: + "my-1 flex min-w-52 max-w-full overflow-hidden rounded-lg border border-border/70 bg-muted/35 align-top sm:max-w-xl", + }, + [ + "span", + { + class: "flex min-w-0 flex-1 items-center gap-3 px-3 py-2", + }, + ["span", {}, "Task"], + ["span", {}, title], + ], + ], + ]; + }, + + renderText({ node }) { + return String(node.attrs.href ?? ""); + }, + + addNodeView() { + return ReactNodeViewRenderer(ComposerAgentConversationLinkView); + }, + + addStorage() { + return { + markdown: { + serialize( + // biome-ignore lint/suspicious/noExplicitAny: prosemirror-markdown state is untyped + state: any, + // biome-ignore lint/suspicious/noExplicitAny: PM node + node: any, + ) { + state.write(String(node.attrs.href ?? "")); + }, + parse: { + setup( + this: { options: AgentConversationLinkNodeOptions }, + // biome-ignore lint/suspicious/noExplicitAny: markdown-it is untyped here + md: any, + ) { + registerAgentConversationLinkMarkdownIt(md, this.options); + }, + }, + }, + }; + }, + }); diff --git a/desktop/src/features/messages/lib/agentConversationLinkNodeName.ts b/desktop/src/features/messages/lib/agentConversationLinkNodeName.ts new file mode 100644 index 000000000..38d06b066 --- /dev/null +++ b/desktop/src/features/messages/lib/agentConversationLinkNodeName.ts @@ -0,0 +1 @@ +export const AGENT_CONVERSATION_LINK_NODE_NAME = "agentConversationLink"; diff --git a/desktop/src/features/messages/lib/composerPasteHandler.ts b/desktop/src/features/messages/lib/composerPasteHandler.ts new file mode 100644 index 000000000..d4c159ff7 --- /dev/null +++ b/desktop/src/features/messages/lib/composerPasteHandler.ts @@ -0,0 +1,84 @@ +import { getBuzzCodeBlockClipboardText } from "@/shared/lib/codeBlockClipboard"; +import { + hasMentionClipboardHtml, + normalizeMentionClipboardHtml, +} from "@/features/messages/lib/normalizeMentionClipboard"; +import { buildTaskLinkPasteContent } from "@/features/messages/lib/taskLinkPasteContent"; +import type { MediaUploadController } from "@/features/messages/lib/useMediaUpload"; +import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTextEditor"; + +type PasteView = { + pasteHTML: (html: string) => void; +}; + +type ComposerPasteHandlerOptions = { + agentConversationTitleForHref?: (href: string) => string | undefined; + editor: NonNullable; + scrollComposerToBottom: () => void; + uploadFile: MediaUploadController["uploadFile"]; +}; + +export function createMessageComposerPasteHandler({ + agentConversationTitleForHref, + editor, + scrollComposerToBottom, + uploadFile, +}: ComposerPasteHandlerOptions) { + return (view: PasteView, event: ClipboardEvent) => { + const items = Array.from(event.clipboardData?.items ?? []); + const mediaItem = items.find((item) => item.kind === "file"); + if (mediaItem) { + const file = mediaItem.getAsFile(); + if (file) { + void uploadFile(file); + } + return true; + } + + const codeBlockText = getBuzzCodeBlockClipboardText(event.clipboardData); + if (codeBlockText !== null) { + event.preventDefault(); + editor + .chain() + .focus() + .insertContent([ + { + type: "codeBlock", + content: + codeBlockText.length > 0 + ? [{ type: "text", text: codeBlockText }] + : [], + }, + { type: "paragraph" }, + ]) + .run(); + scrollComposerToBottom(); + return true; + } + + const html = event.clipboardData?.getData("text/html"); + if (html && hasMentionClipboardHtml(html)) { + const cleanHtml = normalizeMentionClipboardHtml(html); + event.preventDefault(); + view.pasteHTML(cleanHtml); + return true; + } + + const plainText = event.clipboardData?.getData("text/plain") ?? ""; + const taskLinkPasteContent = + plainText.includes("\n") || plainText.trim().length === 0 + ? null + : buildTaskLinkPasteContent(plainText, agentConversationTitleForHref); + if (taskLinkPasteContent) { + event.preventDefault(); + editor.chain().focus().insertContent(taskLinkPasteContent).run(); + return true; + } + + if (plainText.includes("\n")) { + scrollComposerToBottom(); + } + + return false; + }; +} diff --git a/desktop/src/features/messages/lib/plainTextProjection.ts b/desktop/src/features/messages/lib/plainTextProjection.ts index bbafacffe..d18afd813 100644 --- a/desktop/src/features/messages/lib/plainTextProjection.ts +++ b/desktop/src/features/messages/lib/plainTextProjection.ts @@ -1,6 +1,7 @@ import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; import { CUSTOM_EMOJI_NODE_NAME } from "./customEmojiNode"; +import { AGENT_CONVERSATION_LINK_NODE_NAME } from "./agentConversationLinkNodeName"; /** * Plain-text projection of a ProseMirror document. @@ -170,13 +171,18 @@ export function buildPlainTextProjection( return true; // descend into block children } - // ── Leaf inline: custom-emoji atom ───────────────────────────── - // 1 PM position wide, projects to its full `:shortcode:` text. Keeps - // the two mappings consistent with what `renderText` emits, so cursor - // math and autocomplete offsets see the shortcode at its natural width. - if (node.type.name === CUSTOM_EMOJI_NODE_NAME) { - const shortcode = String(node.attrs.shortcode ?? ""); - const projected = `:${shortcode}:`; + // ── Leaf inline atoms ────────────────────────────────────────── + // 1 PM position wide, projecting to their markdown/plain-text + // representation. Keeps autocomplete offsets and send-disabled checks + // aligned with what node renderText/markdown serialization emits. + if ( + node.type.name === CUSTOM_EMOJI_NODE_NAME || + node.type.name === AGENT_CONVERSATION_LINK_NODE_NAME + ) { + const projected = + node.type.name === CUSTOM_EMOJI_NODE_NAME + ? `:${String(node.attrs.shortcode ?? "")}:` + : String(node.attrs.href ?? ""); segments.push({ kind: "atom", pmFrom: pos, diff --git a/desktop/src/features/messages/lib/taskLinkPasteContent.ts b/desktop/src/features/messages/lib/taskLinkPasteContent.ts new file mode 100644 index 000000000..d97828cf5 --- /dev/null +++ b/desktop/src/features/messages/lib/taskLinkPasteContent.ts @@ -0,0 +1,59 @@ +import { + AGENT_CONVERSATION_LINK_URL_PATTERN, + isAgentConversationLink, + trimAgentConversationLinkMatch, +} from "@/features/agents/agentConversationLink"; +import { AGENT_CONVERSATION_LINK_NODE_NAME } from "@/features/messages/lib/agentConversationLinkNodeName"; + +type TaskLinkPasteContent = + | { type: "text"; text: string } + | { + type: typeof AGENT_CONVERSATION_LINK_NODE_NAME; + attrs: { href: string; title: string }; + }; + +export function buildTaskLinkPasteContent( + text: string, + titleForHref?: (href: string) => string | undefined, +): TaskLinkPasteContent[] | null { + AGENT_CONVERSATION_LINK_URL_PATTERN.lastIndex = 0; + const content: TaskLinkPasteContent[] = []; + let cursor = 0; + let hasTaskLink = false; + + for (const match of text.matchAll(AGENT_CONVERSATION_LINK_URL_PATTERN)) { + const matchText = match[0]; + const matchIndex = match.index ?? 0; + const { value, trailing } = trimAgentConversationLinkMatch(matchText); + if (!isAgentConversationLink(value)) { + continue; + } + + if (matchIndex > cursor) { + content.push({ type: "text", text: text.slice(cursor, matchIndex) }); + } + + content.push({ + type: AGENT_CONVERSATION_LINK_NODE_NAME, + attrs: { + href: value, + title: titleForHref?.(value) ?? "", + }, + }); + if (trailing) { + content.push({ type: "text", text: trailing }); + } + + cursor = matchIndex + matchText.length; + hasTaskLink = true; + } + + if (!hasTaskLink) { + return null; + } + + if (cursor < text.length) { + content.push({ type: "text", text: text.slice(cursor) }); + } + return content; +} diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index 97784317d..46c3bd54c 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -22,6 +22,7 @@ import { mentionHighlightKey, } from "./mentionHighlightExtension"; import { CUSTOM_EMOJI_NODE_NAME } from "./customEmojiNode"; +import { AgentConversationLinkNode } from "./agentConversationLinkNode"; import { useComposerCustomEmoji } from "./useComposerCustomEmoji"; import { buildPlainTextProjection } from "./plainTextProjection"; import { createLinkInteractionExtension } from "./linkInteractionExtension"; @@ -60,6 +61,8 @@ export type RichTextEditorOptions = { channelNames?: string[]; /** Known custom-emoji set; used to render `:shortcode:` inline as images. */ customEmoji?: CustomEmoji[]; + /** Resolve task-link titles for composer task cards. */ + agentConversationTitleForHref?: (href: string) => string | undefined; /** Called on plain Enter (submit). Handled inside Tiptap's extension system * so it fires *before* ProseMirror's default splitBlock behaviour. */ onSubmit?: () => void; @@ -111,6 +114,7 @@ export function useRichTextEditor({ agentMentionNames, channelNames, customEmoji, + agentConversationTitleForHref, onSubmit, onEditLastOwnMessage, isAutocompleteOpen, @@ -138,6 +142,18 @@ export function useRichTextEditor({ // Custom-emoji atom node wiring (config + src re-resolve). Kept in a sibling // hook so this file stays focused on generic editor setup. const customEmojiWiring = useComposerCustomEmoji(customEmoji); + const agentConversationTitleForHrefRef = React.useRef( + agentConversationTitleForHref, + ); + agentConversationTitleForHrefRef.current = agentConversationTitleForHref; + const agentConversationLinkExtension = React.useMemo( + () => + AgentConversationLinkNode.configure({ + titleForHref: (href) => + agentConversationTitleForHrefRef.current?.(href), + }), + [], + ); const editor = useEditor( { @@ -323,6 +339,7 @@ export function useRichTextEditor({ SpoilerMark, MentionHighlightExtension, customEmojiWiring.extension, + agentConversationLinkExtension, Placeholder.configure({ placeholder: () => placeholderRef.current ?? "Write a message…", }), @@ -410,7 +427,7 @@ export function useRichTextEditor({ onUpdateRef.current?.({ markdown, text }); }, }, - [], + [agentConversationLinkExtension], ); // Toggle editable without destroying the editor instance. diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 2778c3fd7..e2f8ecccc 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -24,10 +24,6 @@ import { } from "@/features/messages/lib/useMediaUpload"; import { useMentions } from "@/features/messages/lib/useMentions"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; -import { - hasMentionClipboardHtml, - normalizeMentionClipboardHtml, -} from "@/features/messages/lib/normalizeMentionClipboard"; import { CUSTOM_EMOJI_NODE_NAME } from "@/features/messages/lib/customEmojiNode"; import { type AutocompleteEdit, @@ -37,7 +33,7 @@ import { import { useLinkEditor } from "@/features/messages/lib/useLinkEditor"; import { useComposerSpoilerParticles } from "@/features/messages/lib/useComposerSpoilerParticles"; import { useTypingBroadcast } from "@/features/messages/useTypingBroadcast"; -import { getBuzzCodeBlockClipboardText } from "@/shared/lib/codeBlockClipboard"; +import { createMessageComposerPasteHandler } from "@/features/messages/lib/composerPasteHandler"; import { cn } from "@/shared/lib/cn"; import type { ChannelType } from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; @@ -92,6 +88,7 @@ type MessageComposerProps = { mentionPubkeys: string[], mediaTags?: string[][], ) => Promise; + agentConversationTitleForHref?: (href: string) => string | undefined; placeholder?: string; profiles?: UserProfileLookup; replyTarget?: { @@ -120,6 +117,7 @@ export function MessageComposer({ onEditLastOwnMessage, onEditSave, onSend, + agentConversationTitleForHref, placeholder, profiles, replyTarget = null, @@ -229,6 +227,7 @@ export function MessageComposer({ agentMentionNames: mentions.agentKnownNames, channelNames: channelLinks.knownChannelNames, customEmoji, + agentConversationTitleForHref, onSubmit: () => submitMessageRef.current(), onEditLastOwnMessage: () => { // Never re-enter edit from an empty edit (e.g. image-only edit whose @@ -673,72 +672,15 @@ export function MessageComposer({ richText.editor.setOptions({ editorProps: { ...richText.editor.options.editorProps, - handlePaste: (_view, event) => { - // --- File paste --- - // Any actual file (image, video, document, …) pastes as an - // attachment. String/text items have kind "string", so plain-text - // and code-block paste fall through to the handlers below. - const items = Array.from(event.clipboardData?.items ?? []); - const mediaItem = items.find((item) => item.kind === "file"); - if (mediaItem) { - const file = mediaItem.getAsFile(); - if (file) { - void uploadFileRef.current(file); - } - return true; - } - - // --- Buzz code-block paste --- - // The code block copy button writes a small Buzz marker alongside - // plain text. Use it to paste back as a literal code block so Markdown - // parsing cannot reshape indentation, fence markers, or headings. - const codeBlockText = getBuzzCodeBlockClipboardText( - event.clipboardData, - ); - if (codeBlockText !== null) { - event.preventDefault(); - richText.editor - ?.chain() - .focus() - .insertContent([ - { - type: "codeBlock", - content: - codeBlockText.length > 0 - ? [{ type: "text", text: codeBlockText }] - : [], - }, - { type: "paragraph" }, - ]) - .run(); - scrollComposerToBottom(); - return true; - } - - // --- Mention / channel-link normalization --- - // When copying from the chat area the browser puts styled HTML - // on the clipboard. The mention/channel-link wrappers have - // font-weight:600 which Tiptap's Bold extension misinterprets - // as bold. Strip those wrappers and use ProseMirror's pasteHTML - // to parse the cleaned HTML into proper rich content nodes. - const html = event.clipboardData?.getData("text/html"); - if (html && hasMentionClipboardHtml(html)) { - const cleanHtml = normalizeMentionClipboardHtml(html); - event.preventDefault(); - _view.pasteHTML(cleanHtml); - return true; - } - - const plainText = event.clipboardData?.getData("text/plain") ?? ""; - if (plainText.includes("\n")) { - scrollComposerToBottom(); - } - - return false; - }, + handlePaste: createMessageComposerPasteHandler({ + agentConversationTitleForHref, + editor: richText.editor, + scrollComposerToBottom, + uploadFile: uploadFileRef.current, + }), }, }); - }, [richText.editor, scrollComposerToBottom]); + }, [richText.editor, scrollComposerToBottom, agentConversationTitleForHref]); // ── Send button state ─────────────────────────────────────────────── const sendDisabled = React.useMemo( diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 343df21a7..5ac6891d7 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -4,6 +4,7 @@ import type { TimelineMessage, TimelineReaction, } from "@/features/messages/types"; +import type { AgentConversationMarker } from "@/features/agents/agentConversations"; import { MessageReactions } from "@/features/messages/ui/MessageReactions"; import { useReactionHandler } from "@/features/messages/ui/useReactionHandler"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; @@ -119,6 +120,7 @@ export const MessageRow = React.memo( highlightThreadLineDepths, hoverBackground = true, actionBarPlacement = "floating", + agentConversationMarkers, collapseDescendantsLabel, isFollowingThread, isUnread, @@ -143,6 +145,7 @@ export const MessageRow = React.memo( agentPubkeys, videoReviewContext, }: { + agentConversationMarkers?: readonly AgentConversationMarker[]; agentPubkeys?: ReadonlySet; channelId?: string | null; collapseDepthGuideActions?: ReadonlyArray; @@ -354,6 +357,7 @@ export const MessageRow = React.memo( return ( {showUnreadDivider ? : null}
+ getAgentConversationMarkerTitleForHref( + agentConversationMarkers, + href, + ) + } channelId={channelId} channelName={channelName} channelType={channel?.channelType ?? null} diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index a57c5d38e..da477aec7 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -223,6 +223,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ case "message": return ( void; }; +type AgentConversationLinkCardProps = { + href: string; + interactive: boolean; + link: ParsedAgentConversationLink; + marker?: AgentConversationMarker; + onOpenAgentConversationLink: (link: ParsedAgentConversationLink) => void; +}; + let shikiHighlighter: HighlighterGeneric | null = null; let shikiInitPromise: Promise | null = null; @@ -206,6 +223,7 @@ const VideoReviewMarkdownContext = React.createContext< type MarkdownRuntime = { agentMentionPubkeysByName?: Record; + agentConversationMarkers?: readonly AgentConversationMarker[]; channels: Channel[]; imetaByUrl?: ImetaLookup; mentionPubkeysByName?: Record; @@ -277,6 +295,7 @@ function messageLinkUrlTransform(value: string, key: string): string { } type MarkdownProps = { + agentConversationMarkers?: readonly AgentConversationMarker[]; channelNames?: string[]; className?: string; content: string; @@ -1272,6 +1291,17 @@ function getCodeBlockText(children: React.ReactNode) { return getReactNodeText(children).replace(/\n$/, ""); } +function findAgentConversationMarker( + markers: readonly AgentConversationMarker[] | undefined, + link: ParsedAgentConversationLink, +): AgentConversationMarker | undefined { + return markers?.find( + (marker) => + marker.channelId === link.channelId && + marker.agentReplyId === link.agentReplyId, + ); +} + function MessageLinkPill({ channels, href, @@ -1312,6 +1342,75 @@ function MessageLinkPill({ ); } +function AgentConversationLinkCard({ + href, + interactive, + link, + marker, + onOpenAgentConversationLink, +}: AgentConversationLinkCardProps) { + const title = marker?.title?.trim() || "Task"; + const content = ( + <> + + + + + + Task + + + {title} + + + {interactive ? ( + + Open + + ) : null} + + ); + + if (!interactive) { + return ( + + + {content} + + + ); + } + + return ( + + ); +} + function InlineEmojiPopover({ alt, resolvedSrc, @@ -1815,8 +1914,12 @@ function createMarkdownComponents( ), a: ({ children, href, ...props }) => { - const { imetaByUrl, onOpenAgentConversationLink, onOpenMessageLink } = - runtimeRef.current; + const { + agentConversationMarkers, + imetaByUrl, + onOpenAgentConversationLink, + onOpenMessageLink, + } = runtimeRef.current; if (!interactive) { return {children}; } @@ -1881,24 +1984,40 @@ function createMarkdownComponents( ); } - - if (isAgentConversationLink(href)) { - const parsed = parseAgentConversationLink(href); - if (parsed.ok) { + const agentConversationLinkTarget = + resolveAgentConversationLinkRenderTarget({ + href, + label: getReactNodeText(children), + }); + if (agentConversationLinkTarget.kind !== "none") { + if (agentConversationLinkTarget.kind === "card") { return ( - { - event.preventDefault(); - onOpenAgentConversationLink(parsed.value); - }} - > - {children} - + interactive={interactive} + link={agentConversationLinkTarget.link} + marker={findAgentConversationMarker( + agentConversationMarkers, + agentConversationLinkTarget.link, + )} + onOpenAgentConversationLink={onOpenAgentConversationLink} + /> ); } + + return ( + { + event.preventDefault(); + onOpenAgentConversationLink(agentConversationLinkTarget.link); + }} + > + {children} + + ); } // Malformed message deep link — fall through to the default // anchor (renders as a normal external link). @@ -2181,7 +2300,7 @@ function createMarkdownComponents( }, "message-link": ({ children }: { children?: React.ReactNode }) => { const { channels, onOpenMessageLink } = runtimeRef.current; - const href = String(children ?? ""); + const href = getReactNodeText(children); const parsed = parseMessageLink(href); if (!parsed.ok) { // Malformed `buzz://message?…` — render the raw URL as plain text @@ -2204,36 +2323,32 @@ function createMarkdownComponents( }: { children?: React.ReactNode; }) => { - const { onOpenAgentConversationLink } = runtimeRef.current; - const href = String(children ?? ""); + const { agentConversationMarkers, onOpenAgentConversationLink } = + runtimeRef.current; + const href = getReactNodeText(children); const parsed = parseAgentConversationLink(href); if (!parsed.ok) { - return {href}; - } - - if (!interactive) { - return Open task; + return {href}; } return ( - + ); }, } as Components; } function MarkdownInner({ + agentConversationMarkers, channelNames, className, content, @@ -2255,6 +2370,14 @@ function MarkdownInner({ }, [goChannel], ); + const onOpenAgentConversationLink = React.useCallback( + (link: ParsedAgentConversationLink) => { + void goChannel(link.channelId, { + taskReplyId: link.agentReplyId, + }); + }, + [goChannel], + ); const onOpenMessageLink = React.useCallback( (link: ParsedMessageLink) => { // Always route through `goChannel` with `messageId` set: the channel @@ -2271,16 +2394,9 @@ function MarkdownInner({ }, [goChannel], ); - const onOpenAgentConversationLink = React.useCallback( - (link: ParsedAgentConversationLink) => { - void goChannel(link.channelId, { - taskReplyId: link.agentReplyId, - }); - }, - [goChannel], - ); const runtimeRef = useLatestRef({ agentMentionPubkeysByName, + agentConversationMarkers, channels, imetaByUrl, mentionPubkeysByName, @@ -2372,6 +2488,7 @@ export const Markdown = React.memo( MarkdownInner, (prev, next) => prev.content === next.content && + prev.agentConversationMarkers === next.agentConversationMarkers && prev.className === next.className && prev.customEmoji === next.customEmoji && prev.interactive === next.interactive &&