Fix task link navigation

This commit is contained in:
klopez4212
2026-06-30 13:55:15 +01:00
parent c1cd1f133c
commit 82caa72b66
7 changed files with 130 additions and 4 deletions
@@ -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",
});
});
@@ -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 {
@@ -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) => {
@@ -89,6 +89,7 @@ export type ChannelPaneProps = {
emoji: string,
remove: boolean,
) => Promise<void>;
onThreadScrollTargetChange: (messageId: string | null) => void;
onThreadScrollTargetResolved: () => void;
onThreadPanelResizeStart: (
event: React.PointerEvent<HTMLButtonElement>,
@@ -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
}
+39 -2
View File
@@ -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(
</SpoilerInline>
),
a: ({ children, href, ...props }) => {
const { imetaByUrl, linkPreviewHrefs, onOpenMessageLink } =
runtimeRef.current;
const {
imetaByUrl,
linkPreviewHrefs,
onOpenAgentConversationLink,
onOpenMessageLink,
} = runtimeRef.current;
if (!interactive) {
return <span className="font-medium text-current">{children}</span>;
}
@@ -1593,6 +1602,25 @@ function createMarkdownComponents(
</a>
);
}
if (isAgentConversationLink(href)) {
const parsed = parseAgentConversationLink(href);
if (parsed.ok) {
return (
<a
{...props}
className="font-medium text-primary underline underline-offset-4 transition-colors hover:text-primary/80 cursor-pointer"
href={href}
onClick={(event) => {
event.preventDefault();
onOpenAgentConversationLink(parsed.value);
}}
>
{children}
</a>
);
}
}
// 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<MarkdownRuntime>({
agentMentionPubkeysByName,
channels,
@@ -1969,6 +2005,7 @@ function MarkdownInner({
linkPreviewHrefs,
mentionPubkeysByName,
onOpenChannel,
onOpenAgentConversationLink,
onOpenMessageLink,
});
+2
View File
@@ -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<string>;
mentionPubkeysByName?: Record<string, string>;
onOpenChannel: (channelId: string) => void;
onOpenAgentConversationLink: (link: ParsedAgentConversationLink) => void;
onOpenMessageLink: (link: ParsedMessageLink) => void;
};