From 85655c019289b1ea3a1fd4224d622f6a16f96d37 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Fri, 26 Jun 2026 18:54:58 +0100 Subject: [PATCH 01/33] Refine task conversations --- desktop/scripts/check-file-sizes.mjs | 18 +- desktop/src-tauri/src/deep_link.rs | 60 +- .../src/app/navigation/useAppNavigation.ts | 18 +- desktop/src/app/routes/ChannelRouteScreen.tsx | 20 +- .../channels.$channelId.posts.$postId.tsx | 1 + .../src/app/routes/channels.$channelId.tsx | 3 + .../features/agents/agentConversationLink.ts | 24 + .../agents/ui/AgentConversationScreen.tsx | 96 ++- .../src/features/channels/ui/ChannelPane.tsx | 618 +++++++++++++----- .../features/channels/ui/ChannelPane.types.ts | 2 + .../features/channels/ui/ChannelScreen.tsx | 115 +++- .../channels/ui/ChannelScreen.types.ts | 1 + .../channels/ui/ChannelScreenHeader.tsx | 127 +++- desktop/src/features/chat/ui/ChatHeader.tsx | 109 ++- .../ui/AgentConversationMarkerRow.tsx | 6 +- .../features/messages/ui/MessageActionBar.tsx | 8 +- desktop/src/shared/deep-link.ts | 20 + desktop/src/shared/useMessageDeepLinks.ts | 18 +- 18 files changed, 1029 insertions(+), 235 deletions(-) create mode 100644 desktop/src/features/agents/agentConversationLink.ts diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 06e4e9f4b..5c8f93d89 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,11 +140,13 @@ 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. + // 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, and side-panel suppression sit at the channel orchestration seam. + // latest main rebase threads additional header routing through this seam. ["src/features/channels/ui/ChannelScreen.tsx", 1027], // continued-agent-conversations: composer notice banner for read-only agent // conversations. 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..dc828d75c 100644 --- a/desktop/src/app/routes/ChannelRouteScreen.tsx +++ b/desktop/src/app/routes/ChannelRouteScreen.tsx @@ -17,6 +17,7 @@ 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; @@ -96,6 +97,7 @@ async function fetchRouteTargetEvents( export function ChannelRouteScreen({ channelId, selectedPostId, + targetAgentConversationReplyId, targetMessageId, targetReplyId, targetThreadRootId, @@ -140,7 +142,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; }; @@ -156,6 +163,7 @@ export function ChannelRouteScreen({ } const eventIds = [ + targetAgentConversationReplyId, targetMessageId, targetThreadRootId && targetThreadRootId !== targetMessageId ? targetThreadRootId @@ -164,7 +172,7 @@ export function ChannelRouteScreen({ void fetchRouteTargetEvents( eventIds, - targetMessageId, + targetAgentConversationReplyId ?? targetMessageId, targetThreadRootId, ).then((events) => { if (!isCancelled) { @@ -181,7 +189,12 @@ export function ChannelRouteScreen({ return () => { isCancelled = true; }; - }, [selectedPostId, targetMessageId, targetThreadRootId]); + }, [ + selectedPostId, + targetAgentConversationReplyId, + targetMessageId, + targetThreadRootId, + ]); if (channelsQuery.isPending && !activeChannel) { return ( @@ -204,6 +217,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() { - {isPublishingThreadSummary - ? "Generating recap..." - : "Send recap to thread"} + {isPublishingThreadSummary ? "Generating recap..." : "Send recap"} @@ -718,25 +718,53 @@ 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 headerTitleTrailingContent = ( + <> + + + + + + + + + Copy task link + + ); return ( @@ -748,16 +776,22 @@ export function AgentConversationScreen({ > onBackToThread(conversation), + title: "Back to source thread", + } + : undefined + } + titleTrailingContent={headerTitleTrailingContent} + visibility={sourceChannelVisibility} /> void; + onGoToTaskMessage?: ( + marker: AgentConversationMarker, + message: TimelineMessage, + threadMessage: TimelineMessage, + ) => void; + profiles?: UserProfileLookup; + threadMessage: TimelineMessage; +}) { + const startedAt = marker.startedAt || marker.createdAt; + const starterName = resolveUserLabel({ + currentPubkey, + profiles, + pubkey: marker.starterPubkey, + }); + + return ( +
+
+
+ +
+
+

