mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Fix task link navigation
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user