diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 06e4e9f4b..8e6dd3e50 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -117,6 +117,12 @@ const overrides = new Map([ ["src-tauri/src/nostr_convert.rs", 1126], ["src/shared/api/relayClientSession.ts", 1022], ["src-tauri/src/migration.rs", 1449], + // persona-events rebase: boot-time event-sync wiring (run_boot_migrations + // syncs team-dir edits before all personas.json readers; run_event_sync + // signs the persona/team retention events post-identity) layered on top of + // main's growth. continued-agent-conversations: task deep-link parsing and + // regression tests. Load-bearing feature growth, queued to split with the list. + ["src-tauri/src/lib.rs", 1092], // onMarkRead + isUnread prop threading (mirrors the onMarkUnread prop // already here) for the single-toggle mark-read/unread menu item — a small // overage from load-bearing per-message plumbing, not generic debt growth. @@ -134,12 +140,15 @@ const overrides = new Map([ // continued-agent-conversations: persisted channel-scoped conversation state // and route wiring. Queued to split with the rest of AppShell state. ["src/app/AppShell.tsx", 1060], - // continued-agent-conversations: marker filtering, thread handoff, and - // activity handoff props live at the channel surface for now. - ["src/features/channels/ui/ChannelPane.tsx", 1107], - // continued-agent-conversations: channel task/message surface routing is - // threaded through the screen while the pane split follow-up is pending. - ["src/features/channels/ui/ChannelScreen.tsx", 1027], + // continued-agent-conversations: marker filtering, tasks tab list/focus + // behavior, thread handoff, and activity handoff props live at the channel + // surface for now. + ["src/features/channels/ui/ChannelPane.tsx", 1415], + // continued-agent-conversations: channel task-tab state, deep-link task + // routing, side-panel suppression, and task-link route target plumbing sit at + // the channel orchestration seam. + // latest main rebase threads additional header routing through this seam. + ["src/features/channels/ui/ChannelScreen.tsx", 1122], // continued-agent-conversations: composer notice banner for read-only agent // conversations. ["src/features/messages/ui/MessageComposer.tsx", 1010], @@ -160,7 +169,9 @@ const overrides = new Map([ ["src/features/channels/readState/readStateManager.ts", 1030], // Shared UI was added to this guard after splitting globals/markdown so // large shared renderers cannot grow further while follow-up splits land. - ["src/shared/ui/markdown.tsx", 2082], + // continued-agent-conversations: task-link card renderer and marker lookup + // are temporarily housed here until markdown renderers are split further. + ["src/shared/ui/markdown.tsx", 2258], ["src/shared/ui/VideoPlayer.tsx", 2199], ["src/shared/ui/sidebar.tsx", 1042], // Option C databricks-model-discovery: parse/HTTP logic moved to buzz-agent diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index 2940cfeec..f3acd0801 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -33,10 +33,35 @@ fn parse_message_deep_link(url: &Url) -> Option { })) } +/// Parse the query string of a `buzz://task?…` URL into the JSON payload +/// emitted on `deep-link-agent-conversation`. +fn parse_task_deep_link(url: &Url) -> Option { + let mut channel: Option = None; + let mut reply: Option = None; + for (k, v) in url.query_pairs() { + let v = v.into_owned(); + if v.is_empty() { + continue; + } + match k.as_ref() { + "channel" => channel = Some(v), + "reply" => reply = Some(v), + _ => {} + } + } + let (channel_id, agent_reply_id) = (channel?, reply?); + Some(serde_json::json!({ + "channelId": channel_id, + "agentReplyId": agent_reply_id, + })) +} + /// Handle an incoming `buzz://` deep link URL. /// /// Currently supports: /// - `buzz://connect?relay=` — emits `deep-link-connect` to the frontend +/// - `buzz://message?channel=&id=[&thread=]` — emits `deep-link-message` +/// - `buzz://task?channel=&reply=` — emits `deep-link-agent-conversation` pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { let url = match Url::parse(url_str) { Ok(u) => u, @@ -93,6 +118,13 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { }; let _ = app.emit("deep-link-message", payload); } + Some("task") => { + let Some(payload) = parse_task_deep_link(&url) else { + eprintln!("buzz-desktop: task deep link missing channel or reply: {url_str}"); + return; + }; + let _ = app.emit("deep-link-agent-conversation", payload); + } Some(action) => { eprintln!("buzz-desktop: unknown deep link action: {action}"); } @@ -106,7 +138,7 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { mod tests { use url::Url; - use super::parse_message_deep_link; + use super::{parse_message_deep_link, parse_task_deep_link}; #[test] fn parse_message_deep_link_extracts_required_params() { @@ -157,4 +189,30 @@ mod tests { let payload = parse_message_deep_link(&url).expect("required params present"); assert!(payload["threadRootId"].is_null()); } + + #[test] + fn parse_task_deep_link_extracts_required_params() { + let url = Url::parse("buzz://task?channel=abc&reply=xyz").unwrap(); + let payload = parse_task_deep_link(&url).expect("required params present"); + assert_eq!(payload["channelId"], "abc"); + assert_eq!(payload["agentReplyId"], "xyz"); + } + + #[test] + fn parse_task_deep_link_rejects_missing_reply() { + let url = Url::parse("buzz://task?channel=abc").unwrap(); + assert!(parse_task_deep_link(&url).is_none()); + } + + #[test] + fn parse_task_deep_link_rejects_empty_channel() { + let url = Url::parse("buzz://task?channel=&reply=xyz").unwrap(); + assert!(parse_task_deep_link(&url).is_none()); + } + + #[test] + fn parse_task_deep_link_rejects_empty_reply() { + let url = Url::parse("buzz://task?channel=abc&reply=").unwrap(); + assert!(parse_task_deep_link(&url).is_none()); + } } diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index 0788bea19..85e3a4ea0 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -135,6 +135,7 @@ export function useAppNavigation() { options?: { messageId?: string; replace?: boolean; + taskReplyId?: string; threadRootId?: string | null; }, ) => @@ -144,16 +145,19 @@ export function useAppNavigation() { params: { channelId, }, - search: options?.messageId - ? { - messageId: options.messageId, - threadRootId: options.threadRootId ?? undefined, - } - : {}, + search: + options?.messageId || options?.taskReplyId + ? { + messageId: options.messageId, + taskReplyId: options.taskReplyId, + threadRootId: options.threadRootId ?? undefined, + } + : {}, }, { replace: options?.replace, - resetScroll: options?.messageId ? true : undefined, + resetScroll: + options?.messageId || options?.taskReplyId ? true : undefined, }, ), [commitNavigation], diff --git a/desktop/src/app/routes/ChannelRouteScreen.tsx b/desktop/src/app/routes/ChannelRouteScreen.tsx index d654b905a..4141c6984 100644 --- a/desktop/src/app/routes/ChannelRouteScreen.tsx +++ b/desktop/src/app/routes/ChannelRouteScreen.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { getCachedSearchHitEvent } from "@/app/navigation/searchHitEventCache"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; @@ -8,21 +9,30 @@ import { getThreadReference, isBroadcastReply, } from "@/features/messages/lib/threading"; +import { mergeTimelineCacheMessages } from "@/features/messages/hooks"; +import { channelMessagesKey } from "@/features/messages/lib/messageQueryKeys"; import { useProfileQuery } from "@/features/profile/hooks"; +import { relayClient } from "@/shared/api/relayClient"; import { useIdentityQuery } from "@/shared/api/hooks"; import { getEventById } from "@/shared/api/tauri"; import type { RelayEvent } from "@/shared/api/types"; +import { + CHANNEL_TIMELINE_CONTENT_KINDS, + CHANNEL_TIMELINE_STATE_KINDS, +} from "@/shared/constants/kinds"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; type ChannelRouteScreenProps = { channelId: string; selectedPostId: string | null; + targetAgentConversationReplyId: string | null; targetMessageId: string | null; targetReplyId: string | null; targetThreadRootId: string | null; }; const MAX_ROUTE_ANCESTOR_HOPS = 50; +const MAX_ROUTE_TASK_EVENTS = 1000; async function fetchRouteEvent(eventId: string): Promise { try { @@ -42,8 +52,10 @@ function getReplyParentId(event: RelayEvent): string | null { } async function fetchRouteTargetEvents( + channelId: string, eventIds: string[], targetMessageId: string | null, + targetAgentConversationReplyId: string | null, targetThreadRootId: string | null, ): Promise { const eventsById = new Map(); @@ -67,11 +79,37 @@ async function fetchRouteTargetEvents( } const targetThreadRef = getThreadReference(targetEvent.tags); - const threadRootId = targetThreadRootId ?? targetThreadRef.rootId ?? null; + const threadRootId = + targetThreadRootId ?? + targetThreadRef.rootId ?? + (targetAgentConversationReplyId ? targetEvent.id : null); if (threadRootId && !eventsById.has(threadRootId)) { addEvent(await fetchRouteEvent(threadRootId)); } + if (targetAgentConversationReplyId && threadRootId) { + try { + const taskEvents = await relayClient.fetchEvents({ + "#e": [threadRootId], + "#h": [channelId], + kinds: [ + ...CHANNEL_TIMELINE_CONTENT_KINDS, + ...CHANNEL_TIMELINE_STATE_KINDS, + ], + limit: MAX_ROUTE_TASK_EVENTS, + }); + for (const event of taskEvents) { + addEvent(event); + } + } catch (error) { + console.error( + "Failed to load route task conversation", + targetAgentConversationReplyId, + error, + ); + } + } + let parentId = getReplyParentId(targetEvent); let guard = 0; while ( @@ -93,13 +131,25 @@ async function fetchRouteTargetEvents( return [...eventsById.values()]; } +function mergeRouteEvents( + currentEvents: RelayEvent[] | undefined, + routeEvents: RelayEvent[], +): RelayEvent[] { + return routeEvents.reduce( + (messages, event) => mergeTimelineCacheMessages(messages, event), + currentEvents ?? [], + ); +} + export function ChannelRouteScreen({ channelId, selectedPostId, + targetAgentConversationReplyId, targetMessageId, targetReplyId, targetThreadRootId, }: ChannelRouteScreenProps) { + const queryClient = useQueryClient(); const { closeForumPost, goForumPost } = useAppNavigation(); const channelsQuery = useChannelsQuery(); const identityQuery = useIdentityQuery(); @@ -140,7 +190,12 @@ export function ChannelRouteScreen({ // deep-linked message, the spliced event is the only copy — dropping it on // 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 ((!targetMessageId && !targetThreadRootId) || selectedPostId) { + if ( + (!targetAgentConversationReplyId && + !targetMessageId && + !targetThreadRootId) || + selectedPostId + ) { return () => { isCancelled = true; }; @@ -148,6 +203,10 @@ export function ChannelRouteScreen({ const cachedTarget = getCachedSearchHitEvent(targetMessageId); if (cachedTarget) { + queryClient.setQueryData( + channelMessagesKey(channelId), + (currentEvents) => mergeRouteEvents(currentEvents, [cachedTarget]), + ); setTargetMessageEvents((currentEvents) => currentEvents.some((event) => event.id === cachedTarget.id) ? currentEvents @@ -156,6 +215,7 @@ export function ChannelRouteScreen({ } const eventIds = [ + targetAgentConversationReplyId, targetMessageId, targetThreadRootId && targetThreadRootId !== targetMessageId ? targetThreadRootId @@ -163,11 +223,17 @@ export function ChannelRouteScreen({ ].filter((eventId): eventId is string => eventId !== null); void fetchRouteTargetEvents( + channelId, eventIds, - targetMessageId, + targetAgentConversationReplyId ?? targetMessageId, + targetAgentConversationReplyId, targetThreadRootId, ).then((events) => { if (!isCancelled) { + queryClient.setQueryData( + channelMessagesKey(channelId), + (currentEvents) => mergeRouteEvents(currentEvents, events), + ); setTargetMessageEvents((currentEvents) => { const eventsById = new Map(); for (const event of [...currentEvents, ...events]) { @@ -181,7 +247,14 @@ export function ChannelRouteScreen({ return () => { isCancelled = true; }; - }, [selectedPostId, targetMessageId, targetThreadRootId]); + }, [ + selectedPostId, + channelId, + queryClient, + targetAgentConversationReplyId, + targetMessageId, + targetThreadRootId, + ]); if (channelsQuery.isPending && !activeChannel) { return ( @@ -204,6 +277,7 @@ export function ChannelRouteScreen({ void goForumPost(channelId, postId); }} selectedForumPostId={selectedPostId} + targetAgentConversationReplyId={targetAgentConversationReplyId} targetForumReplyId={targetReplyId} targetMessageEvents={targetMessageEvents} targetMessageId={targetMessageId} diff --git a/desktop/src/app/routes/channels.$channelId.posts.$postId.tsx b/desktop/src/app/routes/channels.$channelId.posts.$postId.tsx index 084a363b0..6b31ec91c 100644 --- a/desktop/src/app/routes/channels.$channelId.posts.$postId.tsx +++ b/desktop/src/app/routes/channels.$channelId.posts.$postId.tsx @@ -41,6 +41,7 @@ function ForumPostRouteComponent() { { + const url = buildAgentConversationLink({ + agentReplyId: REPLY, + channelId: CHANNEL, + }); + assert.equal(url, `buzz://task?channel=${CHANNEL}&reply=${REPLY}`); + + const parsed = parseAgentConversationLink(url); + assert.equal(parsed.ok, true); + assert.deepEqual(parsed.ok && parsed.value, { + agentReplyId: REPLY, + channelId: CHANNEL, + }); +}); + +test("buildAgentConversationLink rejects missing required params", () => { + assert.throws(() => + buildAgentConversationLink({ agentReplyId: REPLY, channelId: "" }), + ); + assert.throws(() => + buildAgentConversationLink({ agentReplyId: "", channelId: CHANNEL }), + ); +}); + +test("parseAgentConversationLink rejects unsupported schemes and hosts", () => { + assert.deepEqual( + parseAgentConversationLink( + `https://example.com/?channel=${CHANNEL}&reply=${REPLY}`, + ), + { ok: false, reason: "wrong-scheme" }, + ); + assert.deepEqual( + parseAgentConversationLink("buzz://message?channel=c&id=m"), + { + ok: false, + reason: "wrong-host", + }, + ); +}); + +test("parseAgentConversationLink rejects missing required params", () => { + assert.deepEqual(parseAgentConversationLink(`buzz://task?reply=${REPLY}`), { + ok: false, + reason: "missing-channel", + }); + assert.deepEqual( + parseAgentConversationLink(`buzz://task?channel=${CHANNEL}`), + { + ok: false, + reason: "missing-reply", + }, + ); +}); + +test("isAgentConversationLink matches task links only", () => { + assert.equal( + isAgentConversationLink(`buzz://task?channel=${CHANNEL}&reply=${REPLY}`), + true, + ); + assert.equal(isAgentConversationLink("buzz://message?channel=c&id=m"), false); + assert.equal(isAgentConversationLink("https://example.com"), false); + assert.equal(isAgentConversationLink(undefined), false); + assert.equal(isAgentConversationLink(""), false); +}); + +test("resolveAgentConversationLinkRenderTarget distinguishes cards from labels", () => { + const href = `buzz://task?channel=${CHANNEL}&reply=${REPLY}`; + const link = { + agentReplyId: REPLY, + channelId: CHANNEL, + }; + + assert.deepEqual( + resolveAgentConversationLinkRenderTarget({ href, label: href }), + { + kind: "card", + link, + }, + ); + assert.deepEqual( + resolveAgentConversationLinkRenderTarget({ href, label: "task" }), + { + kind: "label", + link, + }, + ); + assert.deepEqual( + resolveAgentConversationLinkRenderTarget({ + href: "https://example.com", + label: href, + }), + { kind: "none" }, + ); +}); + +test("trimAgentConversationLinkMatch keeps sentence punctuation outside links", () => { + const href = `buzz://task?channel=${CHANNEL}&reply=${REPLY}`; + + assert.deepEqual(trimAgentConversationLinkMatch(`${href}.`), { + value: href, + trailing: ".", + }); + assert.deepEqual(trimAgentConversationLinkMatch(`${href})`), { + value: href, + trailing: ")", + }); + assert.deepEqual(trimAgentConversationLinkMatch(`${href}]`), { + value: href, + trailing: "]", + }); +}); diff --git a/desktop/src/features/agents/agentConversationLink.ts b/desktop/src/features/agents/agentConversationLink.ts new file mode 100644 index 000000000..e3cafed12 --- /dev/null +++ b/desktop/src/features/agents/agentConversationLink.ts @@ -0,0 +1,123 @@ +const AGENT_CONVERSATION_LINK_SCHEME = "buzz:"; +const AGENT_CONVERSATION_LINK_HOST = "task"; +export const AGENT_CONVERSATION_LINK_URL_PATTERN = + /buzz:\/\/task\?[^\s<>"')\]]+/g; +const TRAILING_PUNCTUATION_PATTERN = /[.,;:!?]+$/; + +export type AgentConversationLinkInput = { + agentReplyId: string; + channelId: string; +}; + +export type ParsedAgentConversationLink = { + agentReplyId: string; + channelId: string; +}; + +export type AgentConversationLinkParseResult = + | { ok: true; value: ParsedAgentConversationLink } + | { ok: false; reason: string }; + +export function buildAgentConversationLink( + input: AgentConversationLinkInput, +): string { + if (!input.channelId) { + throw new Error("buildAgentConversationLink: channelId is required"); + } + if (!input.agentReplyId) { + throw new Error("buildAgentConversationLink: agentReplyId is required"); + } + + const params = new URLSearchParams(); + params.set("channel", input.channelId); + params.set("reply", input.agentReplyId); + + return `${AGENT_CONVERSATION_LINK_SCHEME}//${AGENT_CONVERSATION_LINK_HOST}?${params.toString()}`; +} + +export function parseAgentConversationLink( + url: string, +): AgentConversationLinkParseResult { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return { ok: false, reason: "invalid-url" }; + } + + if (parsed.protocol !== AGENT_CONVERSATION_LINK_SCHEME) { + return { ok: false, reason: "wrong-scheme" }; + } + if (parsed.hostname !== AGENT_CONVERSATION_LINK_HOST) { + return { ok: false, reason: "wrong-host" }; + } + + const channelId = parsed.searchParams.get("channel"); + const agentReplyId = parsed.searchParams.get("reply"); + if (!channelId) { + return { ok: false, reason: "missing-channel" }; + } + if (!agentReplyId) { + return { ok: false, reason: "missing-reply" }; + } + + return { + ok: true, + value: { + agentReplyId, + channelId, + }, + }; +} + +export function isAgentConversationLink( + href: string | undefined | null, +): boolean { + if (!href) return false; + return ( + href.startsWith( + `${AGENT_CONVERSATION_LINK_SCHEME}//${AGENT_CONVERSATION_LINK_HOST}?`, + ) || + href === + `${AGENT_CONVERSATION_LINK_SCHEME}//${AGENT_CONVERSATION_LINK_HOST}` + ); +} + +function isUnmatchedClosing(value: string): boolean { + const closing = value[value.length - 1]; + const opening = closing === ")" ? "(" : "["; + return value.split(closing).length > value.split(opening).length; +} + +export function trimAgentConversationLinkMatch(matchText: string) { + let value = matchText.replace(TRAILING_PUNCTUATION_PATTERN, ""); + while (/[)\]]$/.test(value) && isUnmatchedClosing(value)) { + value = value.slice(0, -1).replace(TRAILING_PUNCTUATION_PATTERN, ""); + } + return { value, trailing: matchText.slice(value.length) }; +} + +type AgentConversationLinkRenderInput = { + href: string; + label: string; +}; + +export type AgentConversationLinkRenderTarget = + | { kind: "card"; link: ParsedAgentConversationLink } + | { kind: "label"; link: ParsedAgentConversationLink } + | { kind: "none" }; + +export function resolveAgentConversationLinkRenderTarget({ + href, + label, +}: AgentConversationLinkRenderInput): AgentConversationLinkRenderTarget { + if (!isAgentConversationLink(href)) return { kind: "none" }; + + const parsed = parseAgentConversationLink(href); + if (!parsed.ok) return { kind: "none" }; + + return { + kind: label === href ? "card" : "label", + link: parsed.value, + }; +} diff --git a/desktop/src/features/agents/agentConversations.test.mjs b/desktop/src/features/agents/agentConversations.test.mjs index 4f0eff1d7..3facb2fcf 100644 --- a/desktop/src/features/agents/agentConversations.test.mjs +++ b/desktop/src/features/agents/agentConversations.test.mjs @@ -2,8 +2,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - buildAgentConversationMentionPubkeys, buildAgentConversation, + buildAgentConversationMentionPubkeys, buildAgentConversationRecap, buildAgentConversationMarkers, deriveAgentConversationTitle, diff --git a/desktop/src/features/agents/agentConversations.ts b/desktop/src/features/agents/agentConversations.ts index 02eb23d86..7a10a31cf 100644 --- a/desktop/src/features/agents/agentConversations.ts +++ b/desktop/src/features/agents/agentConversations.ts @@ -7,6 +7,7 @@ import { KIND_AGENT_CONVERSATION_COMPAT, } from "@/shared/constants/kinds"; import { normalizePubkey } from "@/shared/lib/pubkey"; +import { parseAgentConversationLink } from "./agentConversationLink"; import { collectConversationContextMessages, deriveTitleFromContext, @@ -150,6 +151,19 @@ export function buildAgentConversationMentionPubkeys({ return merged; } +export function getAgentConversationMarkerTitleForHref( + markers: readonly AgentConversationMarker[] | undefined, + href: string, +) { + const parsed = parseAgentConversationLink(href); + if (!parsed.ok) return undefined; + return markers?.find( + (marker) => + marker.channelId === parsed.value.channelId && + marker.agentReplyId === parsed.value.agentReplyId, + )?.title; +} + export function readHiddenAgentConversationIds( pubkey: string, workspaceScope: string | null | undefined, diff --git a/desktop/src/features/agents/remarkAgentConversationLinks.ts b/desktop/src/features/agents/remarkAgentConversationLinks.ts new file mode 100644 index 000000000..5e3359003 --- /dev/null +++ b/desktop/src/features/agents/remarkAgentConversationLinks.ts @@ -0,0 +1,32 @@ +/** + * Remark plugin that detects bare `buzz://task?…` URLs and replaces each with + * a custom task-link element. The renderer turns that element into the same + * native task card shown when a dedicated conversation is started. + */ + +import { createRemarkPrefixPlugin } from "../../shared/lib/createRemarkPrefixPlugin.ts"; +import { + AGENT_CONVERSATION_LINK_URL_PATTERN, + trimAgentConversationLinkMatch, +} from "./agentConversationLink.ts"; + +export default function remarkAgentConversationLinks() { + return createRemarkPrefixPlugin( + AGENT_CONVERSATION_LINK_URL_PATTERN, + (matchText) => { + const { value, trailing } = trimAgentConversationLinkMatch(matchText); + + return { + node: { + type: "agent-conversation-link", + value, + data: { + hName: "agent-conversation-link", + hChildren: [{ type: "text", value }], + }, + }, + trailing, + }; + }, + ); +} diff --git a/desktop/src/features/agents/ui/AgentConversationScreen.tsx b/desktop/src/features/agents/ui/AgentConversationScreen.tsx index 5f31411a9..78d797f64 100644 --- a/desktop/src/features/agents/ui/AgentConversationScreen.tsx +++ b/desktop/src/features/agents/ui/AgentConversationScreen.tsx @@ -1,16 +1,19 @@ import * as React from "react"; -import { ArrowLeft, Bot, createLucideIcon } from "lucide-react"; +import { Bot, ChevronRight, Copy, createLucideIcon } from "lucide-react"; import { toast } from "sonner"; import { - buildAgentConversationMentionPubkeys, + buildAgentConversationLink, + parseAgentConversationLink, +} from "@/features/agents/agentConversationLink"; +import { buildAgentConversationMarkers, buildAgentConversationRecap, deriveAgentConversationTitle, - getAutoRoutedAgentConversationPubkeys, type AgentConversation, publishAgentConversationMarker, } from "@/features/agents/agentConversations"; +import { mergeAutoRouteMentionPubkeys } from "@/features/channels/ui/ChannelPane.helpers"; import { useManagedAgentsQuery, useRelayAgentsQuery, @@ -58,6 +61,7 @@ import type { Channel, Identity, Profile } from "@/shared/api/types"; import { channelContentTopPaddingMeasurement } from "@/shared/layout/chromeLayout"; import { useMeasuredCssVariable } from "@/shared/layout/useMeasuredCssVariable"; import { normalizePubkey } from "@/shared/lib/pubkey"; +import { AnimatedTextSwap } from "@/shared/ui/AnimatedTextSwap"; import { Shimmer } from "@/shared/ui/Shimmer"; import { Button } from "@/shared/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; @@ -496,9 +500,9 @@ export function AgentConversationScreen({ .map((participant) => participant.pubkey), [agentParticipants], ); - const autoRoutedAgentPubkeys = React.useMemo( - () => getAutoRoutedAgentConversationPubkeys(agentParticipants), - [agentParticipants], + const autoRouteAgentPubkeys = React.useMemo( + () => (routeableAgentPubkeys.length === 1 ? routeableAgentPubkeys : []), + [routeableAgentPubkeys], ); const canMessageAnyAgent = routeableAgentPubkeys.length > 0; const restrictedAgentNames = React.useMemo( @@ -565,21 +569,23 @@ export function AgentConversationScreen({ mentionPubkeys: string[], mediaTags?: string[][], ) => { + const routedMentionPubkeys = mergeAutoRouteMentionPubkeys({ + autoRouteAgentPubkeys, + mentionPubkeys, + }); + await sendMessageMutation.mutateAsync({ clientTags: [ ["client", "agent-conversation", conversation.agentReply.id], ], content, mediaTags, - mentionPubkeys: buildAgentConversationMentionPubkeys({ - autoRouteAgentPubkeys: autoRoutedAgentPubkeys, - mentionPubkeys, - }), + mentionPubkeys: routedMentionPubkeys, parentEventId: replyParentEventId, }); }, [ - autoRoutedAgentPubkeys, + autoRouteAgentPubkeys, conversation.agentReply.id, replyParentEventId, sendMessageMutation, @@ -707,9 +713,7 @@ export function AgentConversationScreen({ > - {isPublishingThreadSummary - ? "Generating recap..." - : "Send recap to thread"} + {isPublishingThreadSummary ? "Generating recap..." : "Send recap"} @@ -718,25 +722,68 @@ export function AgentConversationScreen({ ); - const headerLeadingContent = onBackToThread ? ( - - - - - Back to source thread - - ) : ( - false + const sourceChannelName = channel?.name ?? conversation.channelName; + const sourceChannelType = channel?.channelType ?? "stream"; + const sourceChannelVisibility = channel?.visibility ?? "open"; + const handleCopyConversationLink = React.useCallback(async () => { + try { + await navigator.clipboard.writeText( + buildAgentConversationLink({ + agentReplyId: conversation.agentReply.id, + channelId: channel?.id ?? conversation.channelId, + }), + ); + toast.success("Task link copied"); + } catch { + toast.error("Failed to copy task link"); + } + }, [channel?.id, conversation.agentReply.id, conversation.channelId]); + const getAgentConversationTitleForHref = React.useCallback( + (href: string) => { + const parsed = parseAgentConversationLink(href); + if ( + !parsed.ok || + parsed.value.channelId !== conversation.channelId || + parsed.value.agentReplyId !== conversation.agentReply.id + ) { + return undefined; + } + + return conversation.title; + }, + [conversation.agentReply.id, conversation.channelId, conversation.title], + ); + const headerTitleTrailingContent = ( + <> + + + + + + + + + Copy task link + + ); return ( @@ -748,19 +795,26 @@ export function AgentConversationScreen({ > onBackToThread(conversation), + title: "Back to source thread", + } + : undefined + } + titleTrailingContent={headerTitleTrailingContent} + visibility={sourceChannelVisibility} />
{ + assert.equal( + canOpenAgentConversationInChannel({ + channel: channel(), + }), + true, + ); + assert.equal( + canOpenAgentConversationInChannel({ + channel: channel({ archivedAt: "2026-06-27T00:00:00.000Z" }), + }), + false, + ); + assert.equal( + canOpenAgentConversationInChannel({ + channel: channel({ isMember: false }), + }), + false, + ); +}); + +test("existing agent conversation markers can open in read-only channels", () => { + assert.equal( + canOpenAgentConversationInChannel({ + channel: channel({ archivedAt: "2026-06-27T00:00:00.000Z" }), + publishMarker: false, + }), + true, + ); + assert.equal( + canOpenAgentConversationInChannel({ + channel: channel({ isMember: false }), + publishMarker: false, + }), + true, + ); +}); test("DM composer auto-routes only when exactly one other participant is an agent", () => { const knownAgentPubkeys = new Set(["agent-one", "agent-two"]); @@ -105,15 +119,26 @@ test("DM composer auto-routes only when exactly one other participant is an agen ); }); -test("thread composer auto-routes only for one human and one known agent", () => { +test("auto-routed mentions merge with explicit mentions without duplicates", () => { + assert.deepEqual( + mergeAutoRouteMentionPubkeys({ + autoRouteAgentPubkeys: ["AGENT-ONE"], + mentionPubkeys: ["agent-one", "agent-two"], + }), + ["AGENT-ONE", "agent-two"], + ); +}); + +test("thread composer auto-routes exactly one current human and one known agent", () => { const knownAgentPubkeys = new Set(["agent-one", "agent-two"]); assert.deepEqual( getThreadAutoRouteAgentPubkeys({ + currentPubkey: "human", knownAgentPubkeys, messages: [ - message({ id: "root", tags: [["p", "agent-one"]] }), - message({ id: "agent-reply", pubkey: "agent-one" }), + { id: "root", pubkey: "human", tags: [["p", "agent-one"]] }, + { id: "reply", pubkey: "agent-one", tags: [] }, ], }), ["agent-one"], @@ -121,25 +146,30 @@ test("thread composer auto-routes only for one human and one known agent", () => assert.deepEqual( getThreadAutoRouteAgentPubkeys({ + currentPubkey: "human", + knownAgentPubkeys, + messages: [ + { id: "root", pubkey: "human", tags: [["p", "agent-one"]] }, + { id: "reply", pubkey: "other-human", tags: [] }, + ], + }), + [], + ); + + assert.deepEqual( + getThreadAutoRouteAgentPubkeys({ + currentPubkey: "human-one", knownAgentPubkeys, messages: [ - message({ + { id: "root", pubkey: "human-one", - tags: [ - ["p", "human-one"], - ["p", "agent-one"], - ], - }), - message({ - id: "human-two-reply", - pubkey: "human-two", tags: [ ["p", "human-two"], ["p", "agent-one"], ], - }), - message({ id: "agent-reply", pubkey: "agent-one" }), + }, + { id: "reply", pubkey: "agent-one", tags: [] }, ], }), [], @@ -147,60 +177,19 @@ test("thread composer auto-routes only for one human and one known agent", () => assert.deepEqual( getThreadAutoRouteAgentPubkeys({ + currentPubkey: "human", knownAgentPubkeys, messages: [ - message({ id: "agent-one-reply", pubkey: "agent-one" }), - message({ id: "agent-two-reply", pubkey: "agent-two" }), + { + id: "root", + pubkey: "human", + tags: [ + ["p", "agent-one"], + ["p", "agent-two"], + ], + }, ], }), [], ); }); - -test("auto-routed mentions merge with explicit mentions without duplicates", () => { - assert.deepEqual( - mergeAutoRouteMentionPubkeys({ - autoRouteAgentPubkeys: ["AGENT-ONE"], - mentionPubkeys: ["agent-one", "agent-two"], - }), - ["AGENT-ONE", "agent-two"], - ); -}); - -test("new agent conversations require a writable channel", () => { - assert.equal( - canOpenAgentConversationInChannel({ - channel: channel(), - }), - true, - ); - assert.equal( - canOpenAgentConversationInChannel({ - channel: channel({ archivedAt: "2026-06-27T00:00:00.000Z" }), - }), - false, - ); - assert.equal( - canOpenAgentConversationInChannel({ - channel: channel({ isMember: false }), - }), - false, - ); -}); - -test("existing agent conversation markers can open in read-only channels", () => { - assert.equal( - canOpenAgentConversationInChannel({ - channel: channel({ archivedAt: "2026-06-27T00:00:00.000Z" }), - publishMarker: false, - }), - true, - ); - assert.equal( - canOpenAgentConversationInChannel({ - channel: channel({ isMember: false }), - publishMarker: false, - }), - true, - ); -}); diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.ts b/desktop/src/features/channels/ui/ChannelPane.helpers.ts index f56754435..d3669df32 100644 --- a/desktop/src/features/channels/ui/ChannelPane.helpers.ts +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.ts @@ -1,9 +1,9 @@ import { isEphemeralChannel } from "@/features/channels/lib/ephemeralChannel"; +import { collectMessageMentionPubkeys } from "@/features/messages/lib/formatTimelineMessages"; import type { TimelineMessage } from "@/features/messages/types"; import type { Channel } from "@/shared/api/types"; import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; import { normalizePubkey } from "@/shared/lib/pubkey"; -import { getMentionTagPubkey } from "@/shared/lib/resolveMentionNames"; export function getChannelIntroKind(channel: Channel): string { const isPrivate = channel.visibility === "private"; @@ -129,43 +129,57 @@ export function getDmAutoRouteAgentPubkeys({ } export function getThreadAutoRouteAgentPubkeys({ + currentPubkey, knownAgentPubkeys, messages, }: { + currentPubkey?: string; knownAgentPubkeys: ReadonlySet; messages: readonly TimelineMessage[]; }) { const agentPubkeys = new Map(); const humanPubkeys = new Set(); - const addParticipant = (pubkey: string | null | undefined) => { - if (!pubkey) { + const normalizedCurrentPubkey = currentPubkey + ? normalizePubkey(currentPubkey) + : null; + + const addAuthor = (pubkey?: string | null) => { + if (!pubkey) return; + const normalized = normalizePubkey(pubkey); + if (!normalized) return; + if (knownAgentPubkeys.has(normalized)) { + agentPubkeys.set(normalized, pubkey); return; } + humanPubkeys.add(normalized); + }; + for (const message of messages) { + addAuthor(message.pubkey); + } + + for (const pubkey of collectMessageMentionPubkeys([...messages])) { const normalized = normalizePubkey(pubkey); if (!normalized) { - return; + continue; } if (knownAgentPubkeys.has(normalized)) { agentPubkeys.set(normalized, pubkey); - return; + continue; } humanPubkeys.add(normalized); - }; - - for (const message of messages) { - addParticipant(message.pubkey); + } - for (const tag of message.tags ?? []) { - addParticipant(getMentionTagPubkey(tag)); - } + if (agentPubkeys.size !== 1 || humanPubkeys.size !== 1) { + return []; + } + if (normalizedCurrentPubkey && !humanPubkeys.has(normalizedCurrentPubkey)) { + return []; } - return agentPubkeys.size === 1 && humanPubkeys.size === 1 - ? [...agentPubkeys.values()] - : []; + return [...agentPubkeys.values()]; } export function mergeAutoRouteMentionPubkeys({ diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 1faa3e4cf..afbf7cb58 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -11,12 +11,12 @@ import { MessageTimeline, type MessageTimelineHandle, } from "@/features/messages/ui/MessageTimeline"; -import { getHiddenAgentConversationMessageIds } from "@/features/agents/agentConversations"; -import { buildDirectMessageIntro } from "@/features/channels/lib/dmParticipantDisplay"; import { - getDmHuddleMemberPubkeys, - hasOtherDmParticipant, -} from "@/features/channels/lib/dmHuddleMembers"; + getHiddenAgentConversationMessageIds, + type AgentConversationMarker, +} from "@/features/agents/agentConversations"; +import { buildDirectMessageIntro } from "@/features/channels/lib/dmParticipantDisplay"; +import { getDmHuddleMemberPubkeys } from "@/features/channels/lib/dmHuddleMembers"; import { buildVideoReviewCommentsByRootId, buildVideoReviewContextForMessage, @@ -29,6 +29,7 @@ import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThre import { ChannelManagementAuxiliaryPanel } from "@/features/channels/ui/ChannelManagementAuxiliaryPanel"; import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane"; import { BotActivityComposerAction } from "@/features/channels/ui/BotActivityBar"; +import { ChannelTasksView } from "@/features/channels/ui/ChannelTasksView"; import { containsWelcomePersonaMention, WelcomeComposerBanner, @@ -42,13 +43,16 @@ import { canOpenAgentConversationInChannel, getChannelIntroDescription, getChannelIntroKind, + getThreadAutoRouteAgentPubkeys, isWelcomeSetupSystemMessage, + mergeAutoRouteMentionPubkeys, mentionsKnownAgent, } from "@/features/channels/ui/ChannelPane.helpers"; import type { ChannelPaneProps } from "@/features/channels/ui/ChannelPane.types"; import * as agentSessionSelection from "@/features/channels/ui/agentSessionSelection"; import { Button } from "@/shared/ui/button"; import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel"; +import { isBroadcastReply } from "@/features/messages/lib/threading"; import { useRenderScopedReactionHydration } from "@/features/messages/lib/useRenderScopedReactionHydration"; import type { TimelineMessage } from "@/features/messages/types"; import { isWelcomeChannel } from "@/features/onboarding/welcome"; @@ -107,11 +111,13 @@ export const ChannelPane = React.memo(function ChannelPane({ onOpenMembers, onOpenProfilePanel, onOpenThread, + onSurfaceTabChange, onResetThreadPanelWidth, onSelectThreadReplyTarget, onSendMessage, onSendVideoReviewComment, onSendThreadReply, + onThreadScrollTargetChange, onThreadScrollTargetResolved, onThreadPanelResizeStart, onTargetReached, @@ -123,6 +129,7 @@ export const ChannelPane = React.memo(function ChannelPane({ openThreadHeadId, shouldShowThreadSkeleton, openAgentSessionPubkey, + surfaceTab = "messages", onProfilePanelViewChange, onProfilePanelTabChange, profilePanelPubkey, @@ -144,6 +151,10 @@ export const ChannelPane = React.memo(function ChannelPane({ const messageTimelineRef = React.useRef(null); const composerWrapperRef = React.useRef(null); const { openAgentConversation } = useAppShell(); + const [taskFocusMessageId, setTaskFocusMessageId] = React.useState< + string | null + >(null); + const previousTaskFocusChannelIdRef = React.useRef(null); const completedWelcomeBannerChannelIdsRef = React.useRef(new Set()); const welcomeComposerDismissTimerRef = React.useRef(null); const welcomeComposerHideTimerRef = React.useRef(null); @@ -155,16 +166,24 @@ export const ChannelPane = React.memo(function ChannelPane({ !activeChannel.isMember && activeChannel.visibility === "open" && !activeChannel.archivedAt; - const hasMainComposerOverlay = !isNonMemberView; + const isTasksSurface = surfaceTab === "tasks"; + const hasMainComposerOverlay = !isNonMemberView && !isTasksSurface; const activeChannelId = activeChannel?.id ?? null; const huddleMemberPubkeys = React.useMemo( () => getDmHuddleMemberPubkeys(activeChannel, agentPubkeys, currentPubkey), [activeChannel, agentPubkeys, currentPubkey], ); - const huddleMemberPubkeysPending = - agentPubkeysPending && hasOtherDmParticipant(activeChannel, currentPubkey); + const huddleMemberPubkeysPending = agentPubkeysPending; const isActiveWelcomeChannel = activeChannel !== null && isWelcomeChannel(activeChannel); + React.useEffect(() => { + if (previousTaskFocusChannelIdRef.current === activeChannelId) { + return; + } + + previousTaskFocusChannelIdRef.current = activeChannelId; + setTaskFocusMessageId(null); + }, [activeChannelId]); useComposerHeightPadding( timelineScrollRef, composerWrapperRef, @@ -212,7 +231,6 @@ export const ChannelPane = React.memo(function ChannelPane({ threadMessages.some((entry) => entry.message.id === editTarget.id)); const mainEditTarget = editTarget && !isEditInThread ? editTarget : null; const threadEditTarget = editTarget && isEditInThread ? editTarget : null; - const findLastOwnEditable = React.useCallback( (candidates: TimelineMessage[]): TimelineMessage | null => { if (!onEdit || !currentPubkey) return null; @@ -233,7 +251,6 @@ export const ChannelPane = React.memo(function ChannelPane({ }, [onEdit, currentPubkey], ); - const handleEditLastOwnMainMessage = React.useCallback((): boolean => { const target = findLastOwnEditable(messages); if (!target || !onEdit) return false; @@ -359,6 +376,48 @@ export const ChannelPane = React.memo(function ChannelPane({ }, [activeChannel, messages, openAgentConversation], ); + const handleGoToTaskMessage = React.useCallback( + ( + marker: AgentConversationMarker, + message: TimelineMessage, + threadMessage: TimelineMessage, + ) => { + onSurfaceTabChange?.("messages"); + const isBroadcastTask = isBroadcastReply(message.tags ?? []); + const isThreadedTask = + !isBroadcastTask && + (Boolean(marker.parentMessageId) || + Boolean(message.parentId) || + Boolean(message.rootId && message.rootId !== message.id) || + threadMessage.id !== message.id); + + if (isThreadedTask) { + onOpenThread(threadMessage); + onThreadScrollTargetChange(message.id); + return; + } + + onCloseThread(); + setTaskFocusMessageId(message.id); + }, + [ + onCloseThread, + onOpenThread, + onSurfaceTabChange, + onThreadScrollTargetChange, + ], + ); + const handleTimelineTargetReached = React.useCallback( + (messageId: string) => { + setTaskFocusMessageId((current) => + current === messageId ? null : current, + ); + if (taskFocusMessageId !== messageId) { + onTargetReached?.(messageId); + } + }, + [onTargetReached, taskFocusMessageId], + ); const canDropInMainColumn = hasMainComposerOverlay && !isComposerDisabled && !isSinglePanelView; const hasTypingActivity = typingPubkeys.length > 0; @@ -515,6 +574,25 @@ export const ChannelPane = React.memo(function ChannelPane({ ...threadMessages.map((entry) => entry.message), ]; }, [threadHeadMessage, threadMessages]); + const threadAutoRouteAgentPubkeys = React.useMemo( + () => + getThreadAutoRouteAgentPubkeys({ + currentPubkey, + knownAgentPubkeys, + messages: threadSourceMessages, + }), + [currentPubkey, knownAgentPubkeys, threadSourceMessages], + ); + const handleSendThreadReply = React.useCallback( + (content: string, mentionPubkeys: string[], mediaTags?: string[][]) => { + const sendMentionPubkeys = mergeAutoRouteMentionPubkeys({ + autoRouteAgentPubkeys: threadAutoRouteAgentPubkeys, + mentionPubkeys, + }); + return onSendThreadReply(content, sendMentionPubkeys, mediaTags); + }, + [onSendThreadReply, threadAutoRouteAgentPubkeys], + ); const hiddenAgentConversationMessageIds = React.useMemo(() => { const hiddenIds = getHiddenAgentConversationMessageIds( baseVisibleMessages, @@ -687,7 +765,7 @@ export const ChannelPane = React.memo(function ChannelPane({ } > {header} - {channelFind.isOpen ? ( + {channelFind.isOpen && !isTasksSurface ? (
) : null} - - {isNonMemberView ? ( -
-
- - - Viewing{" "} - - #{activeChannel?.name} - - -
- -
+ {isTasksSurface ? ( + ) : ( -
-
- {isActiveWelcomeChannel ? ( - - ) : null} - -
-
- {hasComposerBotActivity ? ( -
- -
- ) : null} - {hasTypingActivity ? ( - + + {isNonMemberView ? ( +
+
+ + + Viewing{" "} + + #{activeChannel?.name} + + +
+ +
+ ) : ( +
+
+ {isActiveWelcomeChannel ? ( + ) : null} + +
+
+ {hasComposerBotActivity ? ( +
+ +
+ ) : null} + {hasTypingActivity ? ( + + ) : null} +
+
-
-
+ )} + )} {canDropInMainColumn && mainComposerMedia.isDragOver ? ( ) : null} ) : null} - - {channelManagementOpen && activeChannel ? ( + {!isTasksSurface && channelManagementOpen && activeChannel ? ( - ) : threadHeadMessage ? ( + ) : !isTasksSurface && threadHeadMessage ? ( (() => { const panel = ( { const panel = ( { const panel = ( { const panel = ( void; onOpenProfilePanel: (pubkey: string) => void; onOpenThread: (message: TimelineMessage) => void; + onSurfaceTabChange?: (tab: "messages" | "tasks") => void; onResetThreadPanelWidth: () => void; onSelectThreadReplyTarget: (message: TimelineMessage) => void; onSendMessage: ( @@ -88,6 +89,7 @@ export type ChannelPaneProps = { emoji: string, remove: boolean, ) => Promise; + onThreadScrollTargetChange: (messageId: string | null) => void; onThreadScrollTargetResolved: () => void; onThreadPanelResizeStart: ( event: React.PointerEvent, @@ -97,6 +99,7 @@ export type ChannelPaneProps = { openThreadHeadId: string | null; shouldShowThreadSkeleton: boolean; openAgentSessionPubkey: string | null; + surfaceTab?: "messages" | "tasks"; onProfilePanelViewChange: ( view: ProfilePanelView, options?: { replace?: boolean }, diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 67c4929e4..07bd395df 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -4,10 +4,6 @@ import { cacheSearchHitEvent } from "@/app/navigation/searchHitEventCache"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useActiveChannelHeader } from "@/features/channels/useActiveChannelHeader"; import { useChannelPaneHandlers } from "@/features/channels/useChannelPaneHandlers"; -import { - buildAgentConversationMarkers, - getHiddenAgentConversationMessageIds, -} from "@/features/agents/agentConversations"; import { useChannelMembersQuery, useJoinChannelMutation, @@ -16,16 +12,19 @@ import { MSG_PREFIX, THREAD_PREFIX, } from "@/features/channels/readState/readStateFormat"; +import { + getDmAutoRouteAgentPubkeys, + mergeAutoRouteMentionPubkeys, +} from "@/features/channels/ui/ChannelPane.helpers"; import { ChannelScreenEmptyState } from "@/features/channels/ui/ChannelScreenEmptyState"; -import { ChannelScreenHeader } from "@/features/channels/ui/ChannelScreenHeader"; +import { + ChannelScreenHeader, + type ChannelSurfaceTab, +} from "@/features/channels/ui/ChannelScreenHeader"; import { ChannelPane, ForumView, } from "@/features/channels/ui/ChannelScreenLazyViews"; -import { - getDmAutoRouteAgentPubkeys, - getThreadAutoRouteAgentPubkeys, -} from "@/features/channels/ui/ChannelPane.helpers"; import { MembersSidebar } from "@/features/channels/ui/MembersSidebar"; import { useManagedAgentsQuery, @@ -75,15 +74,16 @@ import { mergeAgentNamesIntoProfiles, useChannelActivityTyping, } from "./useChannelActivityTyping"; +import { useAgentConversationRouteTarget } from "./useAgentConversationRouteTarget"; import { useChannelAgentSessions } from "./useChannelAgentSessions"; import { useChannelPanelHistoryState } from "./useChannelPanelHistoryState"; import { useChannelProfilePanel } from "./useChannelProfilePanel"; import { useChannelRouteTarget } from "./useChannelRouteTarget"; import { useChannelUnreadState } from "./useChannelUnreadState"; +import { useAgentConversationTimelineState } from "./filterAgentConversationMessages"; +import { useResetChannelSurfaceTabOnRouteOpen } from "./useResetChannelSurfaceTabOnRouteOpen"; import type { ChannelScreenProps } from "./ChannelScreen.types"; - const HEADER_ACTIONS_COMPACT_BREAKPOINT_PX = 760; - export function ChannelScreen({ activeChannel, currentIdentity, @@ -91,17 +91,21 @@ export function ChannelScreen({ onCloseForumPost, onSelectForumPost, selectedForumPostId, + targetAgentConversationReplyId, targetForumReplyId, targetMessageEvents, targetMessageId, }: ChannelScreenProps) { - const { goHome } = useAppNavigation(); + const { goChannel, goHome } = useAppNavigation(); + const [activeSurfaceTab, setActiveSurfaceTab] = + React.useState("messages"); const { markChannelRead, markChannelUnread, getChannelReadAt, getMessageReadAt, markMessageRead, + openAgentConversation, setContextParentResolver, openCreateChannel, openChannelManagement: openGlobalChannelManagement, @@ -159,6 +163,21 @@ export function ChannelScreen({ const mainInsetRef = useMainInsetRef(); const currentPubkey = currentIdentity?.pubkey; const activeChannelId = activeChannel?.id ?? null; + const canShowTasksSurface = activeChannel?.channelType === "stream"; + const effectiveSurfaceTab = canShowTasksSurface + ? activeSurfaceTab + : "messages"; + useResetChannelSurfaceTabOnRouteOpen({ + activeChannelId, + openThreadHeadId, + setActiveSurfaceTab, + targetMessageId, + }); + React.useEffect(() => { + if (!canShowTasksSurface && activeSurfaceTab === "tasks") { + setActiveSurfaceTab("messages"); + } + }, [activeSurfaceTab, canShowTasksSurface]); const effectiveOpenThreadHeadId = optimisticOpenThreadHeadId === undefined ? openThreadHeadId @@ -312,6 +331,11 @@ export function ChannelScreen({ const messageProfilesQuery = useUsersBatchQuery(messageProfilePubkeys, { enabled: messageProfilePubkeys.length > 0, }); + const messageProfilesReady = + messageProfilePubkeys.length === 0 || + (!messageProfilesQuery.isPending && + !messageProfilesQuery.isFetching && + !messageProfilesQuery.isPlaceholderData); const channelMembersQuery = useChannelMembersQuery(activeChannel?.id ?? null); const channelMembers = channelMembersQuery.data; const managedAgentsQuery = useManagedAgentsQuery(); @@ -340,14 +364,18 @@ export function ChannelScreen({ } return pubkeys; }, [channelMembers, managedAgents, messageProfilesQuery.data, relayAgents]); - const agentPubkeysPending = - activeChannel?.channelType === "dm" && - (channelMembersQuery.isPending || - managedAgentsQuery.isPending || - relayAgentsQuery.isPending || - (messageProfilePubkeys.length > 0 && - (messageProfilesQuery.isPending || - messageProfilesQuery.isPlaceholderData))); + const agentLookupPending = + channelMembersQuery.isPending || + channelMembersQuery.isFetching || + managedAgentsQuery.isPending || + managedAgentsQuery.isFetching || + relayAgentsQuery.isPending || + relayAgentsQuery.isFetching || + (messageProfilePubkeys.length > 0 && + (messageProfilesQuery.isPending || + messageProfilesQuery.isFetching || + messageProfilesQuery.isPlaceholderData)); + const agentPubkeysPending = agentLookupPending; const { agentSessionCandidates, botTypingEntries, @@ -398,6 +426,24 @@ export function ChannelScreen({ messageProfilesQuery.data?.profiles, relayAgents, ]); + const routingAgentPubkeys = React.useMemo(() => { + const pubkeys = new Set(agentPubkeys); + for (const [pubkey, profile] of Object.entries(messageProfiles)) { + if (profile?.isAgent) { + pubkeys.add(normalizePubkey(pubkey)); + } + } + return pubkeys; + }, [agentPubkeys, messageProfiles]); + const dmAutoRouteAgentPubkeys = React.useMemo( + () => + getDmAutoRouteAgentPubkeys({ + channel: activeChannel, + currentPubkey, + knownAgentPubkeys: routingAgentPubkeys, + }), + [activeChannel, currentPubkey, routingAgentPubkeys], + ); const personasQuery = usePersonasQuery(); const { personaLookup, respondToLookup } = React.useMemo(() => { const agents = managedAgentsQuery.data ?? []; @@ -445,23 +491,8 @@ export function ChannelScreen({ : [...currentEvents, event], ); }, []); - const agentConversationMarkers = React.useMemo( - () => buildAgentConversationMarkers(resolvedMessages), - [resolvedMessages], - ); - const unreadTimelineMessages = React.useMemo(() => { - const hiddenMessageIds = getHiddenAgentConversationMessageIds( - timelineMessages, - agentConversationMarkers, - ); - if (hiddenMessageIds.size === 0) { - return timelineMessages; - } - - return timelineMessages.filter( - (message) => !hiddenMessageIds.has(message.id), - ); - }, [agentConversationMarkers, timelineMessages]); + const { agentConversationMarkers, unreadTimelineMessages } = + useAgentConversationTimelineState(resolvedMessages, timelineMessages); const channelFind = useChannelFind({ channelId: activeChannelId, messages: timelineMessages, @@ -501,37 +532,6 @@ export function ChannelScreen({ timelineMessages.find((message) => message.id === editTargetId) ?? null, [editTargetId, timelineMessages], ); - const routingAgentPubkeys = React.useMemo(() => { - const pubkeys = new Set(agentPubkeys); - for (const [pubkey, profile] of Object.entries(messageProfiles)) { - if (profile?.isAgent) { - pubkeys.add(normalizePubkey(pubkey)); - } - } - return pubkeys; - }, [agentPubkeys, messageProfiles]); - const messageAutoRouteAgentPubkeys = React.useMemo( - () => - getDmAutoRouteAgentPubkeys({ - channel: activeChannel, - currentPubkey, - knownAgentPubkeys: routingAgentPubkeys, - }), - [activeChannel, currentPubkey, routingAgentPubkeys], - ); - const threadAutoRouteAgentPubkeys = React.useMemo(() => { - if (!openThreadHeadMessage) { - return []; - } - - return getThreadAutoRouteAgentPubkeys({ - knownAgentPubkeys: routingAgentPubkeys, - messages: [ - openThreadHeadMessage, - ...threadMessages.map((entry) => entry.message), - ], - }); - }, [openThreadHeadMessage, routingAgentPubkeys, threadMessages]); const { handleCancelEdit, handleCancelThreadReply, @@ -549,7 +549,6 @@ export function ChannelScreen({ deleteMessageMutation, editMessageMutation, editTargetId, - messageAutoRouteAgentPubkeys, expandedThreadReplyIds, getFirstReplyIdForMessage, getReplyDescendantIdsForMessage, @@ -562,10 +561,26 @@ export function ChannelScreen({ setOpenThreadHeadId, setThreadReplyTargetId, setThreadScrollTargetId, - threadAutoRouteAgentPubkeys, threadReplyTargetId, toggleReactionMutation, }); + const handleSendMessageWithDmAutoRoute = React.useCallback( + async ( + content: string, + mentionPubkeys: string[], + mediaTags?: string[][], + ) => { + await handleSendMessage( + content, + mergeAutoRouteMentionPubkeys({ + autoRouteAgentPubkeys: dmAutoRouteAgentPubkeys, + mentionPubkeys, + }), + mediaTags, + ); + }, + [dmAutoRouteAgentPubkeys, handleSendMessage], + ); const effectiveToggleReaction = React.useMemo( () => activeChannel && !activeChannel.archivedAt && activeChannel.isMember @@ -700,25 +715,96 @@ export function ChannelScreen({ const handleThreadScrollTargetResolved = React.useCallback(() => { setThreadScrollTargetId(null); }, []); - const handleTargetReached = React.useCallback(() => { - clearMessageRouteTarget({ replace: true }); - }, [clearMessageRouteTarget]); + const [ + pendingMainTimelineRouteTargetId, + setPendingMainTimelineRouteTargetId, + ] = React.useState(null); React.useEffect(() => { resetComposerTargets(activeChannelId); }, [activeChannelId, resetComposerTargets]); - const mainTimelineTargetMessageId = useChannelRouteTarget({ + const handleSurfaceTabChange = React.useCallback( + (tab: ChannelSurfaceTab) => { + setActiveSurfaceTab(tab); + + if (tab !== "tasks") { + return; + } + + clearOptimisticThreadOverride(); + setChannelManagementOpen(false); + setOpenThreadHeadId(null, { replace: true }); + setExpandedThreadReplyIds(new Set()); + setThreadScrollTargetId(null); + setThreadReplyTargetId(null); + handleCloseAgentSession(); + setProfilePanelPubkey(null); + }, + [ + clearOptimisticThreadOverride, + handleCloseAgentSession, + setChannelManagementOpen, + setOpenThreadHeadId, + setProfilePanelPubkey, + ], + ); + useAgentConversationRouteTarget({ activeChannel, activeChannelId, - closeAgentSession: handleCloseAgentSession, - setEditTargetId, - setExpandedThreadReplyIds, - setOpenThreadHeadId, - setProfilePanelPubkey, - setThreadReplyTargetId, - setThreadScrollTargetId, - targetMessageId, + goChannel, + messageProfilesReady, + openAgentConversation, + targetAgentConversationReplyId, timelineMessages, }); + const { mainTimelineTargetMessageId, rootThreadHeadTargetId } = + useChannelRouteTarget({ + activeChannel, + activeChannelId, + closeAgentSession: handleCloseAgentSession, + setEditTargetId, + setExpandedThreadReplyIds, + setOpenThreadHeadId, + setProfilePanelPubkey, + setThreadReplyTargetId, + setThreadScrollTargetId, + targetMessageId, + timelineMessages, + }); + const handleTargetReached = React.useCallback( + (messageId: string) => { + setPendingMainTimelineRouteTargetId((current) => + current === messageId ? null : current, + ); + if (rootThreadHeadTargetId === messageId) { + handleCloseAgentSession(); + setProfilePanelPubkey(null, { replace: true }); + setEditTargetId(null); + setOpenThreadHeadId(messageId, { replace: true }); + setThreadReplyTargetId(messageId); + setThreadScrollTargetId(null); + setExpandedThreadReplyIds(new Set()); + } + clearMessageRouteTarget({ replace: true }); + }, + [ + clearMessageRouteTarget, + handleCloseAgentSession, + rootThreadHeadTargetId, + setOpenThreadHeadId, + setProfilePanelPubkey, + ], + ); + // biome-ignore lint/correctness/useExhaustiveDependencies: activeChannelId is the reset trigger; the effect intentionally clears target state when the channel changes. + React.useEffect(() => { + setPendingMainTimelineRouteTargetId(null); + }, [activeChannelId]); + React.useEffect(() => { + if (mainTimelineTargetMessageId) { + setPendingMainTimelineRouteTargetId(mainTimelineTargetMessageId); + } + }, [mainTimelineTargetMessageId]); + const effectiveMainTimelineTargetMessageId = + pendingMainTimelineRouteTargetId ?? mainTimelineTargetMessageId; React.useEffect(() => { if (openThreadHeadId && !openThreadHeadMessage) { // While the timeline is still loading (e.g. a reload restoring the @@ -814,10 +900,12 @@ export function ChannelScreen({ setThreadReplyTargetId(null); handleCloseAgentSession(); setProfilePanelPubkey(null); + handleSurfaceTabChange("messages"); setChannelManagementOpen(true); }, [ activeChannel?.channelType, channelManagementOpen, + handleSurfaceTabChange, openGlobalChannelManagement, setChannelManagementOpen, setOpenThreadHeadId, @@ -835,6 +923,7 @@ export function ChannelScreen({ activeChannel={activeChannel} activeChannelEphemeralDisplay={activeChannelEphemeralDisplay} activeChannelTitle={activeChannelTitle} + activeSurfaceTab={effectiveSurfaceTab} actionsVariant={shouldCompactHeaderActions ? "compact" : "inline"} activeDmAvatarUrl={activeDmAvatarUrl} activeDmHeaderParticipants={activeDmHeaderParticipants} @@ -846,6 +935,7 @@ export function ChannelScreen({ onAddBotOpenChange={setIsAddBotOpen} onJoinChannel={joinChannelMutation.mutateAsync} onManageChannel={handleManageChannel} + onSurfaceTabChange={handleSurfaceTabChange} onToggleMembers={handleToggleMembers} showHeaderContent={!isSinglePanelView} transparentChrome={activeChannel?.channelType !== "forum"} @@ -861,6 +951,8 @@ export function ChannelScreen({ activeDmPresenceStatus, channelHeaderChromeRef, currentPubkey, + effectiveSurfaceTab, + handleSurfaceTabChange, isAddBotOpen, joinChannelMutation.isPending, joinChannelMutation.mutateAsync, @@ -900,7 +992,7 @@ export function ChannelScreen({ activeChannel={activeChannel} activityAgents={channelAgentSessionAgents} agentConversationMarkers={agentConversationMarkers} - agentPubkeys={agentPubkeys} + agentPubkeys={routingAgentPubkeys} agentPubkeysPending={agentPubkeysPending} agentSessionAgents={agentSessionAgents} botTypingEntries={botTypingEntries} @@ -971,9 +1063,10 @@ export function ChannelScreen({ onCloseProfilePanel={handleCloseProfilePanel} onOpenThread={handleOpenThreadAndCloseAgentSession} onSelectThreadReplyTarget={handleSelectThreadReplyTarget} - onSendMessage={handleSendMessage} + onSendMessage={handleSendMessageWithDmAutoRoute} onSendVideoReviewComment={effectiveSendVideoReviewComment} onSendThreadReply={handleSendThreadReply} + onThreadScrollTargetChange={setThreadScrollTargetId} onThreadScrollTargetResolved={ handleThreadScrollTargetResolved } @@ -990,9 +1083,11 @@ export function ChannelScreen({ profilePanelView={profilePanelView} personaLookup={personaLookup} profiles={messageProfiles} + surfaceTab={effectiveSurfaceTab} + onSurfaceTabChange={handleSurfaceTabChange} firstUnreadMessageId={firstUnreadMessageId} unreadCount={unreadCount} - targetMessageId={mainTimelineTargetMessageId} + targetMessageId={effectiveMainTimelineTargetMessageId} threadHeadMessage={displayedThreadHeadMessage} threadMessages={displayedThreadMessages} threadPanelWidthPx={threadPanelWidthPx} diff --git a/desktop/src/features/channels/ui/ChannelScreen.types.ts b/desktop/src/features/channels/ui/ChannelScreen.types.ts index cb0adc617..5401c44b1 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.types.ts +++ b/desktop/src/features/channels/ui/ChannelScreen.types.ts @@ -12,6 +12,7 @@ export type ChannelScreenProps = { onCloseForumPost: () => void; onSelectForumPost: (postId: string) => void; selectedForumPostId: string | null; + targetAgentConversationReplyId: string | null; targetForumReplyId: string | null; targetMessageEvents: RelayEvent[]; targetMessageId: string | null; diff --git a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx index a3a8a2023..275789bc0 100644 --- a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx +++ b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx @@ -1,5 +1,5 @@ import { LogIn } from "lucide-react"; -import type * as React from "react"; +import * as React from "react"; import { ChatHeader } from "@/features/chat/ui/ChatHeader"; import type { EphemeralChannelDisplay } from "@/features/channels/lib/ephemeralChannel"; @@ -15,6 +15,7 @@ import { } from "@/features/profile/ui/ProfileAvatarWithStatus"; import { Button } from "@/shared/ui/button"; import type { Channel, PresenceStatus } from "@/shared/api/types"; +import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs"; import { UserAvatar } from "@/shared/ui/UserAvatar"; const DM_HEADER_AVATAR_SIZE = 32; @@ -23,10 +24,13 @@ const DM_HEADER_AVATAR_STATUS_GEOMETRY = scaleProfileAvatarStatusGeometry( DM_HEADER_AVATAR_SIZE, ); +export type ChannelSurfaceTab = "messages" | "tasks"; + type ChannelScreenHeaderProps = { activeChannel: Channel | null; activeChannelEphemeralDisplay: EphemeralChannelDisplay | null; activeChannelTitle: string; + activeSurfaceTab?: ChannelSurfaceTab; actionsVariant?: "inline" | "compact"; activeDmAvatarUrl: string | null; activeDmHeaderParticipants: ActiveDmHeaderParticipant[]; @@ -40,13 +44,20 @@ type ChannelScreenHeaderProps = { onAddBotOpenChange?: (open: boolean) => void; onJoinChannel?: () => Promise; onManageChannel: () => void; + onSurfaceTabChange?: (tab: ChannelSurfaceTab) => void; onToggleMembers: () => void; }; +const CHANNEL_SURFACE_TAB_LIST_CLASS = + "relative h-auto w-full justify-start gap-6 rounded-none bg-transparent p-0 text-muted-foreground"; +const CHANNEL_SURFACE_TAB_TRIGGER_CLASS = + "relative z-10 rounded-none border-0 bg-transparent px-0 py-2 text-sm font-medium shadow-none transition-colors duration-150 ease-out data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none"; + export function ChannelScreenHeader({ activeChannel, activeChannelEphemeralDisplay, activeChannelTitle, + activeSurfaceTab = "messages", actionsVariant = "inline", activeDmAvatarUrl, activeDmHeaderParticipants, @@ -60,6 +71,7 @@ export function ChannelScreenHeader({ transparentChrome = false, onJoinChannel, onManageChannel, + onSurfaceTabChange, onToggleMembers, }: ChannelScreenHeaderProps) { const isGroupDm = @@ -71,6 +83,76 @@ export function ChannelScreenHeader({ activeChannel.visibility === "open" && !activeChannel.archivedAt && onJoinChannel; + const showSurfaceTabs = + activeChannel?.channelType === "stream" && Boolean(onSurfaceTabChange); + const tabListRef = React.useRef(null); + const tabTriggerRefs = React.useRef< + Record + >({ + messages: null, + tasks: null, + }); + const [tabIndicator, setTabIndicator] = React.useState({ + left: 0, + width: 0, + }); + + const updateTabIndicator = React.useCallback(() => { + const list = tabListRef.current; + const trigger = tabTriggerRefs.current[activeSurfaceTab]; + + if (!showSurfaceTabs || !list || !trigger) { + return; + } + + const nextIndicator = { + left: trigger.offsetLeft, + width: trigger.offsetWidth, + }; + + setTabIndicator((current) => + Math.abs(current.left - nextIndicator.left) < 0.5 && + Math.abs(current.width - nextIndicator.width) < 0.5 + ? current + : nextIndicator, + ); + }, [activeSurfaceTab, showSurfaceTabs]); + + React.useLayoutEffect(() => { + updateTabIndicator(); + + if (!showSurfaceTabs) { + return; + } + + let isCancelled = false; + const updateIfActive = () => { + if (!isCancelled) { + updateTabIndicator(); + } + }; + const frameId = window.requestAnimationFrame(updateIfActive); + const observer = new ResizeObserver(updateTabIndicator); + const list = tabListRef.current; + + void document.fonts.ready.then(updateIfActive); + + if (list) { + observer.observe(list); + } + + for (const trigger of Object.values(tabTriggerRefs.current)) { + if (trigger) { + observer.observe(trigger); + } + } + + return () => { + isCancelled = true; + window.cancelAnimationFrame(frameId); + observer.disconnect(); + }; + }, [showSurfaceTabs, updateTabIndicator]); const actions = activeChannel ? ( showJoinButton ? ( @@ -100,8 +182,51 @@ export function ChannelScreenHeader({ return null; } + const surfaceTabs = showSurfaceTabs ? ( + + onSurfaceTabChange?.(value as ChannelSurfaceTab) + } + value={activeSurfaceTab} + > + + + + ) : undefined; + return ( Promise; + hasOlderMessages?: boolean; + isFetchingOlder?: boolean; + isTimelineLoading?: boolean; + messages: readonly TimelineMessage[]; + onOpenAgentConversation?: ( + message: TimelineMessage, + options?: { publishMarker?: boolean }, + ) => void; + onGoToTaskMessage?: ( + marker: AgentConversationMarker, + message: TimelineMessage, + threadMessage: TimelineMessage, + ) => void; + profiles?: UserProfileLookup; + scrollContainerRef: React.RefObject; +}; + +function formatTaskStartedAt(unixSeconds: number): string { + return `${formatDayHeading(unixSeconds)} at ${formatTime(unixSeconds)}`; +} + +function ChannelTaskRow({ + currentPubkey, + marker, + message, + onOpenAgentConversation, + onGoToTaskMessage, + profiles, + threadMessage, +}: { + currentPubkey?: string; + marker: AgentConversationMarker; + message: TimelineMessage | null; + onOpenAgentConversation?: ( + message: TimelineMessage, + options?: { publishMarker?: boolean }, + ) => void; + onGoToTaskMessage?: ( + marker: AgentConversationMarker, + message: TimelineMessage, + threadMessage: TimelineMessage, + ) => void; + profiles?: UserProfileLookup; + threadMessage: TimelineMessage | null; +}) { + const startedAt = marker.startedAt || marker.createdAt; + const starterName = resolveUserLabel({ + currentPubkey, + profiles, + pubkey: marker.starterPubkey, + }); + + return ( +
+
+
+ +
+
+

+ {marker.title} +

+

+ {starterName} · {formatTaskStartedAt(startedAt)} +

+
+
+ + +
+
+
+ ); +} + +export function ChannelTasksView({ + activeChannel, + agentConversationMarkers, + currentPubkey, + messages, + fetchOlder, + hasOlderMessages, + isFetchingOlder, + isTimelineLoading, + onOpenAgentConversation, + onGoToTaskMessage, + profiles, + scrollContainerRef, +}: ChannelTasksViewProps) { + const loadOlderRef = React.useRef(null); + const messageById = React.useMemo( + () => new Map(messages.map((message) => [message.id, message])), + [messages], + ); + const channelTaskMarkers = React.useMemo(() => { + const channelId = activeChannel?.id ?? null; + + return (agentConversationMarkers ?? []).filter( + (marker) => !channelId || marker.channelId === channelId, + ); + }, [activeChannel?.id, agentConversationMarkers]); + const canLoadOlderTasks = Boolean( + fetchOlder && + hasOlderMessages && + !isTimelineLoading && + (messages.length > 0 || channelTaskMarkers.length > 0), + ); + const canAutoLoadOlderTasks = + canLoadOlderTasks && channelTaskMarkers.length > 0; + const handleLoadOlderTasks = React.useCallback(() => { + if (!fetchOlder || isFetchingOlder) { + return; + } + + void fetchOlder(); + }, [fetchOlder, isFetchingOlder]); + + React.useEffect(() => { + if (!canAutoLoadOlderTasks || isFetchingOlder) { + return; + } + + const root = scrollContainerRef.current; + const target = loadOlderRef.current; + if (!root || !target) { + return; + } + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry?.isIntersecting) { + handleLoadOlderTasks(); + } + }, + { root, rootMargin: "160px 0px" }, + ); + observer.observe(target); + return () => observer.disconnect(); + }, [ + canAutoLoadOlderTasks, + handleLoadOlderTasks, + isFetchingOlder, + scrollContainerRef, + ]); + + const taskItems = React.useMemo(() => { + return channelTaskMarkers + .map((marker) => { + const message = messageById.get(marker.agentReplyId) ?? null; + const resolvedThreadMessage = + messageById.get(marker.threadRootMessageId ?? "") ?? + messageById.get(marker.threadRootId) ?? + null; + const isBroadcastTaskSource = message + ? isBroadcastReply(message.tags ?? []) + : false; + const threadMessage = + resolvedThreadMessage ?? + (marker.threadRootId === marker.agentReplyId || isBroadcastTaskSource + ? message + : null); + return { + marker, + message, + threadMessage, + }; + }) + .sort( + (left, right) => + (right.marker.startedAt || right.marker.createdAt) - + (left.marker.startedAt || left.marker.createdAt) || + right.marker.eventId.localeCompare(left.marker.eventId), + ); + }, [channelTaskMarkers, messageById]); + const olderTasksLoader = canLoadOlderTasks ? ( +
+ +
+ ) : null; + + return ( +
+
+
+ {taskItems.length === 0 ? ( + <> +
+
+ +
+

+ No tasks yet +

+

+ New tasks will appear here when an agent conversation is + opened from this channel. +

+
+ {olderTasksLoader} + + ) : ( +
+ {taskItems.map(({ marker, message, threadMessage }) => ( + + ))} + {olderTasksLoader} +
+ )} +
+
+
+ ); +} diff --git a/desktop/src/features/channels/ui/filterAgentConversationMessages.ts b/desktop/src/features/channels/ui/filterAgentConversationMessages.ts new file mode 100644 index 000000000..65153ee2a --- /dev/null +++ b/desktop/src/features/channels/ui/filterAgentConversationMessages.ts @@ -0,0 +1,54 @@ +import * as React from "react"; +import { + buildAgentConversationMarkers, + getHiddenAgentConversationMessageIds, + type AgentConversationMarker, +} from "@/features/agents/agentConversations"; +import type { TimelineMessage } from "@/features/messages/types"; +import type { RelayEvent } from "@/shared/api/types"; + +function filterHiddenAgentConversationMessages( + messages: TimelineMessage[], + markers: readonly AgentConversationMarker[] | undefined, +): TimelineMessage[] { + const hiddenMessageIds = getHiddenAgentConversationMessageIds( + messages, + markers, + ); + if (hiddenMessageIds.size === 0) { + return messages; + } + + return messages.filter((message) => !hiddenMessageIds.has(message.id)); +} + +export function useUnreadTimelineMessages( + messages: TimelineMessage[], + markers: readonly AgentConversationMarker[] | undefined, +): TimelineMessage[] { + return React.useMemo( + () => filterHiddenAgentConversationMessages(messages, markers), + [markers, messages], + ); +} + +export function useAgentConversationMarkers( + messages: RelayEvent[], +): AgentConversationMarker[] { + return React.useMemo( + () => buildAgentConversationMarkers(messages), + [messages], + ); +} + +export function useAgentConversationTimelineState( + events: RelayEvent[], + messages: TimelineMessage[], +) { + const agentConversationMarkers = useAgentConversationMarkers(events); + const unreadTimelineMessages = useUnreadTimelineMessages( + messages, + agentConversationMarkers, + ); + return { agentConversationMarkers, unreadTimelineMessages }; +} diff --git a/desktop/src/features/channels/ui/useAgentConversationRouteTarget.ts b/desktop/src/features/channels/ui/useAgentConversationRouteTarget.ts new file mode 100644 index 000000000..5637d3968 --- /dev/null +++ b/desktop/src/features/channels/ui/useAgentConversationRouteTarget.ts @@ -0,0 +1,101 @@ +import * as React from "react"; + +import type { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import type { OpenAgentConversationInput } from "@/features/agents/agentConversations"; +import type { TimelineMessage } from "@/features/messages/types"; +import type { Channel } from "@/shared/api/types"; + +type GoChannel = ReturnType["goChannel"]; +type OpenAgentConversation = ( + input: OpenAgentConversationInput, + options?: { publishMarker?: boolean }, +) => void; + +type UseAgentConversationRouteTargetOptions = { + activeChannel: Channel | null; + activeChannelId: string | null; + goChannel: GoChannel; + messageProfilesReady: boolean; + openAgentConversation: OpenAgentConversation; + targetAgentConversationReplyId: string | null; + timelineMessages: readonly TimelineMessage[]; +}; + +export function useAgentConversationRouteTarget({ + activeChannel, + activeChannelId, + goChannel, + messageProfilesReady, + openAgentConversation, + targetAgentConversationReplyId, + timelineMessages, +}: UseAgentConversationRouteTargetOptions) { + const handledRouteTargetRef = React.useRef(null); + + React.useEffect(() => { + if (!targetAgentConversationReplyId) { + handledRouteTargetRef.current = null; + return; + } + + const targetKey = `${activeChannelId ?? "none"}:${targetAgentConversationReplyId}`; + if (handledRouteTargetRef.current === targetKey) { + return; + } + if (!activeChannel || activeChannel.channelType === "forum") { + return; + } + if (!messageProfilesReady) { + return; + } + + const agentReply = + timelineMessages.find( + (message) => message.id === targetAgentConversationReplyId, + ) ?? null; + const agentReplyPubkey = agentReply?.pubkey; + if (!agentReply || !agentReplyPubkey) { + return; + } + + const rootId = agentReply.rootId ?? agentReply.parentId ?? agentReply.id; + const contextMessages = timelineMessages.filter( + (candidate) => + candidate.id === rootId || + candidate.id === agentReply.id || + candidate.rootId === rootId || + candidate.parentId === rootId, + ); + const parentMessage = agentReply.parentId + ? (timelineMessages.find( + (candidate) => candidate.id === agentReply.parentId, + ) ?? null) + : null; + const threadRootMessage = + timelineMessages.find((candidate) => candidate.id === rootId) ?? null; + + handledRouteTargetRef.current = targetKey; + void goChannel(activeChannel.id, { replace: true }).then(() => { + openAgentConversation( + { + agentName: agentReply.author, + agentPubkey: agentReplyPubkey, + agentReply, + channel: activeChannel, + contextMessages, + parentMessage, + threadRootMessage, + }, + { publishMarker: false }, + ); + }); + }, [ + activeChannel, + activeChannelId, + goChannel, + messageProfilesReady, + openAgentConversation, + targetAgentConversationReplyId, + timelineMessages, + ]); +} diff --git a/desktop/src/features/channels/ui/useChannelRouteTarget.ts b/desktop/src/features/channels/ui/useChannelRouteTarget.ts index 0dc4b0e4d..bf288bba6 100644 --- a/desktop/src/features/channels/ui/useChannelRouteTarget.ts +++ b/desktop/src/features/channels/ui/useChannelRouteTarget.ts @@ -45,7 +45,11 @@ function getRouteMainTimelineTargetId( return null; } - if (!targetMessage?.parentId || isBroadcastReply(targetMessage.tags ?? [])) { + if (!targetMessage) { + return null; + } + + if (!targetMessage.parentId || isBroadcastReply(targetMessage.tags ?? [])) { return targetMessageId; } @@ -88,6 +92,10 @@ export function useChannelRouteTarget({ targetMessageId, targetTimelineMessage, ); + const rootThreadHeadTargetId = + targetTimelineMessage && !targetTimelineMessage.parentId + ? targetTimelineMessage.id + : null; const handledThreadRouteTargetRef = React.useRef(null); React.useEffect(() => { @@ -115,18 +123,9 @@ export function useChannelRouteTarget({ } if (!targetMessage.parentId) { - closeAgentSession(); - // Root message links should open the reply panel for that root. The - // timeline scroll/highlight target alone is not enough: root links have - // no parent/thread metadata, so the reply-only branch below cannot infer - // a thread head. - setProfilePanelPubkey(null, { replace: true }); - setEditTargetId(null); - setOpenThreadHeadId(targetMessage.id, { replace: true }); - setThreadReplyTargetId(targetMessage.id); - setThreadScrollTargetId(null); - setExpandedThreadReplyIds(new Set()); - handledThreadRouteTargetRef.current = targetKey; + // Root links still need to open the reply panel, but not until the main + // timeline has centered the row. Opening the panel first changes the main + // column layout mid-jump and can make the virtualized target abandon early. return; } @@ -166,5 +165,5 @@ export function useChannelRouteTarget({ timelineMessageById, ]); - return mainTimelineTargetMessageId; + return { mainTimelineTargetMessageId, rootThreadHeadTargetId }; } diff --git a/desktop/src/features/channels/ui/useResetChannelSurfaceTabOnRouteOpen.ts b/desktop/src/features/channels/ui/useResetChannelSurfaceTabOnRouteOpen.ts new file mode 100644 index 000000000..4158a9157 --- /dev/null +++ b/desktop/src/features/channels/ui/useResetChannelSurfaceTabOnRouteOpen.ts @@ -0,0 +1,45 @@ +import * as React from "react"; +import type { ChannelSurfaceTab } from "./ChannelScreenHeader"; + +type RouteSnapshot = { + activeChannelId: string | null; + openThreadHeadId: string | null; + targetMessageId: string | null; +}; + +type UseResetChannelSurfaceTabOnRouteOpenOptions = RouteSnapshot & { + setActiveSurfaceTab: React.Dispatch>; +}; + +export function useResetChannelSurfaceTabOnRouteOpen({ + activeChannelId, + openThreadHeadId, + setActiveSurfaceTab, + targetMessageId, +}: UseResetChannelSurfaceTabOnRouteOpenOptions) { + const previousRouteRef = React.useRef({ + activeChannelId, + openThreadHeadId, + targetMessageId, + }); + + React.useEffect(() => { + const previous = previousRouteRef.current; + const shouldReset = + previous.activeChannelId !== activeChannelId || + (openThreadHeadId !== null && + previous.openThreadHeadId !== openThreadHeadId) || + (targetMessageId !== null && + previous.targetMessageId !== targetMessageId); + + previousRouteRef.current = { + activeChannelId, + openThreadHeadId, + targetMessageId, + }; + + if (shouldReset) { + setActiveSurfaceTab("messages"); + } + }, [activeChannelId, openThreadHeadId, setActiveSurfaceTab, targetMessageId]); +} diff --git a/desktop/src/features/channels/useChannelPaneHandlers.ts b/desktop/src/features/channels/useChannelPaneHandlers.ts index 8491f930d..e753f4cbb 100644 --- a/desktop/src/features/channels/useChannelPaneHandlers.ts +++ b/desktop/src/features/channels/useChannelPaneHandlers.ts @@ -8,7 +8,6 @@ import type { useSendMessageMutation, useToggleReactionMutation, } from "@/features/messages/hooks"; -import { mergeAutoRouteMentionPubkeys } from "@/features/channels/ui/ChannelPane.helpers"; /** * Stable callback references for ChannelPane so that keystroke-driven @@ -22,7 +21,6 @@ export function useChannelPaneHandlers({ deleteMessageMutation, editMessageMutation, editTargetId, - messageAutoRouteAgentPubkeys, expandedThreadReplyIds, getFirstReplyIdForMessage, getReplyDescendantIdsForMessage, @@ -35,14 +33,12 @@ export function useChannelPaneHandlers({ setOpenThreadHeadId, setThreadReplyTargetId, setThreadScrollTargetId, - threadAutoRouteAgentPubkeys, threadReplyTargetId, toggleReactionMutation, }: { deleteMessageMutation: ReturnType; editMessageMutation: ReturnType; editTargetId: string | null; - messageAutoRouteAgentPubkeys: readonly string[]; expandedThreadReplyIds: ReadonlySet; getFirstReplyIdForMessage: (messageId: string) => string | null; getReplyDescendantIdsForMessage: (messageId: string) => string[]; @@ -57,7 +53,6 @@ export function useChannelPaneHandlers({ setOpenThreadHeadId: (value: string | null) => void; setThreadReplyTargetId: React.Dispatch>; setThreadScrollTargetId: React.Dispatch>; - threadAutoRouteAgentPubkeys: readonly string[]; threadReplyTargetId: string | null; toggleReactionMutation: ReturnType; }) { @@ -74,16 +69,6 @@ export function useChannelPaneHandlers({ const expandedThreadReplyIdsRef = React.useRef(expandedThreadReplyIds); expandedThreadReplyIdsRef.current = expandedThreadReplyIds; - const messageAutoRouteAgentPubkeysRef = React.useRef( - messageAutoRouteAgentPubkeys, - ); - messageAutoRouteAgentPubkeysRef.current = messageAutoRouteAgentPubkeys; - - const threadAutoRouteAgentPubkeysRef = React.useRef( - threadAutoRouteAgentPubkeys, - ); - threadAutoRouteAgentPubkeysRef.current = threadAutoRouteAgentPubkeys; - const sendMutateRef = React.useRef(sendMessageMutation.mutateAsync); sendMutateRef.current = sendMessageMutation.mutateAsync; @@ -242,10 +227,7 @@ export function useChannelPaneHandlers({ ) => { await sendMutateRef.current({ content, - mentionPubkeys: mergeAutoRouteMentionPubkeys({ - autoRouteAgentPubkeys: messageAutoRouteAgentPubkeysRef.current, - mentionPubkeys, - }), + mentionPubkeys, mediaTags, }); }, @@ -279,10 +261,7 @@ export function useChannelPaneHandlers({ const sentMessage = await sendMutateRef.current({ content, - mentionPubkeys: mergeAutoRouteMentionPubkeys({ - autoRouteAgentPubkeys: threadAutoRouteAgentPubkeysRef.current, - mentionPubkeys, - }), + mentionPubkeys, parentEventId, mediaTags, }); diff --git a/desktop/src/features/chat/ui/ChatHeader.tsx b/desktop/src/features/chat/ui/ChatHeader.tsx index 67968dd18..bbdc41772 100644 --- a/desktop/src/features/chat/ui/ChatHeader.tsx +++ b/desktop/src/features/chat/ui/ChatHeader.tsx @@ -25,6 +25,8 @@ type ChatHeaderProps = { actions?: React.ReactNode; animatedTitle?: boolean; animatedTitleResetKey?: string; + belowTitleContent?: React.ReactNode; + belowTitleContentClassName?: string; belowSystemChrome?: boolean; compactTitleStack?: boolean; /** Ref to the outer chrome wrapper when `belowSystemChrome` is true. */ @@ -42,10 +44,17 @@ type ChatHeaderProps = { /** Render the chrome wrapper without an individual backdrop when a parent supplies shared blur. */ transparentChrome?: boolean; subtitle?: string | null; + showCopyTitle?: boolean; + titleAction?: { + ariaLabel: string; + onClick: () => void; + title?: string; + }; + titleTrailingContent?: React.ReactNode; }; const HEADER_ICON_CLASS = "h-4 w-4 text-muted-foreground"; -const CHANNEL_HASH_ICON_CLASS = "h-4 w-4 translate-y-px"; +const CHANNEL_HASH_ICON_CLASS = "h-4 w-4 translate-y-px text-foreground"; function ChannelIcon({ channelType, @@ -88,13 +97,15 @@ function ChannelIcon({ return ; } - return ; + return ; } export function ChatHeader({ actions, animatedTitle = false, animatedTitleResetKey, + belowTitleContent, + belowTitleContentClassName, belowSystemChrome = false, compactTitleStack = false, chromeWrapperRef, @@ -109,7 +120,10 @@ export function ChatHeader({ overlaysContent = false, statusBadge, transparentChrome = false, + showCopyTitle = true, subtitle, + titleAction, + titleTrailingContent, }: ChatHeaderProps) { const trimmedDescription = description?.trim() ?? ""; const trimmedSubtitle = subtitle?.trim() ?? ""; @@ -143,17 +157,31 @@ export function ChatHeader({ const header = (
+ {belowTitleContent ? ( +