From 7b5c0e4884ee9b1fc9d35e9fd00c1188f0fc3b61 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 27 Jun 2026 08:30:25 +0100 Subject: [PATCH] Gate channel tasks experiment --- desktop/scripts/check-file-sizes.mjs | 2 +- desktop/src/app/AppShell.tsx | 17 +++- desktop/src/app/routes/ChannelRouteScreen.tsx | 17 ++-- .../agents/ui/AgentConversationScreen.tsx | 1 + .../src/features/channels/ui/ChannelPane.tsx | 38 +++++-- .../features/channels/ui/ChannelPane.types.ts | 1 + .../features/channels/ui/ChannelScreen.tsx | 26 ++++- .../ui/filterAgentConversationMessages.ts | 8 +- .../lib/agentConversationLinkNode.tsx | 20 ++++ .../messages/lib/composerPasteHandler.ts | 6 +- .../messages/lib/useRichTextEditor.ts | 6 +- .../features/messages/ui/MessageComposer.tsx | 11 ++- .../messages/ui/MessageThreadPanel.tsx | 3 + .../messages/ui/TimelineMessageList.tsx | 2 - desktop/src/shared/features/featureIds.ts | 1 + desktop/src/shared/features/index.ts | 1 + desktop/src/shared/ui/markdown.test.mjs | 22 +++-- desktop/src/shared/ui/markdown.tsx | 99 +++++++++++-------- desktop/src/shared/ui/markdown/utils.ts | 12 ++- desktop/src/shared/useMessageDeepLinks.ts | 21 ++-- preview-features.json | 8 ++ 21 files changed, 235 insertions(+), 87 deletions(-) create mode 100644 desktop/src/shared/features/featureIds.ts diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 8e6dd3e504..387ca3ac81 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -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 diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 0dcff40953..35344b520e 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -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} diff --git a/desktop/src/app/routes/ChannelRouteScreen.tsx b/desktop/src/app/routes/ChannelRouteScreen.tsx index 4141c6984e..2855e44af3 100644 --- a/desktop/src/app/routes/ChannelRouteScreen.tsx +++ b/desktop/src/app/routes/ChannelRouteScreen.tsx @@ -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} diff --git a/desktop/src/features/agents/ui/AgentConversationScreen.tsx b/desktop/src/features/agents/ui/AgentConversationScreen.tsx index 78d797f64a..cac2f84f84 100644 --- a/desktop/src/features/agents/ui/AgentConversationScreen.tsx +++ b/desktop/src/features/agents/ui/AgentConversationScreen.tsx @@ -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} diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index c928b0755e..3417701dde 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -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(); + } + 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 ? ( { const panel = ( Promise; header?: React.ReactNode; hasOlderMessages?: boolean; diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 90562e6acd..7851d4ac8e 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -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("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} diff --git a/desktop/src/features/channels/ui/filterAgentConversationMessages.ts b/desktop/src/features/channels/ui/filterAgentConversationMessages.ts index 65153ee2a2..a5739feb1c 100644 --- a/desktop/src/features/channels/ui/filterAgentConversationMessages.ts +++ b/desktop/src/features/channels/ui/filterAgentConversationMessages.ts @@ -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, diff --git a/desktop/src/features/messages/lib/agentConversationLinkNode.tsx b/desktop/src/features/messages/lib/agentConversationLinkNode.tsx index 3a261252e8..539cc398ae 100644 --- a/desktop/src/features/messages/lib/agentConversationLinkNode.tsx +++ b/desktop/src/features/messages/lib/agentConversationLinkNode.tsx @@ -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 ( + + {href} + + ); + } + 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); }, }, diff --git a/desktop/src/features/messages/lib/composerPasteHandler.ts b/desktop/src/features/messages/lib/composerPasteHandler.ts index d4c159ff7b..3a648ea75c 100644 --- a/desktop/src/features/messages/lib/composerPasteHandler.ts +++ b/desktop/src/features/messages/lib/composerPasteHandler.ts @@ -13,6 +13,7 @@ type PasteView = { type ComposerPasteHandlerOptions = { agentConversationTitleForHref?: (href: string) => string | undefined; + enableAgentConversationLinks?: boolean; editor: NonNullable; 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) { diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index 7074f2837e..07191e9828 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -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( diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 5c77770ee0..bc05210ae4 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -90,6 +90,7 @@ type MessageComposerProps = { mediaTags?: string[][], ) => Promise; 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( diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index 0545263be0..a72961501b 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -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} diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index 699dab1490..6415b0eeda 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -400,7 +400,6 @@ function MessageRowItem({ )} > + 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. diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index ecbc9ce884..a87755213d 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -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, interactive = true, + agentConversationLinksEnabled = true, ): Components { const paragraphClassName = "leading-[inherit]"; const listItemClassName = "my-1 [&_p]:inline"; @@ -1694,41 +1696,42 @@ function createMarkdownComponents( ); } + if (agentConversationLinksEnabled) { + const agentConversationLinkTarget = + resolveAgentConversationLinkRenderTarget({ + href, + label, + }); + if (agentConversationLinkTarget.kind !== "none") { + if (agentConversationLinkTarget.kind === "card") { + return ( + + ); + } - const agentConversationLinkTarget = - resolveAgentConversationLinkRenderTarget({ - href, - label, - }); - if (agentConversationLinkTarget.kind !== "none") { - if (agentConversationLinkTarget.kind === "card") { return ( - + onClick={(event) => { + event.preventDefault(); + onOpenAgentConversationLink(agentConversationLinkTarget.link); + }} + > + {children} + ); } - - return ( - { - event.preventDefault(); - onOpenAgentConversationLink(agentConversationLinkTarget.link); - }} - > - {children} - - ); } // 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 {href}; + } const parsed = parseAgentConversationLink(href); if (!parsed.ok) { return {href}; @@ -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( - () => [ + const remarkPlugins = React.useMemo(() => { + // 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(() => { @@ -2201,7 +2220,9 @@ function MarkdownInner({ components={components} remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} - urlTransform={messageLinkUrlTransform} + urlTransform={(value, key) => + messageLinkUrlTransform(value, key, agentConversationLinksEnabled) + } > {processedContent} diff --git a/desktop/src/shared/ui/markdown/utils.ts b/desktop/src/shared/ui/markdown/utils.ts index 9e1f02c9db..3e3c5987b4 100644 --- a/desktop/src/shared/ui/markdown/utils.ts +++ b/desktop/src/shared/ui/markdown/utils.ts @@ -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; } diff --git a/desktop/src/shared/useMessageDeepLinks.ts b/desktop/src/shared/useMessageDeepLinks.ts index dcf38d55e1..08de0fa676 100644 --- a/desktop/src/shared/useMessageDeepLinks.ts +++ b/desktop/src/shared/useMessageDeepLinks.ts @@ -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]); } diff --git a/preview-features.json b/preview-features.json index 38ea181bb3..ede6e2fddf 100644 --- a/preview-features.json +++ b/preview-features.json @@ -32,6 +32,14 @@ "platforms": [ "desktop" ] + }, + { + "id": "channel-tasks", + "name": "Channel Tasks", + "description": "Dedicated agent task conversations inside channels", + "platforms": [ + "desktop" + ] } ] }