Gate channel tasks experiment

This commit is contained in:
klopez4212
2026-06-27 08:30:25 +01:00
parent 38dd806bc3
commit 7b5c0e4884
21 changed files with 235 additions and 87 deletions

View File

@@ -154,7 +154,7 @@ const overrides = new Map([
["src/features/messages/ui/MessageComposer.tsx", 1010],
// continued-agent-conversations: channel sidebar children and active
// conversation unread suppression. Queued to split with sidebar sections.
["src/features/sidebar/ui/AppSidebar.tsx", 1081],
["src/features/sidebar/ui/AppSidebar.tsx", 1087],
// PersistBackend enum + marker-on-keyring-success plumbing and its three
// fail-closed regression tests (silent identity rotation on keyring outage).
// A small overage from load-bearing security plumbing on a file already at

View File

@@ -69,6 +69,7 @@ import { useApplyTemplate } from "@/features/channel-templates/useApplyTemplate"
import { relayClient } from "@/shared/api/relayClient";
import { useIdentityQuery } from "@/shared/api/hooks";
import { useRelayAutoHeal } from "@/shared/api/useRelayAutoHeal";
import { CHANNEL_TASKS_FEATURE_ID, useFeatureEnabled } from "@/shared/features";
import { useDeferredStartup } from "@/shared/hooks/useDeferredStartup";
import { joinChannel } from "@/shared/api/tauri";
import type { SearchHit } from "@/shared/api/types";
@@ -89,6 +90,7 @@ const LazySettingsScreen = React.lazy(async () => {
export function AppShell() {
useWebviewZoomShortcuts();
useTauriWindowDrag();
const isChannelTasksEnabled = useFeatureEnabled(CHANNEL_TASKS_FEATURE_ID);
const workspacesHook = useWorkspaces();
const [isAddWorkspaceOpen, setIsAddWorkspaceOpen] = React.useState(false);
@@ -212,6 +214,7 @@ export function AppShell() {
} = useAgentConversationShellState({
channels,
currentPubkey,
enabled: isChannelTasksEnabled,
goAgents,
goChannel,
selectedView,
@@ -715,7 +718,11 @@ export function AppShell() {
}}
onAddWorkspaceOpenChange={setIsAddWorkspaceOpen}
onNewDmOpenChange={setIsNewDmOpen}
onHideAgentConversation={handleHideAgentConversation}
onHideAgentConversation={
isChannelTasksEnabled
? handleHideAgentConversation
: undefined
}
onCreateChannelOpenChange={setIsCreateChannelOpen}
onOpenAddWorkspace={() => setIsAddWorkspaceOpen(true)}
onUpdateWorkspace={workspacesHook.updateWorkspace}
@@ -790,7 +797,9 @@ export function AppShell() {
await goChannel(directMessage.id);
}}
onSelectAgentConversation={
handleSelectAgentConversation
isChannelTasksEnabled
? handleSelectAgentConversation
: undefined
}
onSelectAgents={() => {
clearSelectedAgentConversation();
@@ -839,7 +848,9 @@ export function AppShell() {
}
selectedChannelId={selectedChannelId}
selectedAgentConversationId={
selectedAgentConversationId
isChannelTasksEnabled
? selectedAgentConversationId
: null
}
selectedView={selectedView}
unreadChannelIds={unreadChannelIds}

View File

@@ -20,6 +20,7 @@ import {
CHANNEL_TIMELINE_CONTENT_KINDS,
CHANNEL_TIMELINE_STATE_KINDS,
} from "@/shared/constants/kinds";
import { CHANNEL_TASKS_FEATURE_ID, useFeatureEnabled } from "@/shared/features";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
type ChannelRouteScreenProps = {
@@ -150,6 +151,7 @@ export function ChannelRouteScreen({
targetThreadRootId,
}: ChannelRouteScreenProps) {
const queryClient = useQueryClient();
const isChannelTasksEnabled = useFeatureEnabled(CHANNEL_TASKS_FEATURE_ID);
const { closeForumPost, goForumPost } = useAppNavigation();
const channelsQuery = useChannelsQuery();
const identityQuery = useIdentityQuery();
@@ -163,6 +165,9 @@ export function ChannelRouteScreen({
const cachedTarget = getCachedSearchHitEvent(targetMessageId);
return cachedTarget ? [cachedTarget] : [];
});
const effectiveAgentConversationReplyId = isChannelTasksEnabled
? targetAgentConversationReplyId
: null;
// Reset spliced target events when the channel context changes (channel
// switch or entering/leaving a forum post). Tied to channel identity rather
@@ -191,7 +196,7 @@ export function ChannelRouteScreen({
// param-clear blanks the timeline. Resetting on channel / forum-post change
// is handled by the effect below; here we only fetch when there's a target.
if (
(!targetAgentConversationReplyId &&
(!effectiveAgentConversationReplyId &&
!targetMessageId &&
!targetThreadRootId) ||
selectedPostId
@@ -215,7 +220,7 @@ export function ChannelRouteScreen({
}
const eventIds = [
targetAgentConversationReplyId,
effectiveAgentConversationReplyId,
targetMessageId,
targetThreadRootId && targetThreadRootId !== targetMessageId
? targetThreadRootId
@@ -225,8 +230,8 @@ export function ChannelRouteScreen({
void fetchRouteTargetEvents(
channelId,
eventIds,
targetAgentConversationReplyId ?? targetMessageId,
targetAgentConversationReplyId,
effectiveAgentConversationReplyId ?? targetMessageId,
effectiveAgentConversationReplyId,
targetThreadRootId,
).then((events) => {
if (!isCancelled) {
@@ -251,7 +256,7 @@ export function ChannelRouteScreen({
selectedPostId,
channelId,
queryClient,
targetAgentConversationReplyId,
effectiveAgentConversationReplyId,
targetMessageId,
targetThreadRootId,
]);
@@ -277,7 +282,7 @@ export function ChannelRouteScreen({
void goForumPost(channelId, postId);
}}
selectedForumPostId={selectedPostId}
targetAgentConversationReplyId={targetAgentConversationReplyId}
targetAgentConversationReplyId={effectiveAgentConversationReplyId}
targetForumReplyId={targetReplyId}
targetMessageEvents={targetMessageEvents}
targetMessageId={targetMessageId}

View File

@@ -863,6 +863,7 @@ export function AgentConversationScreen({
containerClassName="px-5"
disabled={isComposerDisabled}
draftKey={`agent-conversation:${conversation.id}`}
enableAgentConversationLinks
isSending={sendMessageMutation.isPending}
mediaController={media}
onSend={handleSend}

View File

@@ -71,6 +71,7 @@ export const ChannelPane = React.memo(function ChannelPane({
channelManagementOpen = false,
currentPubkey,
editTarget = null,
enableAgentConversations = true,
fetchOlder,
header,
hasOlderMessages,
@@ -164,7 +165,7 @@ export const ChannelPane = React.memo(function ChannelPane({
!activeChannel.isMember &&
activeChannel.visibility === "open" &&
!activeChannel.archivedAt;
const isTasksSurface = surfaceTab === "tasks";
const isTasksSurface = enableAgentConversations && surfaceTab === "tasks";
const hasMainComposerOverlay = !isNonMemberView && !isTasksSurface;
const activeChannelId = activeChannel?.id ?? null;
const huddleMemberPubkeys = React.useMemo(
@@ -172,6 +173,9 @@ export const ChannelPane = React.memo(function ChannelPane({
[activeChannel, agentPubkeys, currentPubkey],
);
const huddleMemberPubkeysPending = agentPubkeysPending;
const activeAgentConversationMarkers = enableAgentConversations
? agentConversationMarkers
: undefined;
const isActiveWelcomeChannel =
activeChannel !== null && isWelcomeChannel(activeChannel);
React.useEffect(() => {
@@ -335,6 +339,7 @@ export const ChannelPane = React.memo(function ChannelPane({
const handleOpenAgentConversation = React.useCallback(
(message: TimelineMessage, options?: { publishMarker?: boolean }) => {
if (
!enableAgentConversations ||
!activeChannel ||
!message.pubkey ||
!canOpenAgentConversationInChannel({
@@ -372,7 +377,7 @@ export const ChannelPane = React.memo(function ChannelPane({
options,
);
},
[activeChannel, messages, openAgentConversation],
[activeChannel, enableAgentConversations, messages, openAgentConversation],
);
const handleGoToTaskMessage = React.useCallback(
(
@@ -461,7 +466,8 @@ export const ChannelPane = React.memo(function ChannelPane({
const threadActivityAgents = React.useMemo(() => {
if (
threadComposerBotTypingPubkeys.length === 0 ||
(openThreadHeadId &&
(enableAgentConversations &&
openThreadHeadId &&
agentConversationMarkers?.some(
(marker) => marker.threadRootId === openThreadHeadId,
))
@@ -478,6 +484,7 @@ export const ChannelPane = React.memo(function ChannelPane({
}, [
activityAgents,
agentConversationMarkers,
enableAgentConversations,
openThreadHeadId,
threadComposerBotTypingPubkeys,
]);
@@ -573,6 +580,10 @@ export const ChannelPane = React.memo(function ChannelPane({
];
}, [threadHeadMessage, threadMessages]);
const hiddenAgentConversationMessageIds = React.useMemo(() => {
if (!enableAgentConversations) {
return new Set<string>();
}
const hiddenIds = getHiddenAgentConversationMessageIds(
baseVisibleMessages,
agentConversationMarkers,
@@ -598,6 +609,7 @@ export const ChannelPane = React.memo(function ChannelPane({
agentConversationMarkers,
baseVisibleMessages,
channelFind.activeMatch?.messageId,
enableAgentConversations,
targetMessageId,
threadScrollTargetId,
threadSourceMessages,
@@ -760,7 +772,7 @@ export const ChannelPane = React.memo(function ChannelPane({
{isTasksSurface ? (
<ChannelTasksView
activeChannel={activeChannel}
agentConversationMarkers={agentConversationMarkers}
agentConversationMarkers={activeAgentConversationMarkers}
currentPubkey={currentPubkey}
fetchOlder={fetchOlder}
hasOlderMessages={hasOlderMessages}
@@ -776,7 +788,7 @@ export const ChannelPane = React.memo(function ChannelPane({
<>
<MessageTimeline
ref={messageTimelineRef}
agentConversationMarkers={agentConversationMarkers}
agentConversationMarkers={activeAgentConversationMarkers}
agentPubkeys={agentPubkeys}
channelId={activeChannel?.id}
channelIntro={channelIntro}
@@ -816,7 +828,11 @@ export const ChannelPane = React.memo(function ChannelPane({
onEdit={onEdit}
onMarkUnread={onMarkUnread}
onMarkRead={onMarkRead}
onOpenAgentConversation={handleOpenAgentConversation}
onOpenAgentConversation={
enableAgentConversations
? handleOpenAgentConversation
: undefined
}
onReply={activeChannel?.archivedAt ? undefined : onOpenThread}
channelName={activeChannel?.name}
channelType={activeChannel?.channelType ?? null}
@@ -879,6 +895,7 @@ export const ChannelPane = React.memo(function ChannelPane({
channelType={activeChannel?.channelType ?? null}
containerClassName="px-5"
disabled={isComposerDisabled}
enableAgentConversationLinks={enableAgentConversations}
editTarget={mainEditTarget}
isSending={isSending}
mediaController={mainComposerMedia}
@@ -955,13 +972,14 @@ export const ChannelPane = React.memo(function ChannelPane({
(() => {
const panel = (
<MessageThreadPanel
agentConversationMarkers={agentConversationMarkers}
agentConversationMarkers={activeAgentConversationMarkers}
agentPubkeys={agentPubkeys}
channel={activeChannel}
channelId={activeChannel?.id ?? null}
channelName={activeChannel?.name ?? "channel"}
currentPubkey={currentPubkey}
disabled={isComposerDisabled}
enableAgentConversationLinks={enableAgentConversations}
editTarget={threadEditTarget}
firstUnreadReplyId={threadFirstUnreadReplyId}
huddleMemberPubkeys={huddleMemberPubkeys}
@@ -985,7 +1003,11 @@ export const ChannelPane = React.memo(function ChannelPane({
onMarkUnread={onMarkUnread}
onMarkRead={onMarkRead}
onExpandReplies={onExpandThreadReplies}
onOpenAgentConversation={handleOpenAgentConversation}
onOpenAgentConversation={
enableAgentConversations
? handleOpenAgentConversation
: undefined
}
onSelectReplyTarget={onSelectThreadReplyTarget}
onSend={onSendThreadReply}
onScrollTargetResolved={onThreadScrollTargetResolved}

View File

@@ -30,6 +30,7 @@ export type ChannelPaneProps = {
id: string;
imetaMedia?: ImetaMedia[];
} | null;
enableAgentConversations?: boolean;
fetchOlder?: () => Promise<void>;
header?: React.ReactNode;
hasOlderMessages?: boolean;

View File

@@ -57,6 +57,7 @@ import { mergeCurrentProfileIntoLookup } from "@/features/profile/lib/identity";
import type { RelayEvent, RespondToMode, SearchHit } from "@/shared/api/types";
import { useChannelFind } from "@/features/search/useChannelFind";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
import { CHANNEL_TASKS_FEATURE_ID, useFeatureEnabled } from "@/shared/features";
import { AgentSessionProvider } from "@/shared/context/AgentSessionContext";
import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext";
import { useMainInsetRef } from "@/shared/layout/MainInsetContext";
@@ -92,6 +93,7 @@ export function ChannelScreen({
targetMessageEvents,
targetMessageId,
}: ChannelScreenProps) {
const isChannelTasksEnabled = useFeatureEnabled(CHANNEL_TASKS_FEATURE_ID);
const { goChannel, goHome } = useAppNavigation();
const [activeSurfaceTab, setActiveSurfaceTab] =
React.useState<ChannelSurfaceTab>("messages");
@@ -159,7 +161,8 @@ export function ChannelScreen({
const mainInsetRef = useMainInsetRef();
const currentPubkey = currentIdentity?.pubkey;
const activeChannelId = activeChannel?.id ?? null;
const canShowTasksSurface = activeChannel?.channelType === "stream";
const canShowTasksSurface =
isChannelTasksEnabled && activeChannel?.channelType === "stream";
const effectiveSurfaceTab = canShowTasksSurface
? activeSurfaceTab
: "messages";
@@ -479,7 +482,11 @@ export function ChannelScreen({
);
}, []);
const { agentConversationMarkers, unreadTimelineMessages } =
useAgentConversationTimelineState(resolvedMessages, timelineMessages);
useAgentConversationTimelineState(
resolvedMessages,
timelineMessages,
isChannelTasksEnabled,
);
const channelFind = useChannelFind({
channelId: activeChannelId,
messages: timelineMessages,
@@ -694,6 +701,10 @@ export function ChannelScreen({
}, [activeChannelId, resetComposerTargets]);
const handleSurfaceTabChange = React.useCallback(
(tab: ChannelSurfaceTab) => {
if (tab === "tasks" && !isChannelTasksEnabled) {
return;
}
setActiveSurfaceTab(tab);
if (tab !== "tasks") {
@@ -712,6 +723,7 @@ export function ChannelScreen({
[
clearOptimisticThreadOverride,
handleCloseAgentSession,
isChannelTasksEnabled,
setChannelManagementOpen,
setOpenThreadHeadId,
setProfilePanelPubkey,
@@ -723,7 +735,9 @@ export function ChannelScreen({
goChannel,
messageProfilesReady,
openAgentConversation,
targetAgentConversationReplyId,
targetAgentConversationReplyId: isChannelTasksEnabled
? targetAgentConversationReplyId
: null,
timelineMessages,
});
const { mainTimelineTargetMessageId, rootThreadHeadTargetId } =
@@ -905,7 +919,9 @@ export function ChannelScreen({
onAddBotOpenChange={setIsAddBotOpen}
onJoinChannel={joinChannelMutation.mutateAsync}
onManageChannel={handleManageChannel}
onSurfaceTabChange={handleSurfaceTabChange}
onSurfaceTabChange={
isChannelTasksEnabled ? handleSurfaceTabChange : undefined
}
onToggleMembers={handleToggleMembers}
showHeaderContent={!isSinglePanelView}
transparentChrome={activeChannel?.channelType !== "forum"}
@@ -924,6 +940,7 @@ export function ChannelScreen({
effectiveSurfaceTab,
handleSurfaceTabChange,
isAddBotOpen,
isChannelTasksEnabled,
joinChannelMutation.isPending,
joinChannelMutation.mutateAsync,
handleManageChannel,
@@ -969,6 +986,7 @@ export function ChannelScreen({
channelFind={channelFind}
channelManagementOpen={channelManagementOpen}
currentPubkey={currentPubkey}
enableAgentConversations={isChannelTasksEnabled}
canResetThreadPanelWidth={canResetThreadPanelWidth}
fetchOlder={fetchOlder}
header={channelHeader}

View File

@@ -34,18 +34,20 @@ export function useUnreadTimelineMessages(
export function useAgentConversationMarkers(
messages: RelayEvent[],
enabled = true,
): AgentConversationMarker[] {
return React.useMemo(
() => buildAgentConversationMarkers(messages),
[messages],
() => (enabled ? buildAgentConversationMarkers(messages) : []),
[enabled, messages],
);
}
export function useAgentConversationTimelineState(
events: RelayEvent[],
messages: TimelineMessage[],
enabled = true,
) {
const agentConversationMarkers = useAgentConversationMarkers(events);
const agentConversationMarkers = useAgentConversationMarkers(events, enabled);
const unreadTimelineMessages = useUnreadTimelineMessages(
messages,
agentConversationMarkers,

View File

@@ -14,6 +14,7 @@ import {
import { AGENT_CONVERSATION_LINK_NODE_NAME } from "./agentConversationLinkNodeName";
export type AgentConversationLinkNodeOptions = {
enabled?: boolean;
titleForHref?: (href: string) => string | undefined;
};
@@ -48,6 +49,20 @@ function getDisplayTitle(
function ComposerAgentConversationLinkView({ extension, node }: NodeViewProps) {
const href = String(node.attrs.href ?? "");
if (
(extension.options as AgentConversationLinkNodeOptions).enabled === false
) {
return (
<NodeViewWrapper
as="span"
className="font-medium text-primary underline underline-offset-4"
title={href}
>
{href}
</NodeViewWrapper>
);
}
const title = getDisplayTitle(
href,
String(node.attrs.title ?? ""),
@@ -146,6 +161,7 @@ export const AgentConversationLinkNode =
addOptions() {
return {
enabled: true,
titleForHref: undefined,
};
},
@@ -230,6 +246,10 @@ export const AgentConversationLinkNode =
// biome-ignore lint/suspicious/noExplicitAny: markdown-it is untyped here
md: any,
) {
if (this.options.enabled === false) {
return;
}
registerAgentConversationLinkMarkdownIt(md, this.options);
},
},

View File

@@ -13,6 +13,7 @@ type PasteView = {
type ComposerPasteHandlerOptions = {
agentConversationTitleForHref?: (href: string) => string | undefined;
enableAgentConversationLinks?: boolean;
editor: NonNullable<UseRichTextEditorResult["editor"]>;
scrollComposerToBottom: () => void;
uploadFile: MediaUploadController["uploadFile"];
@@ -20,6 +21,7 @@ type ComposerPasteHandlerOptions = {
export function createMessageComposerPasteHandler({
agentConversationTitleForHref,
enableAgentConversationLinks = true,
editor,
scrollComposerToBottom,
uploadFile,
@@ -66,7 +68,9 @@ export function createMessageComposerPasteHandler({
const plainText = event.clipboardData?.getData("text/plain") ?? "";
const taskLinkPasteContent =
plainText.includes("\n") || plainText.trim().length === 0
!enableAgentConversationLinks ||
plainText.includes("\n") ||
plainText.trim().length === 0
? null
: buildTaskLinkPasteContent(plainText, agentConversationTitleForHref);
if (taskLinkPasteContent) {

View File

@@ -63,6 +63,8 @@ export type RichTextEditorOptions = {
customEmoji?: CustomEmoji[];
/** Resolve task-link titles for composer task cards. */
agentConversationTitleForHref?: (href: string) => string | undefined;
/** Enables task-link cards and task-link markdown parsing in the composer. */
enableAgentConversationLinks?: boolean;
/** Called on plain Enter (submit). Handled inside Tiptap's extension system
* so it fires *before* ProseMirror's default splitBlock behaviour. */
onSubmit?: () => void;
@@ -172,6 +174,7 @@ export function useRichTextEditor({
channelNames,
customEmoji,
agentConversationTitleForHref,
enableAgentConversationLinks = true,
onSubmit,
onEditLastOwnMessage,
isAutocompleteOpen,
@@ -206,10 +209,11 @@ export function useRichTextEditor({
const agentConversationLinkExtension = React.useMemo(
() =>
AgentConversationLinkNode.configure({
enabled: enableAgentConversationLinks,
titleForHref: (href) =>
agentConversationTitleForHrefRef.current?.(href),
}),
[],
[enableAgentConversationLinks],
);
const editor = useEditor(

View File

@@ -90,6 +90,7 @@ type MessageComposerProps = {
mediaTags?: string[][],
) => Promise<void>;
agentConversationTitleForHref?: (href: string) => string | undefined;
enableAgentConversationLinks?: boolean;
placeholder?: string;
profiles?: UserProfileLookup;
replyTarget?: {
@@ -119,6 +120,7 @@ function MessageComposerImpl({
onEditSave,
onSend,
agentConversationTitleForHref,
enableAgentConversationLinks = false,
placeholder,
profiles,
replyTarget = null,
@@ -233,6 +235,7 @@ function MessageComposerImpl({
channelNames: channelLinks.knownChannelNames,
customEmoji,
agentConversationTitleForHref,
enableAgentConversationLinks,
onSubmit: () => submitMessageRef.current(),
onEditLastOwnMessage: () => {
// Never re-enter edit from an empty edit (e.g. image-only edit whose
@@ -675,13 +678,19 @@ function MessageComposerImpl({
...richText.editor.options.editorProps,
handlePaste: createMessageComposerPasteHandler({
agentConversationTitleForHref,
enableAgentConversationLinks,
editor: richText.editor,
scrollComposerToBottom,
uploadFile: uploadFileRef.current,
}),
},
});
}, [richText.editor, scrollComposerToBottom, agentConversationTitleForHref]);
}, [
richText.editor,
scrollComposerToBottom,
agentConversationTitleForHref,
enableAgentConversationLinks,
]);
// ── Send button state ───────────────────────────────────────────────
const sendDisabled = React.useMemo(

View File

@@ -45,6 +45,7 @@ type MessageThreadPanelProps = {
channelName: string;
currentPubkey?: string;
disabled?: boolean;
enableAgentConversationLinks?: boolean;
firstUnreadReplyId?: string | null;
huddleMemberPubkeys?: readonly string[];
huddleMemberPubkeysPending?: boolean;
@@ -356,6 +357,7 @@ export function MessageThreadPanel({
channelName,
currentPubkey,
disabled = false,
enableAgentConversationLinks = false,
firstUnreadReplyId,
huddleMemberPubkeys,
huddleMemberPubkeysPending = false,
@@ -680,6 +682,7 @@ export function MessageThreadPanel({
containerClassName={THREAD_PANEL_COMPOSER_GUTTER_CLASS}
disabled={disabled || isSending || !channelId}
draftKey={`thread:${threadHead.id}`}
enableAgentConversationLinks={enableAgentConversationLinks}
editTarget={editTarget}
isSending={isSending}
onCancelEdit={onCancelEdit}

View File

@@ -400,7 +400,6 @@ function MessageRowItem({
)}
>
<MessageRow
agentConversationMarkers={agentConversationMarkers}
agentPubkeys={agentPubkeys}
channelId={channelId}
highlighted={false}
@@ -462,7 +461,6 @@ function MessageRowItem({
return (
<div className="flex flex-col gap-1 pb-2.5">
<MessageRow
agentConversationMarkers={agentConversationMarkers}
agentPubkeys={agentPubkeys}
channelId={channelId}
highlighted={message.id === highlightedMessageId || isSearchActive}

View File

@@ -0,0 +1 @@
export const CHANNEL_TASKS_FEATURE_ID = "channel-tasks";

View File

@@ -1,4 +1,5 @@
export { FeatureGate } from "./FeatureGate";
export { CHANNEL_TASKS_FEATURE_ID } from "./featureIds";
export { allFeatures, desktopFeatures, getFeature, manifest } from "./manifest";
export { getOverrides, setOverride, clearOverride } from "./store";
export type {

View File

@@ -433,21 +433,24 @@ import { isAgentConversationLink } from "../../features/agents/agentConversation
import { isMessageLink } from "../../features/messages/lib/messageLink.ts";
import remarkSpoilers from "../lib/remarkSpoilers.ts";
function messageLinkUrlTransform(value, key) {
if (
key === "href" &&
(isMessageLink(value) || isAgentConversationLink(value))
) {
function messageLinkUrlTransform(value, key, taskLinksEnabled = true) {
if (key === "href" && isMessageLink(value)) {
return value;
}
if (key === "href" && taskLinksEnabled && isAgentConversationLink(value)) {
return value;
}
return defaultUrlTransform(value);
}
function renderMarkdown(content) {
function renderMarkdown(content, taskLinksEnabled = true) {
return renderToStaticMarkup(
React.createElement(
ReactMarkdown,
{ urlTransform: messageLinkUrlTransform },
{
urlTransform: (value, key) =>
messageLinkUrlTransform(value, key, taskLinksEnabled),
},
content,
),
);
@@ -478,6 +481,11 @@ test("messageLinkUrlTransform: preserves buzz://task href", () => {
assert.match(html, /href="buzz:\/\/task\?channel=c1&(?:amp;)?reply=m1"/);
});
test("messageLinkUrlTransform: strips buzz://task href when disabled", () => {
const html = renderMarkdown("[task](buzz://task?channel=c1&reply=r1)", false);
assert.doesNotMatch(html, /href="buzz:\/\/task/);
});
test("messageLinkUrlTransform: still strips javascript: scheme", () => {
const html = renderMarkdown("[xss](javascript:alert(1))");
// defaultUrlTransform replaces unsafe schemes with the empty string.

View File

@@ -30,6 +30,7 @@ import {
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { invokeTauri } from "@/shared/api/tauri";
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
import { CHANNEL_TASKS_FEATURE_ID, useFeatureEnabled } from "@/shared/features";
import { cn } from "@/shared/lib/cn";
import {
extractSupportedLinkPreviews,
@@ -1600,6 +1601,7 @@ function AgentConversationLinkCard({
function createMarkdownComponents(
runtimeRef: React.RefObject<MarkdownRuntime>,
interactive = true,
agentConversationLinksEnabled = true,
): Components {
const paragraphClassName = "leading-[inherit]";
const listItemClassName = "my-1 [&_p]:inline";
@@ -1694,41 +1696,42 @@ function createMarkdownComponents(
</a>
);
}
if (agentConversationLinksEnabled) {
const agentConversationLinkTarget =
resolveAgentConversationLinkRenderTarget({
href,
label,
});
if (agentConversationLinkTarget.kind !== "none") {
if (agentConversationLinkTarget.kind === "card") {
return (
<AgentConversationLinkCard
href={href}
interactive={interactive}
link={agentConversationLinkTarget.link}
marker={findAgentConversationMarker(
agentConversationMarkers,
agentConversationLinkTarget.link,
)}
onOpenAgentConversationLink={onOpenAgentConversationLink}
/>
);
}
const agentConversationLinkTarget =
resolveAgentConversationLinkRenderTarget({
href,
label,
});
if (agentConversationLinkTarget.kind !== "none") {
if (agentConversationLinkTarget.kind === "card") {
return (
<AgentConversationLinkCard
<a
{...props}
className="font-medium text-primary underline underline-offset-4 transition-colors hover:text-primary/80 cursor-pointer"
href={href}
interactive={interactive}
link={agentConversationLinkTarget.link}
marker={findAgentConversationMarker(
agentConversationMarkers,
agentConversationLinkTarget.link,
)}
onOpenAgentConversationLink={onOpenAgentConversationLink}
/>
onClick={(event) => {
event.preventDefault();
onOpenAgentConversationLink(agentConversationLinkTarget.link);
}}
>
{children}
</a>
);
}
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(agentConversationLinkTarget.link);
}}
>
{children}
</a>
);
}
// Malformed message deep link — fall through to the default
// anchor (renders as a normal external link).
@@ -2058,6 +2061,9 @@ function createMarkdownComponents(
const { agentConversationMarkers, onOpenAgentConversationLink } =
runtimeRef.current;
const href = getReactNodeText(children);
if (!agentConversationLinksEnabled) {
return <span data-agent-conversation-link="">{href}</span>;
}
const parsed = parseAgentConversationLink(href);
if (!parsed.ok) {
return <span data-agent-conversation-link="">{href}</span>;
@@ -2093,6 +2099,9 @@ function MarkdownInner({
searchQuery,
videoReviewContext,
}: MarkdownProps) {
const agentConversationLinksEnabled = useFeatureEnabled(
CHANNEL_TASKS_FEATURE_ID,
);
const { channels: rawChannels } = useChannelNavigation();
const channels = useStableArray(rawChannels);
const { goChannel } = useAppNavigation();
@@ -2155,24 +2164,34 @@ function MarkdownInner({
});
const components = React.useMemo(
() => createMarkdownComponents(runtimeRef, interactive),
[runtimeRef, interactive],
() =>
createMarkdownComponents(
runtimeRef,
interactive,
agentConversationLinksEnabled,
),
[runtimeRef, interactive, agentConversationLinksEnabled],
);
// biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable
const remarkPlugins = React.useMemo<any[]>(
() => [
const remarkPlugins = React.useMemo<any[]>(() => {
// biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable
const plugins: any[] = [
remarkGfm,
remarkBreaks,
remarkSpoilers,
remarkMessageLinks,
remarkAgentConversationLinks,
];
if (agentConversationLinksEnabled) {
plugins.push(remarkAgentConversationLinks);
}
plugins.push(
[remarkMentions, { mentionNames }],
[remarkChannelLinks, { channelNames }],
[remarkCustomEmoji, { customEmoji }],
],
[mentionNames, channelNames, customEmoji],
);
);
return plugins;
}, [agentConversationLinksEnabled, mentionNames, channelNames, customEmoji]);
// biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable
const rehypePlugins = React.useMemo<any[]>(() => {
@@ -2201,7 +2220,9 @@ function MarkdownInner({
components={components}
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
urlTransform={messageLinkUrlTransform}
urlTransform={(value, key) =>
messageLinkUrlTransform(value, key, agentConversationLinksEnabled)
}
>
{processedContent}
</ReactMarkdown>

View File

@@ -66,10 +66,18 @@ export function isInsideHiddenSpoiler(element: Element): boolean {
* component override can see them, which would break copy → paste → click
* end-to-end. Everything else delegates to `defaultUrlTransform`.
*/
export function messageLinkUrlTransform(value: string, key: string): string {
export function messageLinkUrlTransform(
value: string,
key: string,
agentConversationLinksEnabled = true,
): string {
if (key === "href" && isMessageLink(value)) {
return value;
}
if (
key === "href" &&
(isMessageLink(value) || isAgentConversationLink(value))
agentConversationLinksEnabled &&
isAgentConversationLink(value)
) {
return value;
}

View File

@@ -1,6 +1,7 @@
import * as React from "react";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { CHANNEL_TASKS_FEATURE_ID, useFeatureEnabled } from "@/shared/features";
import {
listenForAgentConversationDeepLinks,
listenForMessageDeepLinks,
@@ -22,6 +23,7 @@ import {
*/
export function useMessageDeepLinks() {
const { goChannel } = useAppNavigation();
const isChannelTasksEnabled = useFeatureEnabled(CHANNEL_TASKS_FEATURE_ID);
React.useEffect(() => {
let cancelled = false;
@@ -32,18 +34,19 @@ export function useMessageDeepLinks() {
threadRootId: payload.threadRootId,
});
});
const agentConversationUnlistenPromise =
listenForAgentConversationDeepLinks((payload) => {
if (cancelled) return;
void goChannel(payload.channelId, {
taskReplyId: payload.agentReplyId,
});
});
const agentConversationUnlistenPromise = isChannelTasksEnabled
? listenForAgentConversationDeepLinks((payload) => {
if (cancelled) return;
void goChannel(payload.channelId, {
taskReplyId: payload.agentReplyId,
});
})
: null;
return () => {
cancelled = true;
void messageUnlistenPromise.then((fn) => fn());
void agentConversationUnlistenPromise.then((fn) => fn());
void agentConversationUnlistenPromise?.then((fn) => fn());
};
}, [goChannel]);
}, [goChannel, isChannelTasksEnabled]);
}

View File

@@ -32,6 +32,14 @@
"platforms": [
"desktop"
]
},
{
"id": "channel-tasks",
"name": "Channel Tasks",
"description": "Dedicated agent task conversations inside channels",
"platforms": [
"desktop"
]
}
]
}