mirror of
https://github.com/block/buzz.git
synced 2026-11-14 05:12:27 +00:00
Gate channel tasks experiment
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -30,6 +30,7 @@ export type ChannelPaneProps = {
|
||||
id: string;
|
||||
imetaMedia?: ImetaMedia[];
|
||||
} | null;
|
||||
enableAgentConversations?: boolean;
|
||||
fetchOlder?: () => Promise<void>;
|
||||
header?: React.ReactNode;
|
||||
hasOlderMessages?: boolean;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
1
desktop/src/shared/features/featureIds.ts
Normal file
1
desktop/src/shared/features/featureIds.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const CHANNEL_TASKS_FEATURE_ID = "channel-tasks";
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,14 @@
|
||||
"platforms": [
|
||||
"desktop"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "channel-tasks",
|
||||
"name": "Channel Tasks",
|
||||
"description": "Dedicated agent task conversations inside channels",
|
||||
"platforms": [
|
||||
"desktop"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user