diff --git a/desktop/src/features/agents/agentConversationLink.test.mjs b/desktop/src/features/agents/agentConversationLink.test.mjs new file mode 100644 index 000000000..e8492af67 --- /dev/null +++ b/desktop/src/features/agents/agentConversationLink.test.mjs @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildAgentConversationLink, + parseAgentConversationLink, +} from "./agentConversationLink.ts"; + +test("buildAgentConversationLink -> parseAgentConversationLink round-trips", () => { + const href = buildAgentConversationLink({ + agentReplyId: "reply-1", + channelId: "channel-1", + }); + + assert.deepEqual(parseAgentConversationLink(href), { + ok: true, + value: { + agentReplyId: "reply-1", + channelId: "channel-1", + }, + }); +}); + +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"), { + ok: false, + reason: "missing-channel", + }); +}); diff --git a/desktop/src/features/agents/agentConversationLink.ts b/desktop/src/features/agents/agentConversationLink.ts index 08ba4899d..9aceee197 100644 --- a/desktop/src/features/agents/agentConversationLink.ts +++ b/desktop/src/features/agents/agentConversationLink.ts @@ -6,6 +6,15 @@ export type AgentConversationLinkInput = { channelId: string; }; +export type ParsedAgentConversationLink = { + agentReplyId: string; + channelId: string; +}; + +export type AgentConversationLinkParseResult = + | { ok: true; value: ParsedAgentConversationLink } + | { ok: false; reason: string }; + export function buildAgentConversationLink( input: AgentConversationLinkInput, ): string { @@ -23,6 +32,41 @@ export function buildAgentConversationLink( return `${AGENT_CONVERSATION_LINK_SCHEME}//${AGENT_CONVERSATION_LINK_HOST}?${params.toString()}`; } +export function parseAgentConversationLink( + url: string, +): AgentConversationLinkParseResult { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return { ok: false, reason: "invalid-url" }; + } + + if (parsed.protocol !== AGENT_CONVERSATION_LINK_SCHEME) { + return { ok: false, reason: "wrong-scheme" }; + } + if (parsed.hostname !== AGENT_CONVERSATION_LINK_HOST) { + return { ok: false, reason: "wrong-host" }; + } + + const channelId = parsed.searchParams.get("channel"); + const agentReplyId = parsed.searchParams.get("reply"); + if (!channelId) { + return { ok: false, reason: "missing-channel" }; + } + if (!agentReplyId) { + return { ok: false, reason: "missing-reply" }; + } + + return { + ok: true, + value: { + agentReplyId, + channelId, + }, + }; +} + export function isAgentConversationLink( href: string | undefined | null, ): boolean { diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 16ba692ce..832129ef6 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -344,6 +344,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onSendMessage, onSendVideoReviewComment, onSendThreadReply, + onThreadScrollTargetChange, onThreadScrollTargetResolved, onThreadPanelResizeStart, onTargetReached, @@ -614,13 +615,21 @@ export const ChannelPane = React.memo(function ChannelPane({ onSurfaceTabChange?.("messages"); if (marker.parentMessageId) { onOpenThread(threadMessage); + onSelectThreadReplyTarget(message); + onThreadScrollTargetChange(message.id); return; } onCloseThread(); setTaskFocusMessageId(message.id); }, - [onCloseThread, onOpenThread, onSurfaceTabChange], + [ + onCloseThread, + onOpenThread, + onSelectThreadReplyTarget, + onSurfaceTabChange, + onThreadScrollTargetChange, + ], ); const handleTimelineTargetReached = React.useCallback( (messageId: string) => { diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 6e0dab8eb..cf915f424 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -89,6 +89,7 @@ export type ChannelPaneProps = { emoji: string, remove: boolean, ) => Promise; + onThreadScrollTargetChange: (messageId: string | null) => void; onThreadScrollTargetResolved: () => void; onThreadPanelResizeStart: ( event: React.PointerEvent, diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 1a06b851c..9b877a1f1 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -86,7 +86,6 @@ import { useChannelUnreadState } from "./useChannelUnreadState"; import type { ChannelScreenProps } from "./ChannelScreen.types"; const HEADER_ACTIONS_COMPACT_BREAKPOINT_PX = 760; - export function ChannelScreen({ activeChannel, currentIdentity, @@ -1083,6 +1082,7 @@ export function ChannelScreen({ onSendMessage={handleSendMessage} onSendVideoReviewComment={effectiveSendVideoReviewComment} onSendThreadReply={handleSendThreadReply} + onThreadScrollTargetChange={setThreadScrollTargetId} onThreadScrollTargetResolved={ handleThreadScrollTargetResolved } diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index 9ff25590f..6a31c93dc 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -14,6 +14,11 @@ import remarkGfm from "remark-gfm"; import { toast } from "sonner"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { + isAgentConversationLink, + parseAgentConversationLink, + type ParsedAgentConversationLink, +} from "@/features/agents/agentConversationLink"; import { parseMessageLink, resolveMessageLinkRenderTarget, @@ -1525,8 +1530,12 @@ function createMarkdownComponents( ), a: ({ children, href, ...props }) => { - const { imetaByUrl, linkPreviewHrefs, onOpenMessageLink } = - runtimeRef.current; + const { + imetaByUrl, + linkPreviewHrefs, + onOpenAgentConversationLink, + onOpenMessageLink, + } = runtimeRef.current; if (!interactive) { return {children}; } @@ -1593,6 +1602,25 @@ function createMarkdownComponents( ); } + + if (isAgentConversationLink(href)) { + const parsed = parseAgentConversationLink(href); + if (parsed.ok) { + return ( + { + event.preventDefault(); + onOpenAgentConversationLink(parsed.value); + }} + > + {children} + + ); + } + } // Malformed message deep link — fall through to the default // anchor (renders as a normal external link). } @@ -1962,6 +1990,14 @@ function MarkdownInner({ () => new Set(linkPreviews.map((preview) => preview.href)), [linkPreviews], ); + const onOpenAgentConversationLink = React.useCallback( + (link: ParsedAgentConversationLink) => { + void goChannel(link.channelId, { + taskReplyId: link.agentReplyId, + }); + }, + [goChannel], + ); const runtimeRef = useLatestRef({ agentMentionPubkeysByName, channels, @@ -1969,6 +2005,7 @@ function MarkdownInner({ linkPreviewHrefs, mentionPubkeysByName, onOpenChannel, + onOpenAgentConversationLink, onOpenMessageLink, }); diff --git a/desktop/src/shared/ui/markdown/types.ts b/desktop/src/shared/ui/markdown/types.ts index 567cfe164..296352198 100644 --- a/desktop/src/shared/ui/markdown/types.ts +++ b/desktop/src/shared/ui/markdown/types.ts @@ -1,3 +1,4 @@ +import type { ParsedAgentConversationLink } from "@/features/agents/agentConversationLink"; import type { ParsedMessageLink } from "@/features/messages/lib/messageLink"; import type { Channel } from "@/shared/api/types"; import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; @@ -30,6 +31,7 @@ export type MarkdownRuntime = { linkPreviewHrefs: ReadonlySet; mentionPubkeysByName?: Record; onOpenChannel: (channelId: string) => void; + onOpenAgentConversationLink: (link: ParsedAgentConversationLink) => void; onOpenMessageLink: (link: ParsedMessageLink) => void; };