+ {marker.title} +

+

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

+
+
+ + +
+
+
+ ); +} + +function ChannelTasksView({ + activeChannel, + agentConversationMarkers, + currentPubkey, + messages, + onOpenAgentConversation, + onGoToTaskMessage, + profiles, + scrollContainerRef, +}: { + activeChannel: Channel | null; + agentConversationMarkers?: readonly AgentConversationMarker[]; + currentPubkey?: string; + messages: readonly TimelineMessage[]; + onOpenAgentConversation?: ( + message: TimelineMessage, + options?: { publishMarker?: boolean }, + ) => void; + onGoToTaskMessage?: ( + marker: AgentConversationMarker, + message: TimelineMessage, + threadMessage: TimelineMessage, + ) => void; + profiles?: UserProfileLookup; + scrollContainerRef: React.RefObject; +}) { + const messageById = React.useMemo( + () => new Map(messages.map((message) => [message.id, message])), + [messages], + ); + const taskItems = React.useMemo(() => { + const channelId = activeChannel?.id ?? null; + + return (agentConversationMarkers ?? []) + .filter((marker) => !channelId || marker.channelId === channelId) + .map((marker) => { + const message = + messageById.get(marker.agentReplyId) ?? + buildTaskFallbackMessage(marker); + const threadMessage = + messageById.get(marker.threadRootMessageId ?? "") ?? + messageById.get(marker.threadRootId) ?? + messageById.get(marker.parentMessageId ?? "") ?? + message; + 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), + ); + }, [activeChannel?.id, agentConversationMarkers, messageById]); + + return ( +
+
+
+ {taskItems.length === 0 ? ( +
+
+ +
+

+ No tasks yet +

+

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

+
+ ) : ( +
+ {taskItems.map(({ marker, message, threadMessage }) => ( + + ))} +
+ )} +
+
+
+ ); +} export const ChannelPane = React.memo(function ChannelPane({ activeChannel, agentConversationMarkers, @@ -107,6 +352,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onOpenMembers, onOpenProfilePanel, onOpenThread, + onSurfaceTabChange, onResetThreadPanelWidth, onSelectThreadReplyTarget, onSendMessage, @@ -123,6 +369,7 @@ export const ChannelPane = React.memo(function ChannelPane({ openThreadHeadId, shouldShowThreadSkeleton, openAgentSessionPubkey, + surfaceTab = "messages", onProfilePanelViewChange, onProfilePanelTabChange, profilePanelPubkey, @@ -144,6 +391,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,7 +406,8 @@ 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), @@ -165,6 +417,14 @@ export const ChannelPane = React.memo(function ChannelPane({ agentPubkeysPending && hasOtherDmParticipant(activeChannel, currentPubkey); const isActiveWelcomeChannel = activeChannel !== null && isWelcomeChannel(activeChannel); + React.useEffect(() => { + if (previousTaskFocusChannelIdRef.current === activeChannelId) { + return; + } + + previousTaskFocusChannelIdRef.current = activeChannelId; + setTaskFocusMessageId(null); + }, [activeChannelId]); useComposerHeightPadding( timelineScrollRef, composerWrapperRef, @@ -359,6 +619,34 @@ export const ChannelPane = React.memo(function ChannelPane({ }, [activeChannel, messages, openAgentConversation], ); + const handleGoToTaskMessage = React.useCallback( + ( + marker: AgentConversationMarker, + message: TimelineMessage, + threadMessage: TimelineMessage, + ) => { + onSurfaceTabChange?.("messages"); + if (marker.parentMessageId) { + onOpenThread(threadMessage); + return; + } + + onCloseThread(); + setTaskFocusMessageId(message.id); + }, + [onCloseThread, onOpenThread, onSurfaceTabChange], + ); + 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; @@ -687,7 +975,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 ? ( @@ -856,7 +1168,7 @@ export const ChannelPane = React.memo(function ChannelPane({ ) : 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: ( @@ -97,6 +98,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..1a06b851c 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -17,7 +17,10 @@ import { THREAD_PREFIX, } from "@/features/channels/readState/readStateFormat"; 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, @@ -91,17 +94,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 +166,14 @@ export function ChannelScreen({ const mainInsetRef = useMainInsetRef(); const currentPubkey = currentIdentity?.pubkey; const activeChannelId = activeChannel?.id ?? null; + React.useEffect(() => { + if (activeChannelId === null) { + setActiveSurfaceTab("messages"); + return; + } + + setActiveSurfaceTab("messages"); + }, [activeChannelId]); const effectiveOpenThreadHeadId = optimisticOpenThreadHeadId === undefined ? openThreadHeadId @@ -706,6 +721,96 @@ export function ChannelScreen({ React.useEffect(() => { resetComposerTargets(activeChannelId); }, [activeChannelId, resetComposerTargets]); + 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, + ], + ); + const handledAgentConversationRouteTargetRef = React.useRef( + null, + ); + React.useEffect(() => { + if (!targetAgentConversationReplyId) { + handledAgentConversationRouteTargetRef.current = null; + return; + } + + const targetKey = `${activeChannelId ?? "none"}:${targetAgentConversationReplyId}`; + if (handledAgentConversationRouteTargetRef.current === targetKey) { + return; + } + if (!activeChannel || activeChannel.channelType === "forum") { + 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; + + handledAgentConversationRouteTargetRef.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, + openAgentConversation, + targetAgentConversationReplyId, + timelineMessages, + ]); const mainTimelineTargetMessageId = useChannelRouteTarget({ activeChannel, activeChannelId, @@ -835,6 +940,7 @@ export function ChannelScreen({ activeChannel={activeChannel} activeChannelEphemeralDisplay={activeChannelEphemeralDisplay} activeChannelTitle={activeChannelTitle} + activeSurfaceTab={activeSurfaceTab} actionsVariant={shouldCompactHeaderActions ? "compact" : "inline"} activeDmAvatarUrl={activeDmAvatarUrl} activeDmHeaderParticipants={activeDmHeaderParticipants} @@ -846,6 +952,7 @@ export function ChannelScreen({ onAddBotOpenChange={setIsAddBotOpen} onJoinChannel={joinChannelMutation.mutateAsync} onManageChannel={handleManageChannel} + onSurfaceTabChange={handleSurfaceTabChange} onToggleMembers={handleToggleMembers} showHeaderContent={!isSinglePanelView} transparentChrome={activeChannel?.channelType !== "forum"} @@ -859,8 +966,10 @@ export function ChannelScreen({ activeDmAvatarUrl, activeDmHeaderParticipants, activeDmPresenceStatus, + activeSurfaceTab, channelHeaderChromeRef, currentPubkey, + handleSurfaceTabChange, isAddBotOpen, joinChannelMutation.isPending, joinChannelMutation.mutateAsync, @@ -990,6 +1099,8 @@ export function ChannelScreen({ profilePanelView={profilePanelView} personaLookup={personaLookup} profiles={messageProfiles} + surfaceTab={activeSurfaceTab} + onSurfaceTabChange={handleSurfaceTabChange} firstUnreadMessageId={firstUnreadMessageId} unreadCount={unreadCount} targetMessageId={mainTimelineTargetMessageId} 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 ( 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 ? ( +